读书笔记--《Effective C#》总结

值得推荐的一本书,适合初中级C#开发人员

第1章 C#语言元素

原则1:尽可能的使用属性(property),而不是数据成员(field)

  ● 属性(property)一直是C#语言中比较有特点的存在。属性允许将数据成员作为共有接口的一部分暴露出去,同时仍旧提供面向对象环境下所需的封装。属性这个语言元素可以让你像访问数据成员一样使用,但其底层依旧是使用方法实现的。

  ● 使用属性,可以非常轻松的在get和set代码段中加入检查机制。

  需要注意,正因为属性是用方法实现的,所以它拥有方法所拥有的一切语言特性:

  1)属性增加多线程的支持是非常方便的。你可以加强 get 和 set 访问器(accessors)的实现来提供数据访问的同步。

  2)属性可以被定义为virtual。

  3)可以把属性扩展为abstract。

  4)可以使用泛型版本的属性类型。

  5)属性也可以定义为接口。

  6)因为实现实现访问的方法get与set是独立的两个方法,在C# 2.0之后,你可以给它们定义不同的访问权限,来更好的控制类成员的可见性。

  7)而为了和多维数组保持一致,我们可以创建多维索引器,在不同的维度上使用相同或不同类型。

 

  无论何时,需要在类型的公有或保护接口中暴露数据,都应该使用属性。如果可以也应该使用索引器来暴露序列或字典。现在多投入一点时间使用属性,换来的是今后维护时的更加游刃有余。

原则2:为你的常量选择readonly而不是const

  对于常量,C#里有两个不同的版本:运行时常量(readonly)和编译时常量(const)。

  应该尽量使用运行时常量,而不是编译器常量。虽然编译器常量略快,但并没有运行时常量那么灵活。应仅仅在那些性能异常敏感,且常量的值在各个版本之间绝对不会变化时,再使用编译时常量。

  编译时常量与运行时常量不同之处表现在于他们的访问方式不同,因为Readonly值是运行时解析的:

  ● 编译时常量(const)的值会被目标代码中的值直接取代。

  ● 运行时常量(readonly)的值是在运行时进行求值。● 引用运行时生成的IL将引用到readonly变量,而不是变量的值。

  这个差别就带来了如下规则:

  ● 编译时常量(const)仅能用于数值和字符串。

  ● 运行时常量(readonly)可以为任意类型。运行时常量必须在构造函数或初始化器中初始化,因为在构造函数执行后不能再被修改。你可以让某个readonly值为一个DataTime结构,而不能指定某个const为  DataTIme。

  ● 可以用readonly值保存实例常量,为类的每个实例存放不同的值。而编译时常量就是静态的常量。

  ● 有时候你需要让某个值在编译时才确定,就最好是使用运行时常量(readonly)。

  ● 标记版本号的值就应该使用运行时常量,因为它的值会随着每个不同版本的发布而改变。

  ● const优于readonly的地方仅仅是性能,使用已知的常量值要比访问readonly值略高一点,不过这其中的效率提升,可以说是微乎其微的。

 

  综上,在编译器必须得到确定数值时,一定要使用const。例如特性(attribute)的参数和枚举的定义,还有那些在各个版本发布之间不会变化的值。除此之外的所有情况,都应尽量选择更加灵活的readonly常量。

原则3:选择is或者as操作符而不是做强制类型转换(翻译)

  ● C#中,is和as操作符的用法概括如下:

  is : 检查一个对象是否兼容于其他指定的类型,并返回一个Bool值,永远不会抛出异常。

  as:作用与强制类型转换是一样,但是永远不会抛出异常,即如果转换不成功,会返回null。

 

  ● 尽可能的使用as操作符,因为相对于强制类型转换来说,as更加安全,也更加高效。

  ● as在转换失败时会返回null,在转换对象是null时也会返回null,所以使用as进行转换时,只需检查返回的引用是否为null即可。

  ● as和is操作符都不会执行任何用户自定义的转换,它们仅当运行时类型符合目标类型时才能转换成功,也不会在转换时创建新的对象。

  ● as运算符对值类型是无效,此时可以使用is,配合强制类型转换进行转换。

  ● 仅当不能使用as进行转换时,才应该使用is操作符。否则is就是多余的。

