我看的书是《Effective C#中文版——改善C#程序的50种方法》,Bill Wagner著,李建忠译。书比较老了,04年写的,主要针对C#1.0,但我相信其中的观点现在仍有价值。(平心而论,和Effective C++有差距,毕竟该书成书时对C#的研究不过几年。)
下面是对这本书条款内容的一些归纳和个人理解,由于我比较熟悉C++,因此也会有也一些C++的对比。
第一章 C#语言元素
条款1:使用属性代替可访问的数据成员
1. 属性具有数据成员的访问语法,这是最易于使用的语法。
2. 属性事实上是方法,因而支持多态,且利于日后进行扩展,如多线程同步访问等。
3. .Net中的库功能,很多是针对属性的,例如数据绑定。
4. 两者性能相当。
条款2:运行是常量(readonly)优于编译时常量(const)
1. 两者生成的il码不同,后者是进行常量替换,而前者具有更好的二进制兼容性(程序集B依赖于程序集A的一个常量,如果A中的这个常量修改了,const声明要求AB都重行编译,而readonly只要求A重编)。
2. 两者性能相当。
条款3:操作符is或as优于强制转型
1. as比强制转型具有更简练的语法(等价于as的强制转型,需要包括异常捕获等处理)。
2. 对于ValueType的派生类,由于不能为null,所以不能使用as,应该选择is。
3. 强制转型会考虑用户定义类型转换(explicit;但注意,用户自定义转型是静态的,不支持多态),而as只取决于继承链。
条款4:使用Conditional特性代替#if条件编译
1. 前者能让程序逻辑更清晰,尽管它的最小应用单元是方法而后者是语句。
2. 前者有更好的性能。Conditional是让客户的调用代码消失,而对应功能的条件编译只是让语句消失,函数调用开销依旧。
条款5:总是提供ToString方法
1. 人最容易理解的是字符串,而Object.ToString的默认行为是返回类型名,正确的实现ToString有利于调试和UI等。
2. Console.WriteLine和String.Format等都支持IFormattable接口,注意IFormattable.ToString和Object.ToString的兼容。
条款6:明辨值类型和引用类型的使用场合
1. 需要打包一组数据,且内存占用不多的时候考虑使用struct。
2. 具有数据和逻辑,或者虽然逻辑简单但数据块占内存很大,考虑使用class。
条款7:将值类型尽可能实现为具有常量性和原子性的类型
1. 常量值类型,更容易编写原子逻辑。如,包含姓名和身份证号两个字段的struct,其两个字段是绑定的,单独修改一般会带来错误,所以与其在运行时用方法逻辑来维持两者匹配,不如干脆将两个字段都声明为readonly,如果想修改就用新值来构造新对象。
2. 针对常量对象更容易编写多线程逻辑。
条款8:确保0为值类型的有效状态
1. 因为clr默认将对象初始化为二进制0,所以应该保证二进制0在程序逻辑中是合法值。如枚举值应该从0开始定义。
条款9:理解几个相等判断之间的关系
1. 对于ValueType:(对应struct)
public override bool Equals(object obj);
它 的实现主要是通过反射来对比各个字段,因此这个默认实现效率很低。ValueType的默认实现中,并不能直接将两个二进制块进行memcmp,因为形如 struct A{ string s; }这样的结构,二进制层次上的对比是没有意义的。事实上,C#编译器也没有提供自动生成T.Equals的服务(即对于用户没有提供Equals实现的 struct,编译器何不自动生成逐字段对比的C#代码?),原因不明。
所以,如果特定struct性能攸关,应该手工实现Equals进行逐字段比较以获得更佳性能。另外考虑实现语法糖operator==来调用Equals。
2. 对于Object: (对应class)
public static bool ReferenceEquals(object objA, object objB);
public static bool Equals(object objA, object objB);
public virtual bool Equals(object obj);
public static bool operator == (object objA, object objB);
Object 基类中的默认实现全是引用比较,即用于判断是否是同一对象。其中ReferenceEquals提供最底层实现,operator ==调用ReferenceEquals, static Equals进行对象非空验证然后调用virtual Equals, 而virtual Equals默认也是调用ReferenceEquals。
如果需要给引用类型提供其他比较语义,如string,则实现virtual Equals,然后重载operator ==调用virtual Equals。
条款10:理解GetHashCode方法的缺陷。
1. 实现GetHashCode的要求(3点):
正确性要求——
(1)相等的对象必须有相同的hash code,即Equals返回true的对象,GetHashCode返回值也应该相同。值相等的对象当然应该在同一个hash捅中。实现方法:用于生成hash code的字段,一定都要参与Equals的实现。
(2)对象生命期中,GetHashCode返回值应该不变。避免在hash表中查询已经插入的对象却找不到。实现方法:用于生成hash code的字段,最好声明为readonly。
性能要求——
(1)GetHashCode尽量返回均匀分布的值。
2. Object的默认实现是返回自增的全局对象ID,ValueType的默认实现是返回第一个字段的GetHashCode。本条的两点恐怕只适用于C#1.0,我测试在C#3.5中,实现已经变化。
条款11:优先采用foreach循环语句
1. foreach会自动针对不同的容器,生成不同的il码以优化效率。例如对数组,foreach不会通过IEnumerable遍历,而是直接使用下标。
2. foreach可以正确遍历起始下标非0的数组和多维数组。下标非0数组是通过Array.CreateInstance创建的。
3. foreach遍历数组,因为可以保证访问数组的每个元素的时候不越界,故foreach对应的下标访问实现不会有下标越界检查的开销。在我使用的 C#3.5中测试,foreach并没有加速效果,恐怕因为在高版本中,下标越界检查已经移到了clr的实现中(il的ldelem),故foreach 并不比for循环快。
第二章 .Net资源管理
条款12:变量初始化器优于赋值语句
1. 初始化顺序:(C#3.5,参见step字段)
1 class InitOrder
2
{
3
public string step = "(1)";
4
public InitOrder()
5
{
6
step = "(2)";
7
}
8
}
9
...
10 InitOrder a
= new
InitOrder { step = "(3)" };
2. 对象的构造函数可以有多个,比起在多个构造函数中分别初始化字段,字段初始化器更容易维护。
条款13:使用静态构造器初始化静态类成员
1. 如果初始化逻辑简单,就用静态成员的初始化器;逻辑复杂的话,使用静态构造器。
条款14:利用构造器链
1. 构造器链可以避免代码重复。相当于C++时代为了避免多个构造函数代码重复而让各个构造函数调用同一个辅助函数(书上说如果在C#中使用C++的惯用法效率较低,经测试辅助函数法和构造函数链效率相同)。
条款15:利用using和try/finally语句来清理资源
1. 对于文件等有Close方法的IDisposable对象,应该使用Dispose方法来代替Close,因为前者还会进行GC.SuppressFinalize操作,明显提高性能。
条款16:尽量减少内存垃圾
1. 频繁调用的成员方法中如果有局部作用域的资源,尝试把资源作为对象的成员数据。如在OnPaint中使用的Font作为成员的话就不必每次都创建。
2. 常用的资源考虑作为静态对象,还可以作为属性在get中进行延迟加载等。如Brush.Black等。
3. 常量性数据避免频繁修改。如string,可以使用string.format和StringBuilder来减少垃圾。
条款17:尽量减少装箱和拆箱
1. 注意将struct转换为引用类型,都会造成装箱。如将struct转换为object/ValueType/interface。
条款18:实现标准Dispose模式
1. Dispose方法的指责:
(1)释放托管和非托管资源。
(2)避免重复释放资源。
(3)GC.SuppressFinalize。
第三章 使用C#表达设计
条款19:定义并实现接口优于继承类型
1. 接口表示“behaves like”,而继承表示“is a”。继承同一个基类的多个类型是相关类型,而继承同一个接口的多个类型可以完全无关,只是都包含一组行为。
2. 接口的签名相对较稳定,因为对接口的修改将影响所以实现该接口的类型;而修改继承体系中的基类,如果有默认实现的话,派生类不一定需要调整。
3. 公开接口相比公开具体类,前者暴露更少的实现细节。
条款20:明辨接口实现和虚方法重写
1. 实现接口时,接口方法的具体修饰如virtual、abstract等可以自由设置。
条款21:使用委托表达回调
1. MulticastDelegate.GetInvocationList返回所有的方法,Delegate.Method返回其中最后一个方法。
2. 委托返回值是MulticastDelegate.GetInvocationList中最后一个方法的返回值。
3. 多播委托触发时内部不捕获异常,即任意一个方法因异常中断都会造成后续方法不能触发。
条款22:使用事件定义外发接口
条款23:避免返回成员对象的引用
1. 返回成员的引用使外部代码有可能在内部不知情的情况下修改成员,暴露了实现细节。
2. 正确的处理方式:
(1)通过成员方法修改内部成员。
(2)内部成员是值类型,可以直接返回。外部修改只能作用在拷贝上。
(3)内部成员是具有常量性的类型,可以直接返回。如string。
(4)返回内部成员实现的接口,如IEnumerable。特殊的接口具有只读权限或者会将修改正确反馈给内部(成员类型本身一般不具有如此高度的封装性)。
(5)返回内部成员的包装类。类似第(4)条,包装类是专业类,能够通过事件等方式正确反馈。
条款24:声明式编程优于命令式编程
1. 主要指特性的应用(System.Attribute的派生类)。例如序列化特性等。
条款25:尽可能将类型实现为可序列化的类型
1. 任何可序列化的类型要求其主要数据成员也应该可序列化,所以考虑是否要让类型支持序列化的时候,还需要考虑将来该类型是否有可能被用于其他可序列化类型作 为其成员(即放弃A的序列化能力,会给包含A的可序列化类型B带来麻烦)。一般只有UI对象等不需要序列化能力,所以大部分类型都应该尽可能的支持序列 化。
条款26:使用IComparable和IComparer接口实现排序关系
1. 排序过程中的比较操作频率很高,需要尽可能高效,注意减少不必要的装箱和使用IComparer代替委托。
条款27:避免ICloneable接口
1. 值类型不支持多态,且本身的赋值操作符效率很高,一般不需要实现ICloneable。
2. 引用类型实现ICloneable的时候,考虑像C++那样,先实现拷贝构造函数,然后用拷贝构造函数实现Clone。
条款28:避免强制转换操作符
1. 建议使用单参数构造函数代替类型转换。首先,隐式类型转换由于过于隐蔽容易造成bug需要严格控制,如C++中慎用operator T和要求单参构造函数声明为explicit,都是为了限制隐式类型转换,规则应用到C#后,还剩下两种选择,即单参构造函数和explicit的类型转 换,前者正是本条款提倡的,而后者,只有当转换后的类型具有常量性的时候可以使用(如果转换为可以修改类型,因为返回的对象是临时对象,应用在这个临时对 象的修改会无效,忽略了这点可能会产生较隐蔽的bug。如Array.Sort((int[])myListType);)。
条款29:只有当新版基类导致问题的时候才考虑使用new修饰符
1. 派生类的同签名非虚方法默认由new修饰(没有new的话编译器报警),但正确的设计中不应该出现这种情况,因为派生类如果尝试重写方法,该方法在基类中一定声明为虚方法。
2. 只在一种特殊的情况下允许使用new以隐藏基类中的方法实现:当前维护者不具有整个类型树的代码修改权。造成类型树中多个类代码不能修改的原因,可能是系 统中部分类由其他公司发布,或者类已经被广泛使用无法回收。出于兼容性考虑,只能用new隐藏实现。书中特例:尝试在基类B中添加非虚方法f,但是派生类 D和D中的f已经被广泛使用了,为了不破坏现有客户代码,在B中添加f后,将D中的f改为new修饰。
第四章 创建二进制组件
条款30:尽可能实现CLS兼容的程序集
1. 要让程序集能够被不同.Net语言访问(语言互操作性),程序集需要与CLS兼容。使用特性[assembly: CLSCompliant(true)],让编译器检查CLS兼容性。CLS兼容意味着,公开或保护的方法和接口与CLS兼容。
条款31:尽可能实现短小简洁的函数
1. JIT编译器采用延迟策略,只在需要时生成本地码,所以较小的函数有利于JIT编译时间摊还。
2. 较小的函数比大函数有更少的局部变量,有利于JIT合理分配寄存器。
3. 较小的函数有利于内联。
条款32:尽可能实现小尺寸、高内聚的程序集
1. 程序集的划分标准之一:程序集的功能应该能够一句话概括。
条款33:限制类型的可见性
1. 可以使用内部类实现公开接口等。
条款34:创建大粒度的Web API
1. 大粒度的Web API、RPC API、脚本API等,由于载荷大,调用频率更低,相比小粒度的API具有跨边界通信总成本小的优点。
第五章 使用框架
条款35:重写优于事件处理器
1. 面对override和event两种事件处理选择的时候,前者更优:
(1)覆盖虚方法性能更高,还可应控制调用基类方法触发event的时机。
(2)event中的方法链是动态表,有可能因未知的原因导致方法没有被调用。如方法链的前端有异常抛出或委托对象被清空等。
条款36:合理使用.Net运行时诊断
1. 在编译时,可以通过编译选项控制System.Diagnostics.Trace和System.Diagnostics.Debug的开关。
2. 在运行时,可以通过配置文件配置Trace的输出目标文件和等级等。
条款37:使用标准配置机制
1. 除.Net默认的配置文件外,还可以利用Xml序列化实现简单的读写配置。
条款38:定制和支持数据绑定
1. 应该尽量利用UI库提供的数据绑定功能,它能够大幅简化代码逻辑。数据绑定允许配置数据源和目标属性、允许控制源和目标类型不匹配时的转换方式,数据绑定能够正确处理数据同步的时机,避免了在多处编写重复的同步代码。
条款39:使用.Net验证
1. 利用.Net已有机制校验用户输入。
条款40:根据需要选用恰当的集合
条款41:DataSet优于自定义结构
1. DataSet由于有数据库的视图、约束、事务等逻辑,很容易和UI结合。缺点是DataSet中表项是弱类型。
条款42:利用特性简化反射
1. 特性的一种用法:给类型或成员打上指定特性作为标记,用于在反射时进行类型、方法的筛选。
条款43:避免过度使用反射
1. 反射是晚绑定,容易引入人为bug,除非对动态编码有高要求,否则尽量优先考虑用工厂模式、接口、委托等方案替代反射。
条款44:为应用程序创建特定的异常类
1. 选择抛出异常是因为clr抛出的异常信息有限,以及有的错误情况如不及时反馈会在后期爆发时无从追查。
2. 外部会根据错误原因做区别处理时,才考虑使用特定类型的异常。
3. 使用新异常类型来表达特殊错误,相比始终抛出System.Exception并用描述字符串区分错误原因的方案,后者因为依赖于字符串无强类型保证,不利于重构且易引起bug。
第六章 杂项讨论
条款45:优先选择强异常安全保证
1. 基本保证:抛出异常后,无资源泄漏且对象都处在有效状态。垃圾收集和using避免了资源泄漏,所以只需要注意不要因异常打乱执行流程而导致对象状态破坏。
2. 强保证:抛出异常导致操作中断后,对象状态应该和操作前相同。C++实践:拷贝对象->修改拷贝后对象->交换拷贝和源对象。
3. 无抛保证:不抛出异常。C#中至少有3组方法不应该抛出异常:
(1)终结器(析构函数)。终结器被垃圾回收线程调用,抛出异常将直接导致程序结束。C++也禁止析构函数抛出异常,理由是构成双异常的情况下导致程序崩溃。
(2)Dispose方法。该方法一般嵌套在finally语句块中,如果finally语句块因为有其他异常抛出才被动执行,那语句块中的Dispose再抛出异常将导致前一个异常被覆盖(C#面对双异常,采取后者覆盖前者的策略),结果最初的异常信息丢失。
(3)委托方法。委托默认是多播委托,如果方法链中的一个方法抛出异常,后面的方法将不被调用。
条款46:最小化互操作
1. interop在数据传输方面存在marshal开销。使用blittable类型(基础类型、基础类型数组、基础类型的struct包装)可以直接传输避免marshal。声明In、Out特性和选择最合适的声明也有利于提高效率。
2. interop在托管和非托管代码间切换的开销。存在三种选择:
(1)Com Interop。因为属性频繁使用造成交互频繁,效率低。适合和Com交互。
(2)Platform Invoke。效率高,但需要声明每个函数,且无自然的面向对象语法。适合和C API交互。
(3)C++/CLI。易于将C++的类型包装成托管类型。适合和C++交互。
3. interop复杂的语法增加了开发成本。
条款47:优先选择安全代码
1. unsafe代码块最好集中到单独的程序集。(为何?如果要求集中到单独的AppDomain我明白。)
2. 为部分受信程序集提供隔离存储区。如Web上的代码。
条款48:掌握相关工具与资源
1. 单元测试(NUnit)、代码分析(FxCop)、IL反汇编(ILDasm)、官方网站和社区、.Net和C#源码(rotor)等。
条款49:为C#2.0做准备
1. C#的模板实例化时,对每种值类型模板分别生成不同实例,而对引用类型模板统一生成System.Object实例,避免类型膨胀。
2. C#模板实例类型的生成,是在JIT编译阶段而非源码编译阶段。
条款50:了解ECMA标准