每一个C#开发者必须知道的13件事情
1. 开发流程
程序的 Bug 与瑕疵往往出现于开发流程当中。只要对工具善加利用,就有助于在你发布程序之前便将问题发现,或避开这些问题。
标准化代码书写
标准化代码书写可以使代码更加易于维护,尤其是在代码由多个开发者或团队进行开发与维护时,这一优点更加突出。常见的强制代码规范化的工具有:FxCop、StyleCop 和 ReSharper。
开发者语:在掩盖错误之前请仔细地思考这些错误,并且去分析结果。不要指望依靠这些工具来在代码中寻找错误,因为结果可能和你的与其相去甚远。
代码审查
审查代码与搭档编程都是很常见的练习,比如开发者刻意去审查他人书写的代码。而其他人很希望发现代码开发者的一些 bug,例如编码错误或者执行错误。
审查代码是一种很有价值的练习,由于很依赖于人工操作,因此很难被量化,准确度也不够令人满意。
静态分析
静态分析不需要你去运行代码,你不必编写测试案例就可以找出一些代码不规范的地方,或者是一些瑕疵的存在。这是一种非常有效地寻找问题的方式,但是你需要有一个不会有太多误报问题的工具。C#常用的静态分析工具有 Coverity,CAT,NET,Visual Studio Code Analysis。
动态分析
在你运行代码的时候,动态分析工具可以帮你找出这些错误:安全漏洞,性能与并发性问题。这种方法是在执行时期的环境下进行分析,正因如此,其有 效性便受制于代码复杂度。Visual Studio 提供了包括 Concurrency Visualizer, IntelliTrace, and Profiling Tools 在内的大量动态分析工具。
管理者/团队领导语:开发实践是练习规避常见陷阱的最好方法。同时也要注意测试工具是否符合你的需求。尽量让你团队的代码诊断水平处于可控的范围内。
测试
测试的方式多种多样:单元测试,系统集成测试,性能测试,渗透测试等等。在开发阶段,绝大多数的测试案例是由开发者或测试人员来完成编写,使程序可以满足需求。
测试只在运行正确的代码时才会有效。在进行功能测试的时候,它还可以用来挑战开发者的研发与维护速度。
开发最佳实践
工具的选择上多花点时间,用正确的工具去解决你关心的问题,不要为开发者增添额外的工作。让分析工具与测试自动流畅地运行起来去寻找问题,但是要保证代码的思想仍然清晰地留在开发者的头脑当中。
尽可能快地定位诊断出来的问题所在位置(不论是通过静态分析还是测试得到的错误,比如编译警告,标准违例,问题检测等)。如果刚出来的问题由于“不关心”而去忽略它,导致该问题后来很难找到,那么就会给代码审阅工作者增加很大的工作量,并且还要祈祷他们不会因此烦躁。
请接受这些有用的建议,让自己代码的质量,安全性,可维护性得到提升,同时也提升开发者们的研发能力、协调能力,以及提升发布代码的可预测性。
目标 | 工具 | 影响 |
一致性,可维护性 | 标准化代码书写,静态分析,代码审查 | 间距一致,命名标准,良好的可读格式,都会让开发者更易编写与维护代码。 |
准确性 | 代码审查,静态分析,动态分析,测试 | 代码不只是需要语法正确,还需要以开发者的思想来满足软件需求。 |
功能性 | 测试 | 测试可以验证大多数的需求是否得到满足:正确性,可拓展性,鲁棒性以及安全性。 |
安全性 | 标准化代码书写,代码审查,静态分析,动态分析,测试 | 安全性是一个复杂的问题,任何一个小的漏洞都是潜在的威胁。 |
开发者研发能力 | 标准化代码书写,静态分析,测试 | 开发者在工具的帮助下会很快速地更正错误。 |
发布可预测性 | 标准化代码书写,代码审查,静态分析,动态分析,测试 | 流线型后期阶段的活动、最小化错误定位循环,都可以让问题发现的更早。 |
2. 类型的陷阱
C#的一个主要的优点就是其灵活的类型系统,而安全的类型可以帮助我们更早地找到错误。通过强制执行严格的类型规则,编译器能够帮助你维持良好 的代码书写习惯。在这一方面,C#语言与 .NET 框架为我们提供了大量的类型,以适应绝大多数的需求。虽然许多开发者对一般的类型有着良好的理解,并且也知晓用户的需求,但是一些误解与误用仍然存在。
更多关于 .NTE 框架类库的信息请参阅 MSDN library。
理解并使用标准接口
特定的接口涉及到常用的 C# 特征。例如,IDiposable 允许使用常见的资源管理语言,例如关键词“using”。良好地理解接口可以帮助你书写通顺的 C# 代码,并且更易于维护。
避免使用 ICloneable 接口——开发者从来没搞清楚一个被复制的对象到底是深拷贝还是浅拷贝。由于仍没有一种对复制对象操作是否正确的标准评判,于是也就没办法有意义地去将接口作为一个 contract 去使用。
结构体
尽量避免向结构体中进行写入,将它们视为一种不变的对象以防止混乱。在像多线程这种场景下进行内存共享,会变得更安全。我们对结构体采用的方法是,在创建结构体时对其进行初始化操作,如果需要改变其数据,那么建议生成一个新的实体。
正确理解哪些标准类型/方法是不可变,并且可返回新的值(例如串,日期),用这些来替代那些易变对象(如 List.Enumerator)。
字符串
字符串的值可能为空,所以可以在合适的时候使用一些比较方便的功能。值判断(s.Length==0)时可能会出现 NullReferenceException 错误,而 String.IsNullOrEmpty (s)和 String.IsNullOrWhitespace (s)可以很好地使用 null。
标记枚举
枚举类型与常量可以使代码更加易于阅读,通过利用标识符替换幻数,可以表现出值的意义。
如果你需要生成大量的枚举类型,那么带有标记的枚举类型是一种更加简单的选择:
[Flag]public enum Tag { None =0x0, Tip =0x1, Example=0x2}
下面这种方法可以让你在一个 snippet 中使用多重标记:
snippet.Tag = Tag.Tip | Tag.Example
这种方法有利于数据的封装,因此你也不必担心在使用 Tag property getter 时有内部集合信息泄露。
Equality comparisons(相等性比较)
有如下两种类型的相等性:
1. 引用相等性,即两种引用都指向同一个对象。
2. 数值相等性,即两个不同的引用对象可以视为相等的。
除此之外,C#还提供了很多相等性的测试方法。最常见的方法如下:
-
==与!=操作
-
由对象的虚继承等值法
-
静态 Object.Equal 法
-
IEquatable<T>接口等值法
-
静态 Object.ReferenceEquals 法
有时候很难弄清楚使用引用或值相等性的目的。想进一步弄明白这些,并且让你的工作做得更好,请参阅:
MSDNhttp://msdn.microsoft.com/en-us/library/dd183752.aspx
如果你想要覆盖某个东西的时候,不要忘了 MSDN 上为我们提供的诸如 IEquatable<T>, GetHashCode ()之类的工具。
注意无类型容器在重载方面的影响,可以考虑使用“myArrayList[0] == myString”这一方法。数组元素是编译阶段类型的“对象”,因此引用相等性可以使用。虽然 C# 会向你提醒这些潜在的错误,但是在编译过程中,unexpected reference equality 在某些情况下不会被提醒。
3. 类的陷阱
封装你的数据
类在恰当管理数据方面起很大的作用。鉴于性能上的一些原因,类总是缓存部分结果,或者是在内部数据的一致性上做出一些假设。使数据权限公开的话 会在一定程度上让你去缓存,或者是作出假设,而这些操作是通过对性能、安全性、并发性的潜在影响表现出来的。例如暴露像泛型集合、数组之类的易变成员项, 可以让用户跳过你而直接进行结构体的修改。
属性
除了可以通过 access modifiers 控制对象之外,属性还可以让你很精确地掌控用户与你的对象之间进行了什么交互。特别要指出的是,属性还可以让你了解到读写的具体情况。
属性能在通过存储逻辑将数据覆写进 getters 与 setters 的时候帮助你建立一个稳定的 API,或是提供一个数据的绑定资源。
永远不要让属性 getter 出现异常,并且也要避免修改对象状态。这是一种对方法的需求,而不是属性的 getter。
更多有关属性的信息,请参阅 MSDN:
http://msdn.microsoft.com/en-us/library/ms229006(v=vs.120) .aspx
同时也要注意 getter 的一些副作用。开发者也习惯于将成员体的存取视为一种常见的操作,因此他们在代码审查的时候也常常忽视那些副作用。
对象初始化
你可以为一个新创建的对象根据它创建的表达形式赋予属性。例如为 Foo 与 Bar 属性创建一个新的具有给定值的C类对象:
new C {Foo=blah, Bar=blam}
你也可以生成一个具有特定属性名称的匿名类型的实体:
var myAwesomeObject = new {Name=”Foo”, Size=10};
初始化过程在构造函数体之前运行,因此需要保证在输入至构造函数之前,将这一域给初始化。由于构造函数还没有运行,所以目标域的初始化可能不管怎样都不涉及“this”。
过渡规范细化的输入参数
为了使一些特殊方法更加容易控制,最好在你使用的方法当中使用最少的特定类型。比如在一种方法中使用 List<Bar>进行迭代:
public void Foo (List<Bar> bars) { foreach(var b in bars) { // do something with the bar... } }
对于其他 IEnumerable<Bar>集来说,使用这种方法的表现更加出色一些,但是对于特定的参数 List<Bar>来说,我们更需要使集以表的形式表现。尽量少地选取特定的类型(诸如 IEnumerable<T>, ICollection<T>此类)以保证你的方法效率的最大化。
4. 泛型
泛型是一种在定义独立类型结构体与设计算法上一种十分有力的工具,它可以强制类型变得安全。
用像 List<T>这样的泛型集来替代数组列表这种无类型集,既可以提升安全性,又可以提升性能。
在使用泛型时,我们可以用关键词“default”来为类型获取缺省值(这些缺省值不可以硬编码写进 implementation)。特别要指出的是,数字类型的缺省值是o,引用类型与空类型的缺省值为 null。
T t = default(T);
5. 类型转换
类型转换有两种模式。其一显式转换必须由开发者调用,另一隐式转换是基于环境下应用于编译器的。
常量o可由隐式转换至枚举型数据。当你尝试调用含有数字的方法时,可以将这些数据转换成枚举类型。
类型转换 | 描述 |
Tree tree = (Tree) obj; | 这种方法可以在对象是树类型时使用;如果对象不是树,可能会出现 InvalidCast 异常。 |
Tree tree = obj as Tree; | 这种方法你可以在预测对象是否为树时使用。如果对象不是树,那么会给树赋值 null。你可以用“as”的转换,然后找到 null 值的返回处,再进行处理。由于它需要有条件处理的返回值,因此记住只在需要的时候才去用这种转换。这种额外的代码可能会造成一些 bug,还可能会降低代码的可读性。 |
转换通常意味着以下两件事之一:
1. RuntimeType 的表现可比编译器所表现出来的特殊的多,Cast 转换命令编译器将这种表达视为一种更特殊的类型。如果你的设想不正确的话,那么编译器会向你输出一个异常。例如:将对象转换成串。
2. 有一种完全不同的类型的值,与 Expression 的值有关。Cast 命令编译器生成代码去与该值相关联,或者是在没有值的情况下报出一个异常。例如:将 double 类型转换成 int 类型。
以上两种类型的 Cast 都有着风险。第一种 Cast 向我们提出了一个问题:“为什么开发者能很清楚地知道问题,而编译器为什么不能?”如果你处于这个情况当中,你可以去尝试改变程序让编译器能够顺利地推理 出正确的类型。如果你认为一个对象的 runtime type 是比 compile time type 还要特殊的类型,你就可以用“as”或者“is”操作。
第二种 cast 也提出了一个问题:“为什么不在第一步就对目标数据类型进行操作?”如果你需要 int 类型的结果,那么用 int 会比 double 更有意义一些。
获取额外的信息请参阅:
http://blogs.msdn.com/b/ericlippert/archive/tags/cast+operator/
在某些情况下显式转换是一种正确的选择,它可以提高代码可阅读性与 debug 能力,还可以在采用合适的操作的情况下提高测试能力。
6. 异常
异常并不是 condition
异常不应该常出现在程序流程中。它们代表着开发者所不愿看到的运行环境,而这些很可能无法修复。如果你期望得到一个可控制的环境,那么主动去检查环境会比等待问题的出现要好得多。
利用 TryParse ()方法可以很方便地将格式化的串转换成数字。不论是否解析成功,它都会返回一个布尔型结果,这要比单纯返回异常要好很多。
注意使用 exception handling scope
写代码时注意 catch 与 finally 块的使用。由于这些不希望得到的异常,控制可能进入这些块中。那些你期望的已执行的代码可能会由于异常而跳过。如:
Frobber originalFrobber = null;try { originalFrobber = this.GetCurrentFrobber (); this.UseTemporaryFrobber (); this.frobSomeBlobs (); }finally { this.ResetFrobber (originalFrobber); }
如果 GetCurrentFrobber ()报出了一个异常,那么当 finally blocks 被执行时 originalFrobber 的值仍然为空。如果 GetCurrentFrobber 不能被扔掉,那么为什么其内部是一个 try block?
明智地处理异常
要注意有针对性地处理你的目标异常,并且只去处理目标代码当中的异常部分。尽量不要去处理所有异常,或者是根类异常,除非你的目的是记录并重新 处理这些异常。某些异常会使应用处于一种接近崩溃的状态,但这也比无法修复要好得多。有些试图修复代码的操作可能会误使情况变得更糟糕。
关于致命的异常都有一些细微的差异,特别是注重 finally blocks 的执行,可以影响到异常的安全与调试。更多信息请参阅:
http://incrediblejourneysintotheknown.blogspot.com/2009/02/fatal-exceptions-and-why-vbnet-has.html
使用一款顶级的异常处理器去安全地处理异常情况,并且会将 debug 的一些问题信息暴露出来。使用 catch 块会比较安全地定位那些特殊的情况,从而安全地解决这些问题,再将一些问题留给顶级的异常处理器去解决。
如果你发现了一个异常,请做些什么去解决它,而不要去将这个问题搁置。搁置只会使问题更加复杂,更难以解决。
将异常包含至一个自定义异常中,对面向公共 API 的代码特别有用。异常是可视界面方法的一部分,它也被参数与返回值所控制。但这种扩散了很多异常的方法对于代码的鲁棒性与可维护性的解决来说十分麻烦。
抛出(Throw)与继续抛出(ReThrow)异常
如果你希望在更高层次上解决 caught 异常,那么就维持原异常状态,并且栈就是一个很好的 debug 方法。但需要注意维持好 debug 与安全考虑的平衡。
好的选择包括简单地将异常继续抛出:
Throw;
或者将异常视为内部异常重新抛出:
抛出一个新 CustomException;
不要显式重新抛出类似于这样的 caught 异常:
Throw e;
这样的话会将异常的处理恢复至初始状态,并且阻碍 debug。
有些异常发生于你代码的运行环境之外。与其使用 caught 块,你可能更需要向目标当中添加如 ThreadException 或 UnhandledException 之类的处理器。例如,Windows 窗体异常并不是出现于窗体处理线程环境当中的。
原子性(数据完整性)
千万不要让异常影响到你数据模型的完整性。你需要保证你的对象处于比较稳定的状态当中——这样一来任何由类的执行的操作都不会出现违例。否则,通过“恢复”这一手段会使你的代码变得更加让人不解,也容易造成进一步的损坏。
考虑几种修改私有域顺序的方法。如果在修改顺序的过程当中出现了异常,那么你的对象可能并不处于非法状态下。尝试在实际更新域之前去得到新的值,这样你就可以在异常安全管理下,正常地更新你的域。
对特定类型的值——包括布尔型,32bit 或者更小的数据类型与引用型——进行可变量的分配,确保可以是原子型。没有什么保障是给一些大型数据(double,long,decimal)使用的。 可以多考虑这个:在共享多线程的变量时,多使用 lock statements。
7. 事件
事件与委托共同提供了一种关于类的方法,这种方法在有特殊的事情发生时向用户进行提醒。委托事件的值在事件发生时应被调用。事件就像是委托类型的域,当对象生成时,其自动初始化为 null。
事件也像值为“组播”的域。这也就是说,一种委托可以依次调用其他委托。你可以将一个委托分配给一个事件,你也可以通过类似-=于+=这样的操作来控制事件。
注意资源竞争
如果一个事件被多个线程所共享,另一个线程就有可能在你检查是否为 null 之后,在调用其之前而清除所有的用户信息——并抛出一个 NullReferenceException。
对于此类问题的标准解决方法是创建一个该事件的副本,用于测试与调用。你仍然需要注意的是,如果委托没有被正确调用的话,那么在其他线程里被移除的用户仍然可以继续操作。你也可以用某种方法将操作按顺序锁定,以避免一些问题。
public event EventHandler SomethingHappened;private void OnSomethingHappened () { // The event is null until somebody hooks up to it // Create our own copy of the event to protect against another thread removing our subscribers EventHandler handler = SomethingHappened; if (handler != null) handler (this,new EventArgs ()); }
更多关于事件与竞争的信息请参阅:
http://blogs.msdn.com/b/ericlippert/archive/2009/04/29/events-and-races.aspx
不要忘记将事件处理器 Unhook
使用一种事件处理器为事件资源生成一个由处理器的资源对象到接收对象的引用,可以保护接收端的 garbage collection。
适当的 unhook 处理器可以确保你不必因委托不再工作而去调用它浪费时间,也不会使内存存储无用委托与不可引用的对象。
8. 特性
特性提供了一种向程序集、类与其信息属性中注入元数据的方法。它们经常用来提供信息给代码的消费者——比如 debugger、框架测试、应用——通过反射这一方式。你也可以向你的用户定义 attributes,或是使用预定义 attributes,详见下表:
特性 | 使用对象 | 目的 |
DebuggerDisplay | Debugger | Debugger display 格式 |
InternalsVisibleTo | Member access | 使用特定类来暴露内部成员去指定其他的类。基于此方法,测试方法可以用来保护成员,并且 persistence 层可以用一些特殊的隐蔽方法。 |
DefaultValue | Properties | 为属性指定一个缺省值 |
一定要对 DebuggerStepThrough 多重视几分——否则它会在这个方法应用的地方让寻找 bug 变得十分困难,你也会因此而跳过某步或是推倒而重做它。
9. Debug
Debug 是在开发过程中必不可少的部分。除了使运行环境不透明的部分变得可视化之外,debugger 也可以侵入运行环境,并且如果不使用 debugger 的话会导致应用程序变现有所不同。
使异常栈可视化
为了观察当前框架异常状态,你可以将“$exception”这一表达添加进 Visual Studio Watch 窗口。这种变量包含了当前异常状态,类似于你在 catch block 中所看见的,但其中不包含在 debugger 中看见的不是代码中的真正存在的异常。
注意访问器的副作用
如果你的属性有副作用,那么考虑你是否应使用特性或者是 debugger 设置去避免 debugger 自动地调用 getter。例如,你的类可能有这样一个属性:
private int remainingAccesses = 10;private string meteredData;public string MeteredData { get { if (remainingAccesses-- > 0) return meteredData; return null; } }
你第一次在 debugger 中看见这个对象时,remainingAccesses 会获得一个值为 10 的整型变量,并且 MeteredData 为 null。然而如果你 hover 结束了 remainingAccesses,你会发现它的值会变成 9. 这样一来 debugger 的属性值表现改变了你的对象的状态。
10. 性能优化
早做计划,不断监测,后做优化
在设计阶段,制定切实可行的目标。在开发阶段,专注于代码的正确性要比去做微调整有意义的多。对于你的目标,你要在开发过程中多进行监测。只需要在你没有达到预期的目标的时候,你才应该去花时间对程序做一个调整。
请记住用合适的工具来确保性能的经验性测量,并且使测试处于这样一种环境当中:可反复多次测试,并且测试过程尽量与现实当中用户的使用习惯一致。
当你对性能进行测试的时候,一定要注意你真正所关心的测试目标是什么。在进行某一项功能的测试时,你的测试有没有包含这项功能的调用或者是回路构造的开销?
我们都听说过很多比别人做得快很多的项目神话,不要盲目相信这些,试验与测试才是实在的东西。
由于 CLR 优化的原因,有时候看起来效率不高的代码可能会比看起来效率高的代码运行的更快。例如,CLR 优化循环覆盖了一个完整的数组,以避免在不可见的 per-element 范围里的检查。开发者经常在循环一个数组之前先计算一下它的长度:
int[] a_val = int[4000];int len = a_val.Length;for (int i = 0; i < len; i++) a_val[i] = i;
通过将长度存储进一个变量当中,CLR 会不去识别这一部分,并且跳过优化。但是有时手动优化会反人类地导致更糟糕的性能表现。
构造字符串
如果你打算将大量的字符串进行连接,可以使用 System.Text.StringBuilder 来避免生成大量的临时字符串。
对集合使用批量处理
如果你打算生成并填满集合中已知的大量数据,由于再分配的存在,可以用保留空间来解决生成集合的性能与资源问题。你可以用 AddRange 方法来进一步对性能进行优化,如下在 List<T>中处理:
Persons.AddRange (listBox.Items);
11. 资源管理
垃圾收集器(garbage collector)可以自动地清理内存。即使这样,一切被抛弃的资源也需要适当的处理——特别是那些垃圾收集器不能管理的资源。
资源管理问题的常见来源 | |
内存碎片 | 如果没有足够大的连续的虚拟地址存储空间,可能会导致分配失败 |
进程限制 | 进程通常都可以读取内存的所有子集,以及系统可用的资源。 |
资源泄露 | 垃圾收集器只管理内存,其他资源需要由应用程序正确管理。 |
不稳定资源 | 那些依赖于垃圾收集器与终结器(finalizers)的资源在很久没用过的时候,不可被立即调用。实际上它们可能永远不可能被调用。 |
利用 try/finally block 来确保资源已被合理释放,或是让你的类使用 IDisposable,以及更方便更安全的声明方式。
using (StreamReader reader=new StreamReader (file)) { //your code here
在产品代码中避免 garbage collector
除了用调用 GC.Collect ()干扰 garbage collector 之外,也可以考虑适当地释放或是抛弃资源。在进行性能测试时,如果你可以承担这种影响带来的后果,你再去使用 garbage collector。
避免编写 finalizers
与当前一些流传的谣言不同的是,你的类不需要 Finalizers,而这只是因为 IDisposable 的存在!你可以让 IDisposable 赋予你的类在任何已拥有的组合实例中调用 Dispose 的能力,但是 finalizers 只能在拥有未管理的资源类中使用。
Finalizers 主要对交互式 Win32 位句柄 API 有很大作用,并且 SafeHandle 句柄是很容易利用的。
不要总是设想你的 finalizers(总是在 finalizer 线程上运行的)会很好地与其他对象进行交互。那些其他的对象可能在该进程之前就被终止掉了。
12. 并发性
处理并发性与多线程编程是件复杂的、困难的事情。在将并发性添加进你的程序之前,请确保你已经明确了解你的做的是什么——因为这里面有太多门道了!
多线程软件的情况很难进行预测,比如很容易产生如竞争条件与死锁的问题,而这些问题并不是仅仅影响单线程应用。基于这些风险,你应该将多线程视 为最后一种手段。如果不得不使用多线程,尽量缩减多线程同时使用内存的需求。如果必须使线程同步,请尽可能地使用最高等级的同步机制。在最高等级的前提 下,包括了这些机制:
-
Async-await/Task Parallel Library/Lazy<T>
-
Lock/monitor/AutoResetEvent
-
Interlocked/Semaphore
-
可变域与显式 barrier
以上的这些很难解释清楚C#/.NET 的复杂之处。如果你想开发一个正常的并发应用,可以去参阅O’Reilly 的《Concurrency in C# Cookboo》。
使用 Volatile
将一个域标记为“volatile”是一种高级特性,而这种设置也经常被专家所误解。C#的编译器会保证目标域可以被获取与释放语义,但是被 lock 的域就不适用于这种情况。如果你不知道获取什么,不知道释放什么语义,以及它们是怎样影响 CPU 层次的优化,那么久避免使用 volatile 域。取而代之的可以用更高层次的工具,比如 Task Parallel Library 或是 CancellationToken。
线程安全与内置方法
标准库类型常提供使对象线程安全更容易的方法。例如 Dictionary.TryGetValue ()。使用此类方法一般可以使你的代码变得更加清爽,并且你也不必担心像 TOCTOU(time-of-check-time-of-use 竞争危害的一种)这样的数据竞争。
不要锁住“this”、字符串,或是其他普通 public 的对象
当使用在多线程环境下的一些类时,多注意 lock 的使用。锁住字符串常量,或是其他公共对象,会阻止你锁状态下的封装,还可能会导致死锁。你需要阻止其他代码锁定在同一使用的对象上,当然你最好的选择是使用 private 对象成员项。
13. 避免常见的错误
Null
滥用 null 是一种常见的导致程序错误的来源,这种非正常操作可能会使程序崩溃或是其他的异常。如果你试图获取一个 null 的引用,就好像它是某对象的有效引用值(例如通过获取一个属性或是方法),那么在运行时就会抛出一个 NullReferenceException。
静态与动态分析工具可以在你发布代码之前为你检查出潜在的 NullReferenceException。在 C# 当中,引用型为 null 通常是由于变量没有引用到某个对象而造成的。对于值可为空的类型与引用型来说,是可以使用 null 的。例如:Nullable<Int>,空委托,已注销的事件,“as”转化失败的,以及一些其他的情况。
每个 null 引用异常都是一个 bug。相比于找到 NullReferenceException 这个问题来说,不如尝试在你使用该对象之前去为 null 进行测试。这样一来可以使代码更易于最小化的 try/catch block 读取。
当从数据库表中读取数据时,注意缺失值可以表示为 DBNull 对象,而不是作为空引用。不要期望它们表现得像潜在的空引用一样。
用二进制的数字表示十进制的值
Float 与 double 都可以表示十进制实数,但不能表示二进制实数,并且在存储十进制值的时候可以在必要时用二进制的近似值存储。从十进制的角度来看,这些二进制的近似值通常 都有不同的精度与取舍,有时在算数操作当中会导致一些不期望的结果。由于浮点型运算通常在硬件当中执行,因此硬件条件的不可预测会使这些差异更加复杂。
在十进制精度很重要的时候,就要使用十进制了——比如经济方面的计算。
调整结构
有一种常见的错误就是忘记了结构是值类型,意即其复制与通过值传递。例如你可能见过这样的代码:
void M () { P p = whatever; Helper (p); N (p); }void Helper (P p) { … p.x = something;
现在当N(p)在M()中被调用,p就有了一个错误的值。调用 Helper (p)传递p的副本,并不是引用p,于是在 Helper ()中的突变便丢失掉了。如果被正常调用,那么 Helper 应该传递的是调整过的p的副本。
非预期计算
C#编译器可以保护在运算过程中的常量溢出,但不一定是计算值。使用“checked”与“unchecked”两个关键词来标记你想对变量进行什么操作。
不保存返回值
与结构体不同的是,类是引用类型,并且可以适当地修改引用对象。然而并不是所有的对象方法都可以实际修改引用对象,有一些返回的是一个新的对 象。当开发者调用后者时,他们需要记住将返回值分配给一个变量,这样才可以使用修改过的对象。在代码审查阶段,这些问题的类型通常会逃过审查而不被发现。 像字符串之类的对象,它们是不可变的,因此永远不可能修改这些对象。即便如此,开发者还是很容易忘记这些问题。
例如,看如下 string.Replace ()代码:
struct P { public int x; public int y; }void M () { P p = whatever; … p.x = something; … N (p);
忽然某一天,代码维护人员决定将代码重构成这样:
void M () { P p = whatever; Helper (p); N (p); }void Helper (P p) { … p.x = something;
现在当N(p)在M()中被调用,p就有了一个错误的值。调用 Helper (p)传递p的副本,并不是引用p,于是在 Helper ()中的突变便丢失掉了。如果被正常调用,那么 Helper 应该传递的是调整过的p的副本。
非预期计算
C#编译器可以保护在运算过程中的常量溢出,但不一定是计算值。使用“checked”与“unchecked”两个关键词来标记你想对变量进行什么操作。
不保存返回值
与结构体不同的是,类是引用类型,并且可以适当地修改引用对象。然而并不是所有的对象方法都可以实际修改引用对象,有一些返回的是一个新的对 象。当开发者调用后者时,他们需要记住将返回值分配给一个变量,这样才可以使用修改过的对象。在代码审查阶段,这些问题的类型通常会逃过审查而不被发现。 像字符串之类的对象,它们是不可变的,因此永远不可能修改这些对象。即便如此,开发者还是很容易忘记这些问题。
例如,看如下 string.Replace ()代码:
string label = “My name is Aloysius”; label.Replace (“Aloysius”, “secret”);
这两行代码运行之后会打印出“My name is Aloysius” ,这是因为 Raeplace 方法并没改变该字符串的值。
不要使迭代器与枚举器失效
注意不要在遍历时去修改集合
List<Int> myItems = new List<Int>{20,25,9,14,50};foreach(int item in myItems) { if (item < 10) { myItems.Remove (item); // iterator is now invalid! // you’ll get an exception on the next iteration
如果你运行了这个代码,那么它一在下一项的集合中进行循环,你就会得到一个异常。
正确的处理方法是使用第二个 list 去保存你想删除的这一项,然后在你想删除的时候再遍历这个 list:
List<Int> myItems = new List<Int>{20,25,9,14,50}; List<Int> toRemove = new List<Int>();foreach(int item in myItems) { if (item < 10) { toRemove.Add (item); } }foreach(int item in toRemove) {
如果你用的是C#3.0 或更高版本,可以尝试 List<T>.RemoveAll:
myInts.RemoveAll (item => (item < 10));
属性名称错误
在实现属性时,要注意属性的名称和在类当中用的成员项的名字有很大差别。很容易在不知情的情况下使用了相同的名称,并且在属性被获取的时候还会触发死循环。
// The following code will trigger infinite recursionprivate string name;public string Name { get { return Name; // should reference “name” instead.
在重命名间接属性时同样要小心。例如:在 WPF 中绑定的数据将属性名称指定为字符串。有时无意的改变属性名称,可能会不小心造成编译器无法解决的问题。