原则4:用条件属性而不是#if预编译块

  ● 由于#if/#endif很容易被滥用,使得编写的代码难于理解且更难于调试。C#为此提供了一条件特性(Conditional attribute)。使用条件特性可以将函数拆分出来,让其只有在定义了某些环境变量或设置了某个值之后才能编译并成为类的一部分。Conditional特性最常用的地方就是将一段代码变成调试语句。 

  ● Conditional特性只可应用在整个方法上,另外,任何一个使用Conditional特性的方法都只能返回void类型。不能再方法内的代码块上应用Conditional特性。也不可以在有返回值的方法上应用Conditional特性。但应用了Conditional特性的方法可以接受任意数目的引用类型参数。 

  ● 使用Conditional特性生成的IL要比使用#if/#Eendif时更有效率。同时,将其限制在函数层面上可以更加清晰地将条件性的代码分离出来,以便进一步保证代码的良好结构。

原则5:始终提供ToString()

  1. 人最容易理解的是字符串,而Object.ToString的默认行为是返回类型名,正确的实现ToString有利于调试和UI等。

  2. Console.WriteLine和String.Format等都支持IFormattable接口,注意IFormattable.ToString和Object.ToString的兼容。

原则6:区别值类型数据和引用类型数据

  ● C#中可以创建两种类型:值类型和引用类型。如果两个引用类型的变量指向的是同一个对象,它们将被认为是“引用相等”。如果两个值类型的变量类型相同,而且包含同样的内容,它们被认为是“值相等”。这也是等同性判断需要如此多方法的原因。

  ● 当我们创建自己的类型时(无论是类还是struct),应为类型定义“等同性”的含义。C#提供了4种不同的函数来判断两个对象是否“相等”。

  1)public static bool ReferenceEquals (object left, object right);判断两个不同变量的对象标识(object identity)是否相等。无论比较的是引用类型还是值类型,该方法判断的依据都是对象标识,而不是对象内容。

  2)public static bool Equals (object left, object right); 用于判断两个变量的运行时类型是否相等。

  3)public virtual bool Equals(object right); 用于重载

  4)public static bool operator ==(MyClass left, MyClass right); 用于重载

  ● 不应该覆写Object.referenceEquals()静态方法和Object.Equals()静态方法,因为它们已经完美的完成了所需要完成的工作,提供了正确的判断,并且该判断与运行时的具体类型无关。对于值类型,我们应该总是覆写Object.Equals()实例方法和operatior==( ),以便为其提供效率更高的等同性判断。对于引用类型,仅当你认为相等的含义并非是对象标识相等时,才需要覆写Object.Equals( )实例方法。在覆写Equals( )时也要实现IEquatable<T>。

原则7:选择恒定的原子值类型数据

  常量性的类型使得我们的代码更加易于维护。不要盲目地为类型中的每一个属性都创建get和set访问器。对于那些目的是存储数据的类型,应该尽可能地保证其常量性和原子性。

原则8:确保0对于值类型数据是有效的

  在创建自定义枚举值时,请确保0是一个有效的选项。若你定义的是标志(flag),那么可以将0定义为没有选中任何状态的标志(比如None)。即作为标记使用的枚举值(即添加了Flags特性)应该总是将None设置为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()的缺陷

  ● GetHashCode( )方法在使用时会有不少坑,要谨慎使用。GetHashCode()函数仅会在一个地方用到,即为基于散列(hash)的集合定义键的散列值时,此类集合包括HashSet<T>和Dictionary<K,V>容器等。对引用类型来讲,索然可以正常工作,但是效率很低。对值类型来讲,基类中的实现有时甚至不正确。而且,编写的自己GetHashCode( )也不可能既有效率又正确。

  ● 在.NET中,每个对象都有一个散列码,其值由System.Object.GetHashCode()决定。

  ● 实现自己的GetHashCode( )时,要遵循上述三条原则:

  1)如果两个对象相等(由operation==定义),那么他们必须生成相同的散列码。否则,这样的散列码将无法用来查找容器中的对象。

  2)对于任何一个对象A,A.GetHashCode()必须保持不变。

  3)对于所有的输入,散列函数应该在所有整数中按随机分别生成散列码。这样散列容器才能得到足够的效率提升。

