首先,良好的代码和劣质的代码都是可以编译的代码。
其次,不论良好的代码还是劣质的代码都有其成因。
良好的代码 | 劣质的代码 |
合理的缩进 | 混乱的缩进 |
有意义的注释 | 解释显而易见的代码 |
API文档注释 | 解释低质量的代码,将代码注释掉 |
使用命名空间合理组织代码 | 命名空间组织混乱 |
合理的命名规则 | 混乱的命名规则 |
一个类执行一种任务 | 一个类执行多种任务 |
一个方法做一件事情 | 一个方法做多件事情 |
方法的代码少于10行,通常小于4行 | 方法的代码大于10行 |
方法的参数不多于两个 | 方法的参数大于两个 |
合理使用异常 | 使用异常控制程序的执行流程 |
代码可读性强 | 代码可读性弱 |
代码耦合程度低 | 代码耦合紧密 |
高内聚的代码 | 低内聚的代码 |
对象会被恰当销毁 | 遗留对象 |
避免使用Finalize()方法 | 使用Finalize()方法 |
合理地进行抽象 | 代码过度设计 |
在大型类中使用#region进行区域划分 | 大型类中缺少区域划分 |
封装并隐藏信息 | 直接暴露信息 |
面向对象的代码 | 面条式的代码 |
设计模式 | 设计反模式 |
1. 劣质的代码
混乱的缩进
混乱的缩进会令代码难以阅读,在方法过长时尤为如此。
为了提高代码的可读性,需要进行合理的缩进。
混乱的缩进会凌然难以区分代码块之间的归属。
Visual Studio 默认会在括号或花括号闭合时正确地格式化并缩进代码。
但是这种格式化功能在代码包含异常情况时并非总是正确的,因此不正确的格式化往往能够引起你的注意。
但是如果使用普遍的文本编辑器,你就只能手动格式化代码了。
修正错误的缩进是一个费时的操作吗,花费大量编程时间来弥补这种易于避免的错误往往令人沮丧。
请看如下代码:
public void DoSomething()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Hello World");
}
}
上述代码虽然格式不佳但终究还是能够阅读的。
但是随着代码行数的增加,可读性也会随之下降。
当缩进不佳时很容易发生遗漏闭合括号的情况。
由于不容易分辨到底是哪个代码块遗漏了括号,因此要找到遗漏括号所在的位置就变得更难了。
解释显而易见的代码
我经常见到程序员对着一些显而易见的注释一筹莫展,也不止一次地在编程讨论中听程序员宣传他们如何讨厌代码注释。
它们认为,代码本身就应该有自解释能力。
我非常理解它们的感受。
如果能像读一本书那样读一段没有注释的代码,那么这段代码一定非常优秀。
而在字符串类型的变量声明后加上//string注释就显得很多余了。
请看以下范例:
public int _value; //This is used for storing integer value
由于变量的类型为int,因此其值必然为整数。
在这种地方继续用注释进行说明是没有必要的。
它不但会浪费时间和精力,还会把代码弄得一团糟。
解释低质量的代码
即使是工期紧张也不要做这种注释://我知道这段代码不怎么样但是至少它可以工作
这不但却反专业精神也会令其他程序员不满。
如果你真的需要尽快做出成果,那么可以创建一个重构标记,并将这个标记作为TODO注释的一部分。
例如://TODO:PBI23154 REFACTOR CODE TO MEET COMPANY CODING PRACTICES。
之后不论是你还是其他处理技术债的同事就可以从产品待办项中挑选这项任务并完成代码重构。
将代码注释掉
在尝试过程中将代码注释掉无可厚非,但是如果决定保留其他代码而放弃注释掉的代码,则最好在检入代码之前删除注释掉的代码。
有一两条注释掉的代码可能还不会太糟。
但是如果注释掉的代码过多,不但会分散注意力,使代码更难维护,甚至还会造成混淆。
/* No longer used as has been replaced by DoSomething()
public void IDidSomething()
{
// ...implementation...
}
*/
保留这段注释是没有必要的。
如果它已经被其他代码替代了,那么请删除它。
如果使用了版本控制系统,则可以浏览这个文件的历史并在需要时将方法找回。
命名空间组织混乱
当使用命名空间组织代码时,务必避免将无关的代码放置在命名空间中。
这将会令人难以找到甚至无法找到所需的代码,在规模庞大的代码库中尤为如此。
例如:
/// <summary>
/// Example of bad organisation.
/// </summary>
namespace MyProject.TextFileMonitor
{
public class Program { }
public class DateTime { }
public class FileMonitorService { }
public class Cryptography { }
}
上例所有的类位于同一个命名空间下,而将其划分在如下三个命名空间会更加合理;
- MyProject.TextFileMonitor.Core:该命名空间存放所有可以作为服务的类。例如:DateTime类。
- MyProject.TextFileMonitor.Services:该命名空间存放所有可以作为服务的类。例如:FileMonitorService。
- MyProject.TextFileMonitor.Security:该命名空间存放与安全相关的类。例如范例中的Cryptography类
混乱的命名规则
在使用VisaulBasic6编程的那个年代通常使用匈牙利命名法。
而Visual Basic .NET中却无须再使用匈牙利命名法了。
相反,匈牙利命名法会令代码变得丑陋。
因此不要再使用lblName、txtName和btnSave这命名方式了,请使用NameLabel、NameTextBox和SaveButton这种现代命名方式吧。
使用晦涩难懂与言不由衷的命名会令代码阅读变得异常艰难。
inri原来是Human Resources Index(人力资源索引),而且它还是一个整数类型,这里肯定想不到吧。
同时请避免使用诸如mystring、myint和mymethod这类命名,因为这些命名无法表达实际含义。
不要在单词之间加入下划线,例如Bad_Programmer。
它会给开发人员带来视觉压力并使代码更难阅读。
移除下划线就好了。
不要在类级别和方法级别的变量名上使用相同的命名规则,否则将难以区分变量的作用域。
推荐使用驼峰命名法为变量命名,例如alienSpawn;
使用Pascal命名法为方法、类、结构体和接口命名,例如EnmySpawnGenerator。
在成员变量(在类的构造器和方法之外定义的变量)的名称前添加下划线前缀可以使我们轻易地区分局部变量(在构造器或方法中的变量)和成员变量。
我在工作中就会这种规则,它效果良好而且为程序员所接受。
一个类执行多种任务
我曾经在工作中迷失在拥有太多层缩进、做了太多事情的方法中,其中的逻辑排列令人难以驾驭。
我确定如果能够将其划分为不同的方法,就可以明显缩减原有方法的大小。
以下例子中的方法接收一个字符串,并将其加密和解密。
这个方法很长,这样就更容易说明为何方法要保持短小:
public string security(string plainText)
{
try
{
byte[] encrypted;
using (AesManaged aes = new AesManaged())
{
ICryptoTransform encryptor = aes.CreateEncryptor(Key, IV);
using (MemoryStream ms = new MemoryStream())
using (CryptoStream cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
{
using (StreamWriter sw = new StreamWriter(cs))
sw.Write(plainText);
encrypted = ms.ToArray();
}
}
Console.WriteLine($"Encrypted data: {System.Text.Encoding.UTF8.GetString(encrypted)}");
using (AesManaged aesm = new AesManaged())
{
ICryptoTransform decryptor = aesm.CreateDecryptor(Key, IV);
using (MemoryStream ms = new MemoryStream(encrypted))
{
using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
{
using (StreamReader reader = new StreamReader(cs))
plainText = reader.ReadToEnd();
}
}
}
Console.WriteLine($"Decrypted data: {plainText}");
}
catch (Exception exp)
{
Console.WriteLine(exp.Message);
}
Console.ReadKey();
return plainText;
}
上述方法包含超过10行代码,难以阅读。
此外,该方法有多于一种职责。
我们可以将上述代码分割为两个方法,每一个执行一种任务。
一个可以进行字符串加密操作,而另外一个进行字符串的解密操作。
方法代码大于10行
过长的方法不易阅读与理解。
并可能产生不易觉察的缺陷。
过长的方法的另一个问题是容易偏离方法原本的目标。
如果方法代码还被注释或区域分割为若干部分,这些弊端就更为显著。
如果必须上下滚动才能浏览方法的全貌,则这个方法应该是过长了。
这会给程序员阅读代码造成压力并产生误解,进而在修改该方法时可能破坏原因代码或曲解代码意图。
方法应当尽可能短小,但这需要加以练习,否则可能会将一个小方法过分细分。
保持平衡的关键在于确保方法的意图明确,实现整洁。
方法的参数大于两个
若方法参数很多则会稍显笨重,这不但不利于阅读,而且容易搞错参数的值从而破坏类型安全性。
方法的参数越多则参数的排列方式就越多,因而测试起来也越复杂,更容易丢失测试用例并造成产品的缺陷。
使用异常控制程序的执行流程
使用异常来控制程序流程容易隐藏代码的意图,导致意料之外的结果。
事实上,如果在编写代码的时候就预期代码会抛出一种或多种异常很有可能意味着设计上的问题。
使用业务规则异常(Business Rule Exception,BRE)来控制程序流程就是一个典型情况。
例如,方法在某些异常发生时会执行特定动作,即程序的流程会由于是否存在异常而确定。
而更好的方式是使用语言本身提供的结构来验证布尔值。
以下代码展示了使用BRE控制程序流程的做法:
public void BreFlowControlExample(BusinessRuleException bre)
{
switch (bre.Message)
{
case "OutOfAcceptableRange":
DoOutOfAcceptableRangeWork();
break;
default:
DoInAcceptableRangeWork();
break;
}
}
BreFlowControlExample()方法接收BusinessRuleException类的参数,并根据异常中消息的内容来决定应用调用DoOutOfAcceptableRangeWork()还是DoInAcceptableRangeWork()方法。
更好的做法是使用布尔逻辑来控制流程。
例如下面的BreFlowControlExample()方法:
public void BetterFlowControlExample(bool isInAcceptableRange)
{
if (isInAcceptableRange)
DoInAcceptableRangeWork();
else
DoOutOfAcceptableRangeWork();
}
以上方法接收一个布尔值,并使用该布尔值判断采取哪一条执行路径。
如果isInAcceptableRange满足判断条件,则调用DoInAcceptableRangeWork()方法;
反之,将调用DoOutOfAcceptableRangeWork()方法。
代码可读性弱
类似千层面或者意大利面的代码是难以阅读与理解的。
命名不当的方法可以掩盖其原意,也同样令然烦恼。
加上如果方法还很长,且关联方法被多个不相关的方法分割就更难以理解了。
千层面代码,指一般所说的间接的、引用抽象层级的代码。
这种引用指名称的引用而非动作的引用。
在面向对象编程OOP中,层的使用很常见,并通常都有好的效果。
但是间接引用越多,代码就越复杂。
此类代码会令项目上新程序员了解代码的过程越发艰难。
因此,维持间接性和易理解性之间的平衡就显得尤为重要。
而意大利面代码,指那些杂乱无章的低内聚紧耦合的代码。
这样的代码难以维护、重构、扩展和重新设计。
从积极的方面说,由于这种程序往往更加过程化,因此也许更易于阅读和模仿。
代码耦合紧密
耦合紧密的代码难以测试、扩展和修改。
同时依赖系统其他代码的代码也不易复用。
代码在参数中引用具体类的类型而非接口就是代码紧耦合的一个范例。
低内聚的代码
低内聚的代码指将执行不同功能的不相干代码聚合在一起的代码形式。
例如工具类中包含多种处理日期、文本、数字、进行文件读写、数据验证、加密解密等功能的方法。
遗留对象
当对象遗留在内存中时,它们可能导致内存泄露。
内存泄漏通常是指程序在运行过程中未能正确释放不再使用的对象,导致这些对象占用的内存无法被垃圾回收器(GC)回收。
常见的内存泄露原因如下:
- 事件订阅未解除
- 静态字段持有对象引用
- 未释放非托管资源
- 闭包捕获外部变量
使用Finalize()方法
终结器虽可用于释放没有被正确销毁的对象中的资源并避免内存泄露,但是它仍然有很多缺点。
首先,终结器的调用时机是不确定的。
其次,垃圾收集器在回收之前,会将含有终结器的对象及其对象图中所有依赖的对象提升到下一代内存中,直到垃圾回收器将其回收。
这意味着这些对象将在内存中停留较长时间。
因此,在使用终结器的情况下,如果创建对象的速度比垃圾回收的速度快,则会发生内存用尽异常。
代码过度设计
过度设计可能会成为十足的麻烦。
对任何人来说,在一个庞大的系统中跋涉,去理解它、使用它以及找到功能的位置都是耗时耗力的。
更麻烦的是,当没有文档时,你对系统很陌生,甚至对系统熟悉的人也无法解决你的问题。
大型类中缺少区域划分
若一个大型类中拥有太多的区域,并且方法并没有归并分类,其代码就是难以阅读和理解。
区域可以将相似的成员聚拢在一起。
失去焦点的代码
当一个类做的事情太多时,就往往会忘记其原始意图。
在处理输入输出的命名空间的文件类中找到了处理日期的方法,这并不合理!
直接暴露信息
在类中直接暴露信息是错误的。
除去造成紧密的耦合并容易导致缺点之外,如需更高相应信息的类型则必须更改任何使用该信息的代码。
2. 良好的代码
介绍完错误的实践后,是时候来看一看良好的代码实践了。
良好的代码实践有助于编写赏心悦目且高效执行的代码。
合理的缩进
使用合理缩进的代码更加易读。
从代码的缩进上很容易辨识代码块的起始和结束位置,以及代码和代码块的归属关系:
public void DoSomething()
{
//打印10次Hello World
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Hello World");
}
}
上述范例虽然简单,但也能够展现出清晰易读的特点。
各个代码块的起始与终止位置均清晰可见。
有意义的注释
有意义的注释是能够表达程序本意的注释。
有时虽然代码是正确的,但其含义并不容易被新接触这段代码的开发人员理解,甚至即使作者本身隔几周之后也不易回想其含义。
此时这类注释就会带来很大的帮助。
API文档注释
良好的API应当拥有记录详细、易于理解的文档。
API注释是一种可以生成HTML文档的XML注释。
HTML文档对API的使用者来说是非常重要的。
文档越易用,开发人员使用API的意愿就越强。
使用命名空间合理组织代码
使用命名空间合理组织的代码可以直观地节省开发者查找代码的时间。
例如,如果需要查找和日期与时间相关的代码,那么可以以DateTime为命名空间,将时间相关的方法集中在Time类中,而将日期相关的方法集中在Date类中。
合理的命名规则
遵循Microsoft C#的命名规则是良好的实践。
在命名空间、类、接口、枚举和方法上应当使用Pascal命名法,而在变量名称、参数名称应当使用驼峰命名法。
在成员变量上必须加上前缀下划线。
一个类执行一种任务
设计良好的类应当只执行一种任务,而且能够清晰地表达设计意图。
类中的内容恰到好处,没有与之无关的代码。
一个方法做一件事情
一个方法应当仅做一件事情;
避免做多件事情,例如解密字符串并进行字符的替换。
方法的意图应当明确。
仅做一件事情的方法更容易成为短小的、可读的与表意清晰的方法。
方法的代码少于10行,最好不超过4行
理想情况下,方法代码不应超过4行。
但这并非总是可行的。(我不推荐。。。。
方法的参数不多于两个
方法最好没有参数,当然有一到两个也是可以的。
但当方法开始拥有两个以上的参数时就需要考虑类和方法的职责是不是太多了。
如果方法的确需要两个以上的参数,那么最好将其合并为一个对象参数。
任何多于两个参数的方法都会逐渐变得难以阅读和理解。
不多于两个参数的方法可以令代码易读,而只含有一个对象参数的方法比起含有多个参数的方法要易读得多。
合理使用异常
永远不要使用异常对象来作为流程控制的手段。
使用不会触发异常的手段来处理一般情况下可能会触发异常的条件。
设计良好的类使用这种手段来避免抛出异常。
可以使用try/catch/finally从异常状态中恢复并释放资源。
请使用可能从代码中抛出的特定异常类型进行捕获,以便能够得到更多细节信息进行日志记录或进行后续处理。
.NET中预定义的异常无法适用于所有场景。
因此,在一些场景自定义异常是非常必要的。
异常的名称应当以Exception结束并至少应当包含以下三种构造器:
- Exception():该构造器使用默认值创建异常。
- Exception(string):使用字符串作为异常消息。
- Exception(string,Exception):使用字符串作为异常消息,并接收一个内部异常(作为产生当前异常的原因)。
如果要使用异常就不要再返回错误代码,直接抛出包含有意义信息的异常即可。
代码可读性强
代码可读性越强,开发人员越乐于去使用它。
这种代码易于学习和使用。
即使项目上人员发生了更迭,新人也能够毫不费力地阅读、扩展并维护其代码。
可读性强的代码也不容易出现缺陷和安全问题。
代码耦合程度低
耦合度低的代码更容易进行测试和重构,更容易在需要时进行替换或更改。
低耦合度的代码还有易于复用的优势。
高内聚的代码
将公共的功能正确地分组的代码具有高度的内聚性。
这样的代码易于查找。
例如,在Microsoft System.Diagnostics命名空间中包含的代码必然只和诊断相关。
在Diagnostics命名空间中包含集合或文件系统相关的代码是没有意义的。
对象会被恰当销毁
使用可销毁的对象时,请务必调用Dispose()方法明确地销毁使用中的资源。
这有助于降低内存泄露的可能性。
有时,我们需要将对象(引用)设置为null以使其超出作用范围。
例如,在静态变量持有的对象引用不再继续使用时。
若使用的是可销毁对象,那么使用using语句可以在对象超出作用域时自动将其销毁。
这种做法无需显式调用Dispose()方法。
避免使用Finalize()方法
使用非托管资源时最好实现IDisposable接口并避免使用Finalize()方法。
终结器执行的时机是不确定的。
它很可能不会按照我们所期望的顺序或时机执行。
因此,最好在更加可靠的Dispose()方法中来销毁非托管资源。
合理地进行抽象
当设计只向更高的级别开放,并仅开放必须的内容时,它就处在正确的抽象层次上。
合理地进行抽象可以避免在实现中迷失方向。
过度抽象会使我们在工作中纠缠于各种实现细节,而抽象不足则会发生多个开发者同时工作在一个类上的情况。
不论遇到哪种情况,我们都可以使用重构来回到正确的抽象层次上。
在大型类中使用#region进行区域划分
“区域”可以进行折叠,因此适于在大型类中将不同的成员进行分组。
阅读大型的类并在其方法中跳来跳去是令然厌恶的。
将类中相互调用的方法分为一组是一个不错的方法。
在处理代码时,可以根据需要折叠或者展开这些方法。