原则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循环快。

第2章 .NET资源管理

原则12:选择变量初始化而不是赋值语句

  成员初始化器是保证类型中成员均被初始化的最简单的方法——无论调用的是哪一个构造函数。初始化器将在所有构造函数执行之前执行。使用这种语法也就保证了你不会再添加的新的构造函数时遗漏掉重要的初始化代码。

  综上,若是所有的构造函数都要将某个成员变量初始化成同一个值,那么应该使用初始化器。

原则13:用静态构造函数初始化类的静态成员

  ● C#提供了有静态初始化器和静态构造函数来专门用于静态成员变量的初始化。

  ● 静态构造函数是一个特殊的函数,将在其他所有方法执行之前以及变量或属性被第一次访问之前执行。可以用这个函数来初始化静态变量,实现单例模式或执行类可用之前必须进行的任何操作。

  ● 和实例初始化一样,也可以使用初始化器语法来替代静态的构造函数。若只是需要为某个静态成员分配空间,那么不妨使用初始化器的语法。而若是要更复杂一些的逻辑来初始化静态成员变量,那么可以使用静态构造函数。

  ● 使用静态构造函数而不是静态初始化器最常见的理由就是处理异常。在使用静态初始化器时,我们无法自己捕获异常。而在静态构造函数中却可以做到。

原则14:使用构造函数链

  ● 编写构造函数很多时候是个重复性的劳动,如果你发现多个构造函数包含相同的逻辑,可以将这个逻辑提取到一个通用的构造函数中。这样既可以避免代码重复,也可以利用构造函数初始化器来生成更高效的目标代码。

  ● C#编译器将把构造函数初始化器看做是一种特殊的语法,并移除掉重复的变量初始化器以及重复的基类构造函数调用。这样使得最终的对象可以执行最少的代码来保证初始化的正确性。

  ● 构造函数初始化器允许一个构造函数去调用另一个构造函数。而C# 4.0添加了对默认参数的支持,这个功能也可以用来减少构造函数中的重复代码。你可以将某个类的所有构造函数统一成一个,并为所有的可选参数指定默认值。其他的几个构造函数调用某个构造函数,并提供不同的参数即可。

原则15:使用using和try/finally来做资源清理

  对于文件等有Close方法的IDisposable对象,应该使用Dispose方法来代替Close,因为前者还会进行GC.SuppressFinalize操作,明显提高性能。

原则16:垃圾最小化

  1. 频繁调用的成员方法中如果有局部作用域的资源,尝试把资源作为对象的成员数据。如在OnPaint中使用的Font作为成员的话就不必每次都创建。

  2. 常用的资源考虑作为静态对象,还可以作为属性在get中进行延迟加载等。如Brush.Black等。

  3. 常量性数据避免频繁修改。如string,可以使用string.format和StringBuilder来减少垃圾。

原则17:装箱和拆箱的最小化

  注意将struct转换为引用类型,都会造成装箱。如将struct转换为object/ValueType/interface。

原则18:实现标准的处理(Dispose)模式

  ● GC可以高效地管理应用程序使用的内存。不过创建和销毁堆上的对象仍旧需要时间。若是在某个方法中创建了太多的引用对象,将会对程序的性能产生严重的影响。

 

  这里有一些规则,可以帮你尽量降低GC的工作量:

  1)若某个引用类型(值类型无所谓)的局部变量用于被频繁调用的例程中,那么应该将其提升为成员变量。

  2)为常用的类型实例提供静态对象。

  3)创建不可变类型的最终值。比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类。

第3章 使用C#表达设计

原则19:选择定义和实现接口而不是继承

  1. 接口表示“behaves like”,而继承表示“is a”。继承同一个基类的多个类型是相关类型,而继承同一个接口的多个类型可以完全无关,只是都包含一组行为。

  2. 接口的签名相对较稳定,因为对接口的修改将影响所以实现该接口的类型;而修改继承体系中的基类,如果有默认实现的话,派生类不一定需要调整。

  3. 公开接口相比公开具体类,前者暴露更少的实现细节。

原则20:明辨接口实现和虚函数重载的区别

  实现接口时,接口方法的具体修饰如virtual、abstract等可以自由设置。

原则21:用委托来表示回调

  在C#中,回调是用委托来实现的,主要要点如下:

  1)委托为我们提供了类型安全的回调定义。虽然大多数常见的委托应用都和事件有关,但这并不是C#委托应用的全部场合。当类之间有通信的需要,并且我们期望一种比接口所提供的更为松散的耦合机制时,委托便是最佳的选择。

  2)委托允许我们在运行时配置目标并通知多个客户对象。委托对象中包含一个方法的应用,该方法可以是静态方法,也可以是实例方法。也就是说,使用委托,我们可以和一个或多个在运行时联系起来的客户对象进行通信。 

  3)由于回调和委托在C#中非常常用,以至于C#特地以lambda表达式的形式为其提供了精简语法。

  4)由于一些历史原因,.NET中的委托都是多播委托(multicast delegate)。多播委托调用过程中,每个目标会被依次调用。委托对象本身不会捕捉任何异常。因此,任何目标抛出的异常都会结束委托链的调用。

原则22:用事件定义对外接口

  ● 事件提供了一种标准的机制来通知监听者,而C#中的事件其实就是观察者模式的一个语法上的快捷实现。

  ● 事件是一种内建的委托,用来为事件处理函数提供类型安全的方法签名。任意数量的客户对象都可以将自己的处理函数注册到事件上,然后处理这些事件,这些客户对象无需在编译器就给出,事件也不必非要有订阅者才能正常工作。

  ● 在C#中使用事件可以降低发送者和可能的通知接受者之间的耦合,发送者可以完全独立于接受者进行开发。

原则23:避免返回内部类对象的引用

  ● 若将引用类型通过公有接口暴露给外界,那么对象的使用者即可绕过我们定义的方法和属性来更改对象的内部结构,这会导致常见的错误。

  ● 共有四种不同的策略可以防止类型内部的数据结构遭到有意或无意的修改:

  1)值类型。当客户代码通过属性来访问值类型成员时,实际返回的是值类型的对象副本。

  2)常量类型。如System.String。

  3)定义接口。将客户对内部数据成员的访问限制在一部分功能中。

  4)包装器(wrapper)。提供一个包装器,仅暴露该包装器,从而限制对其中对象的访问。

原则24:选择申明式编程而不是命令式编程

  主要指特性的应用(System.Attribute的派生类)。例如序列化特性等

原则25:让你的类型支持序列化

   任何可序列化的类型要求其主要数据成员也应该可序列化,所以考虑是否要让类型支持序列化的时候,还需要考虑将来该类型是否有可能被用于其他可序列化类型作为其成员(即放弃A的序列化能力,会给包含A的可序列化类型B带来麻烦)。一般只有UI对象等不需要序列化能力,所以大部分类型都应该尽可能的支持序列化。

原则26:用IComparable和IComparer实现对象的顺序关系

   排序过程中的比较操作频率很高,需要尽可能高效,注意减少不必要的装箱和使用IComparer代替委托。

原则27:避免使用ICloneable

  1. 值类型不支持多态,且本身的赋值操作符效率很高,一般不需要实现ICloneable。

  2. 引用类型实现ICloneable的时候,考虑像C++那样,先实现拷贝构造函数,然后用拷贝构造函数实现Clone。

原则28:避免转换操作

  建议使用单参数构造函数代替类型转换。首先,隐式类型转换由于过于隐蔽容易造成bug需要严格控制,如C++中慎用operator T和要求单参构造函数声明为explicit,都是为了限制隐式类型转换,规则应用到C#后,还剩下两种选择,即单参构造函数和explicit的类型转换,前者正是本条款提倡的,而后者,只有当转换后的类型具有常量性的时候可以使用(如果转换为可以修改类型,因为返回的对象是临时对象,应用在这个临时对象的修改会无效,忽略了这点可能会产生较隐蔽的bug。如Array.Sort((int[])myListType);)。

原则29:仅在对基类进行强制更新时才使用new修饰符

  ● 使用new操作符修饰类成员可以重新定义继承自基类的非虚成员。

  ● new修饰符只是用来解决升级基类所造成的基类方法和派生类方法冲突的问题。

  ● new操作符必须小心使用。若随心所欲的滥用,会造成对象调用方法的二义性。

第4章 创建二进制组件

原则30:选择与CLS兼容的程序集

   要让程序集能够被不同.Net语言访问(语言互操作性),程序集需要与CLS兼容。使用特性[assembly: CLSCompliant(true)],让编译器检查CLS兼容性。CLS兼容意味着,公开或保护的方法和接口与CLS兼容。

原则31:选择小而简单的函数

  较小的函数比大函数有更少的局部变量,有利于JIT合理分配寄存器,有利于内联

  将C#代码翻译成可执行的机器码需要两个步骤。

  C#编译器将生成IL,并放在程序集中。随后,JIT将根据需要逐一为方法(或是一组方法,如果涉及内联)生成机器码。短小的方法让JIT编译器能够更好地平摊编译的代价。短小的方法也更适合内联。

  除了短小之外,简化控制流程也很重要。控制分支越少,JIT编译器也会越容易地找到最适合放在寄存器中的变量。

  所以,短小方法的优势,并不仅体现在代码的可读性上,还关系到程序运行时的效率。

原则32:选择小而内聚的程序集

  程序集的划分标准之一:程序集的功能应该能够一句话概括。

原则33:限制类型的访问

  可以使用内部类实现公开接口等

原则34:创建大容量的Web API

  大粒度的Web API、RPC API、脚本API等,由于载荷大,调用频率更低,相比小粒度的API具有跨边界通信总成本小的优点

第5章 使用框架

原则35:选择重写函数而不是使用事件句柄

  面对override和event两种事件处理选择的时候,前者更优:

  (1)覆盖虚方法性能更高,还可应控制调用基类方法触发event的时机。

  (2)event中的方法链是动态表,有可能因未知的原因导致方法没有被调用。如方法链的前端有异常抛出或委托对象被清空等。

原则36:利用.Net运行时诊断

  1. 在编译时,可以通过编译选项控制System.Diagnostics.Trace和System.Diagnostics.Debug的开关。

  2. 在运行时,可以通过配置文件配置Trace的输出目标文件和等级等。

原则37:使用标准的配置机制

  除.Net默认的配置文件外,还可以利用Xml序列化实现简单的读写配置

原则38:使用和支持数据绑定

  应该尽量利用UI库提供的数据绑定功能,它能够大幅简化代码逻辑。数据绑定允许配置数据源和目标属性、允许控制源和目标类型不匹配时的转换方式,数据绑定能够正确处理数据同步的时机,避免了在多处编写重复的同步代码。

原则39:使用.Net验证

原则40:根据需求选择集合

原则41:选择DataSet而不是自定义的数据结构

  DataSet由于有数据库的视图、约束、事务等逻辑,很容易和UI结合。缺点是DataSet中表项是弱类型。

原则42:使用特性进行简单的反

   特性的一种用法:给类型或成员打上指定特性作为标记,用于在反射时进行类型、方法的筛选。

原则43:请勿滥用反射

   反射是晚绑定,容易引入人为bug,除非对动态编码有高要求,否则尽量优先考虑用工厂模式、接口、委托等方案替代反射

原则44:创建应用程序特定的异常类

  1. 选择抛出异常是因为clr抛出的异常信息有限,以及有的错误情况如不及时反馈会在后期爆发时无从追查。

  2. 外部会根据错误原因做区别处理时,才考虑使用特定类型的异常。

  3. 使用新异常类型来表达特殊错误,相比始终抛出System.Exception并用描述字符串区分错误原因的方案,后者因为依赖于字符串无强类型保证,不利于重构且易引起bug。

第6章 杂项讨论

原则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:了解更多的工具和资源

  单元测试(NUnit)、代码分析(FxCop)、IL反汇编(ILDasm)、官方网站和社区、.Net和C#源码(rotor)等。

原则49:为C#2.0做好准备

原则50:了解ECMA标准

posted @ 2017-06-07 15:04  一指流砂~  阅读(393)  评论(0编辑  收藏  举报