Effective C#读书笔记(Unity特化版)

Effective C# Unity版本

之所以叫Unity特化版是因为,我一般只在Unity里使用C#,所以这本书里的很多条作用不大。参考了知乎的一个大佬进行了学习,他看的是第二版,而这本书现在已经出到第三版了。我比对了一下发现,第三版把很大的篇幅放在了泛型和LINQ上,而LINQ我其实用的很少。所以就先根据他的博客把第二版选择性的阅读了一遍。他的笔记相对来说太简略了,我补充了很多我认为也很重要的点和一些细节。以后有时间会把第三版中一些适用于Unity的条例继续更新。

虽然加了很多东西,但是核心部分还是别人的,所以就投转载了,原文链接:https://zhuanlan.zhihu.com/p/24553860。

补充知识

何谓编程中的几等公民?

  • 一等公民:一般来说,如果某程序设计语言中的一个值可以作为参数传递,可以从子程序中返回,可以赋值给变量,就称它为一等公民

  • 二等公民:可以作为参数传递,但是不能从子程序中返回,也不能赋给变量

  • 三等公民:它的值连作为参数传递都不行(比如label)


原则1:使用属性而不是可访问的数据成员

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

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

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

  • 属性增加多线程的支持是非常方便的。你可以加强 get 和 set 访问器(accessors)的实现来提供数据访问的同步。
  • 属性可以被定义为virtual。
  • 可以把属性扩展为abstract。
  • 可以使用泛型版本的属性类型。
  • 属性也可以定义为接口。
  • 因为实现实现访问的方法get与set是独立的两个方法,可以分别定义不同的访问权限。
  • 可以使用属性的语法创建索引器:public T this[int index]。其中index的类型可以使任意的。而为了和多维数组保持一致,我们可以创建多维索引器,不同维度的索引的类型可以不同。(所有的索引器都必须使用this关键字声明)

由于调用数据成员和属性的方法相同,当把一个数据成员改为属性以后,看上去是无需修改代码,很方便。但实际上这两种的实现方法不同,即二进制序列不同,所以修改后所有用到该数据成员/属性的地方都会重新编译。由于C#把二进制程序集作为一等公民看待。该语言本身的一个目标就是支持发布某个单一程序集时,不需要更新整个的应用程序。而数据成员到属性的改变会破坏这一点。所以最开始就应该使用属性。

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

原则2:偏向于使用运行时常亮(readonly)而不是编译时常量(const)

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

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

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

  • 编译时常量(const)的值会被目标代码中的值直接取代。
  • 运行时常量(readonly)的值是在运行时进行求值。引用(重点)运行时生成的IL将引用到readonly变量,而不是变量的值。

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

  • 编译时常量(const)仅能用于数值和字符串。
  • 运行时常量(readonly)可以为任意类型。运行时常量必须在构造函数或初始化器中初始化,因为在构造函数执行后不能再被修改。你可以让某个readonly值为一个DataTime结构,而不能指定某个const为DataTIme。
  • 可以用readonly值保存实例常量,为类的每个实例存放不同的值。而编译时常量就是静态的常量。
  • 它们之间最为重要的区别还在于:readonly常量是在程序运行的时候才加以解析的,也就是说,如果代码里面用到了这样的常量,那么由这段代码所生成的IL码会通过引用的方式来使用这个readonly量,而不会直接使用常量值本身。这对代码的维护工作有很大影响,因为在生成IL的时候,代码中的编译期常量会直接以字面值的形式写进去,如果你在制作另外一个程序集(assembly)的时候用到了本程序集里面的这个常量,那么它会直接以字面值的形式写到那个程序集里面。也就是说,如果在更改了const的值以后,如果在其他程序集中使用了该const值并且该程序集没有重新编译,那么就会使用旧的const的值。
  • 有的时候,开发者确实想把某个值在编译期固定下来,应该使用const。
  • 标记版本号的值就应该使用运行时常量(readonly),因为它的值会随着每个不同版本的发布而改变。
  • const优于readonly的地方仅仅是性能,使用已知的常量值要比访问readonly值略高一点,不过这其中的效率提升,可以说是微乎其微的。(const不需要占用内存,而readonly需要占用内存)

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

原则3: 推荐使用is 或as操作符而不是强制类型转换

这种类型转换操作很少会为了类型转换而构建新的对象(但若用as运算符把装箱的值类型转换成未装箱且可以为null的值类型,则会创建新的对象)。C#中,is和as操作符的用法概括如下。

  • is : 检查一个对象是否兼容于其他指定的类型,并返回一个Bool值,永远不会抛出异常。
  • as:作用与强制类型转换是一样,但是永远不会抛出异常,即如果转换不成功,会返回null。
  • 尽可能的使用as操作符,因为相对于强制类型转换来说,as更加安全,也更加高效。
  • as在转换失败时会返回null,在转换对象是null时也会返回null,所以使用as进行转换时,只需检查返回的引用是否为null即可。
  • as和is操作符都不会执行任何用户自定义的转换,它们仅当运行时类型符合目标类型时才能转换成功,也不会在转换时创建新的对象。
  • as运算符对值类型是无效,此时可以使用is,配合强制类型转换进行转换。(也可以用as转换成可空型,例如int?)
  • 仅当不能使用as进行转换时,才应该使用is操作符。否则is就是多余的。(C# 7.0以后,对is操作符增强了模式匹配:if(data is string text)在用is判断的同时也声明了新变量text保存data的string类型强制转换的结果。
  • 用户自定义的转换逻辑针对的是对象的运行期类型,而非编译期类型。而强制转换时,其考虑的是对象的编译期类型,而不是实际类型。所以假如一个object类型的对象实际类型是TypeA,TypeA定义了向TypeB的类型转换,但直接t=(TypeB)o是不能成功的。

原则4: 推荐使用条件属性(Conditional)而不是#if条件编译

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

  • Conditional特性只可应用在整个方法上,另外,任何一个使用Conditional特性的方法都只能返回void类型。这将强制我们将条件代码拆分为独立的方法。不能再方法内的代码块上应用Conditional特性。也不可以在有返回值的方法上应用Conditional特性。但应用了Conditional特性的方法可以接受任意数目的引用类型参数。
  • 使用Conditional特性生成的IL要比使用#if/#Eendif时更有效率。同时,将其限制在函数层面上可以更加清晰地将条件性的代码分离出来,以便进一步保证代码的良好结构。使用Conditional特性时,该方法将直接不会出现在Relaese版本的代码中;而在方法中使用#if,则一样会有该方法,只不过该方法为空,但调用等一样会有一些代价。
  • 如果对一个方法有两个Conditional特性,是逻辑或的关系。如果要实现与的关系,可以自己通过#if中条件与来定义。

原则5:理解几个等同性判断之间的关系

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

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

  • public static bool ReferenceEquals (object left, object right);判断两个不同变量的对象标识(object identity)是否相等。无论比较的是引用类型还是值类型,该方法判断的依据都是对象标识,而不是对象内容。(所以比较值类型永远返回false)
  • public static bool Equals (object left, object right); 用于判断两个变量的运行时类型是否相等。(实际实现中是调用下面的Equal方法实现的)
  • public virtual bool Equals(object right); 用于重载。默认的Equals对引用的实现和ReferenceEquals完全相同,但对值类型是通过反射实现对变量的比较的,所以虽然能实现正确的比较,但性能较差,一般来说都要自己重写。
  • 在Equals要用GetType()检查两个对象的精确类型,否则可能会因为继承关系破坏Equal的对称性,即base.Equals(derived)返回true,而derived.Equals(base)返回false。
  • public static bool operator ==(MyClass left, MyClass right); 用于重载

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

原则6:了解GetHashCode( )的一些坑

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

在.NET中,每个对象都有一个散列码,其值由System.Object.GetHashCode()决定。实现自己的GetHashCode( )时,要遵循上述三条原则:

  • 如果两个对象相等(由operation==**定义),那么他们必须生成相同的散列码。否则,这样的散列码将无法用来查找容器中的对象。(所以重写operation==**的同时一定要重写GetHashCode)
  • 对于任何一个对象A,A.GetHashCode()必须保持不变。
  • 对于所有的输入,散列函数应该在所有整数中按随机分别生成散列码。这样散列容器才能得到足够的效率提升。一个常用的算法是:对一个类型中所以的常量字段调用GetHashCode返回的值进行异或运算。
  • 默认的GetHashCode实现是系统创建每一个对象时,都会给他指派一个唯一的对象键(一个整数值),这个值是按顺序递增的,所以集中分布在整数的低端,效率很低。
  • 默认情况下使用值类型的第一个字段生成散列码。(所以如果使用默认的散列码生成方式,并且运行时第一个字段的值发生了变化,那么就会破坏第第二条原则,这也是尽量使用不可变的值类型的又一个理由)。所以应该使用常量属性返回散列码。

原则7:理解短小方法的优势

将C#代码翻译成可执行的机器码需要两个步骤。C#编译器将生成IL,并放在程序集中。随后,JIT将根据需要逐一为方法(或是一组方法,如果涉及内联)生成机器码。JIT不会在程序刚开始时就完全翻译所有的IL。短小的方法让JIT编译器能够更好地平摊编译的代价。

  • 短小的方法也更适合内联。C#中没有什么关键字能够制定内联,主要靠JIT编译器自动执行。所以简洁的代码会更有利于代码被内联,提高效率。

  • 在if-else分支里如果各有几十条语句,则第一次调用其所在的方法时,两个分支都要被JIT编译。但若将其写成单独的方法,就只会JIT编译将要调用的那个分支。

  • 除了短小之外,简化控制流程也很重要。控制分支越少,JIT编译器也会越容易地找到最适合放在寄存器中的变量。并且越少使用局部变量,也就让JIT编译器能够更方便的找到最适合放在寄存器里的那一些。

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

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

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

有如下3中情况中,应该避免初始化器语法:

  1. 当你想要初始化对象为0或null时。系统默认的初始化工作将在执行所有代码前把一切设置为0或null,这一部是很底层的实现,会直接使用CPU指令将一整块内存设置为0,因此,再执行一次额外的0初始化只会增加额外的开销。
  2. 在不同构造函数时需要对同一个变量执行不同的初始化方式。
  3. 对象初始化器执行的过程中发生的所有异常都会传递到对象之外。在类的内部无法尝试修复。此时应该将这部分初始化代码放在构造函数中,这样才能实现必要的恢复性代码。

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

原则9:正确地初始化静态成员变量

C#提供了有静态初始化器和静态构造函数来专门用于静态成员变量的初始化。静态构造函数是一个特殊的函数,将在其他所有方法执行之前以及变量或属性被第一次访问之前执行。可以用这个函数来初始化静态变量,实现单例模式或执行类可用之前必须进行的任何操作。如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑运用Lazy机制,将初始化工作推迟到首次访问该字段的时候再去执行。

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

静态构造函数每个类只能定义一个,而且不能带有参数。由于它是由CLR自动调用的,因此必须谨慎处理其中的异常。如果异常跑到了静态构造函数外面,那么CLR就会抛出TypeInitialization-Exception以终止该程序。调用方如果想要捕获这个异常,那么情况将会更加微妙,因为只要AppDomain还没有卸载,这个类型就一直无法创建,也就是说,CLR根本就不会再次执行其静态构造函数,这导致该类型无法正确地加以初始化,并导致该类及其派生类的对象也无法获得适当的定义。因此,不要令异常脱出静态构造函数的范围。

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

所以,类的初始化顺序:把存放静态变量的空间清零->静态字段的初始化语句->基类静态构造函数->本类静态构造函数->把存放实例变量的空间清零->实例字段初始化器->适当的执行基类的实例构造函数->实例构造函数。

原则10:使用构造函数链(减少重复的初始化逻辑)

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

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

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

  • 默认参数的值必须是编译期常量。例如对string类型的参数来说,只能使用""而不能使用string.Empty。
  • 使用带有默认值的参数来编写构造函数也是有缺点的,因为与编写重载版本相比,这种做法会令客户代码与本类耦合得更加紧密,尤其是会令形式参数的名称及其默认值也成为公共接口的一部分。如果修改了默认值,那么必须把客户代码重新编译一遍,才能够令那些使用旧默认值的代码转而使用新的默认值。用重载的办法来编写构造函数更能适应将来的变化,即便以后添加新的构造函数,或是修改不带默认值的构造函数,也不会影响客户代码。
  • 与依赖辅助函数的那种写法相比,这种采用链式调用的写法有一个好处,就是编译器不会在每一个构造函数里面都去调用基类的构造函数,也不会把初始化成员变量所用的那些语句在每一个构造函数开头重复一遍。这样只会在最后那个构造函数里面调用基类的构造函数,这是个相当重要的特点。
  • 构造函数可以通过初始化命令把一部分工作委派给另一个构造函数,但只能委派一次,也就是说,要么通过this()委派给本类的其他构造函数,要么通过base()委派给基类的构造函数,但不能同时委派给双方。

带有new()约束的泛型类或泛型方法必须看到无参数的构造函数才会允许用户把MyClass当作泛型参数。假如只提供那种所有参数都具备默认值的构造函数,那么代码就无法编译。为此,开发者需要专门创建无参数的版本,即使只是调用默认参数的构造函数。

原则11:实现标准的销毁模式

C#大部分对象在进行垃圾回收时都可以回收,包括非托管资源,因为非托管资源都已经通过C#类进行了封装,会将非托管资源的释放放在析构函数中,同时会实现IDipose接口。IDipose作用是可以通过using手动提前释放,节约宝贵的资源。

  • 析构函数只能由垃圾回收器调用。Despose()方法只能由类的使用者调用。

标准的dispose(释放/处置)模式既会实现IDisposable接口,又会提供finalizer(终结器/终止化器),以便在客户端忘记调用IDisposable.Dispose()的情况下也可以释放资源。这样做虽然有可能令程序的性能因执行finalizer而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。

在类的继承体系中,位于根部的那个基类应该做到以下几点:

  • 实现IDisposable接口,以便释放资源。
  • 如果本身含有非托管资源,那就添加finalizer,以防客户端忘记调用Dispose()方法。若是没有非托管资源,则不用添加finalizer。
  • Dispose方法与finalizer(如果有的话)都把释放资源的工作委派给虚方法,使得子类能够重写该方法,以释放它们自己的资源。

继承体系中的子类应该做到以下几点:

  • 如果子类有自己的资源需要释放,那就重写由基类所定义的那个虚方法,若是没有,则不用重写该方法。
  • 如果子类自身的某个成员字段表示的是非托管资源,那么就实现finalizer,若没有这样的字段,则不用实现finalizer。
  • 记得调用基类的同名函数。

如果你所编写的类使用了某些必须及时释放的资源,那就应该按照惯例实现IDisposable接口,实现IDisposable.Dispose()方法时,要注意以下四点:

  1. 把非托管资源全都释放掉。
  2. 把托管资源全都释放掉(这也包括不再订阅早前关注的那些事件)。
  3. 设定相关的状态标志,用以表示该对象已经清理过了。如果对象已经清理过了之后还有人要访问其中的公有成员,那么你可以通过此标志得知这一状况,从而令这些操作抛出ObjectDisposedException。
  4. 阻止垃圾回收器重复清理该对象。这可以通过GC.SuppressFinalize(this)来完成。

垃圾回收器每次运行的时候,都会把不带finalizer(终结器)的垃圾对象立刻从内存中移走,而带有finalizer的对象则会继续留在内存里面,而且会添加到队列中。GC会安排线程在这些对象上面运行finalizer,运行完毕之后,通常就可以像那些不带finalizer的垃圾对象一样从内存中移走。如果类中有非托管资源,都应该实现finalizer,防止调用者忘记显示的调用Dispose而导致非托管资源没有被清理,而finalizer的内容往往就是调用Dispose(false)清理掉非托管资源。

把finalizer与Dispose()中的重复代码提取到protected级别的虚函数里面,使得子类能够重写该函数,以释放它们自己所分配的那些资源,而基类则应该在接口方法里面把核心的逻辑实现好。

GC可以高效地管理应用程序使用的内存。不过创建和销毁堆上的对象仍旧需要时间。若是在某个方法中创建了太多的引用对象,将会对程序的性能产生严重的影响。
这里有一些规则,可以帮你尽量降低GC的工作量:

  • 若某个引用类型(值类型无所谓)的局部变量用于被频繁调用的例程中,那么应该将其提升为成员变量。
  • 为常用的类型实例提供静态对象。
  • 创建不可变类型的最终值。比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类。

原则12:区分值类型和引用类型

C#中,class对应引用类型,struct对应值类型。
C#不是C++,不能将所有类型定义成值类型并在需要时对其创建引用。C#也不是Java,不像Java中那样所有的东西都是引用类型。你必须在创建时就决定类型的表现行为,这相当重要,因为稍后的更改可能带来很多灾难性的问题。

值类型无法实现多态,因此其最佳用途就是存放数据,用来通过公有方法和属性暴露数据的类型应该是值类型。引用类型支持多态,因此用来定义应用程序的行为。

由于每个引用类型的变量都持有一个引用,具体的存储空间还需要额外的分配,在初始化或分配空间时,值类型可以一次性完成,而引用类型则需要多次。不仅更占用事件,而且分配大量的引用类型将让堆上充满碎片,让程序变慢。

一般情况下,我们习惯用class,随意创建的大都是引用类型,若下面几点都肯定,那么应该创建struct值类型:

  • 该类型主要职责在于数据存储吗?
  • 该类型的公有接口都是由访问其数据成员的属性定义的吗?
  • 你确定该类型绝不会有派生类型吗?
  • 你确定该类型永远都不需要多态支持吗?

用值类型表示底层存储数据的类型,用引用类型来封装程序的行为。这样,你可以保证类暴露出的数据能以复制的形式安全提供,也能得到基于栈存储和使用内联方式存储带来的内存性能提升,更可以使用标准的面向对象技术来表达应用程序的逻辑。而倘若你对类型未来的用图不确定,那么应该选择引用类型。

原则13:保证0为值类型的有效状态

由于.NET系统的默认初始化过程会将所有的对象设置为0,所以所有值类型都应该保证0是有效状态,例如枚举等。

另一个常见的初始化问题是包含引用的值类型。字符串就是其一,当值类型中包含字符串msg并且默认初始化时,这时候该msg的实例值是null,无法直接调用。一种解决方法是通过一个属性将该msg暴露给外接使用者,然后再属性中添加逻辑,在msg为null时返回空字符串。

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

综上所述,其实重点就在于要定义值类型的0值的意义,防止默认初始化时出现问题。

原则14:保证值类型的常量性和原子性

常量性的意思是:自创建后,其值就保持不变。常量性有很多好处:

  • 如果在构造对象的时候验证了参数的有效性,那么就能保证从此以后该变量始终都是有效的,可以省去很多错误检查。
  • 天生线程安全
  • 在散列的集合中有很好的表现,GetHashCode()返回的是一个不变量。

要注意常量类型重的可变的引用类型字段,在为这样的类设计构造函数时,需要对其中的可变类型进行防御性的复制。(其中一种错误可能是内部使用了常量的引用类型,而在外面又通过其他的引用指向了同一块内存,就会导致被修改)。可以在构造函数中通过复制的方法来构建实例。

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

原则15:限制类型的可见性

在保证类型可以完成其工作的前提下。你应该尽可能地给类型分配最小的可见性。也就是,仅仅暴露那些需要暴露的。尽量使用较低可见性的类来实现公有接口。可见性越低,能访问你功能的代码越少,以后可能出现的修改也就越少。

对一些仅使用接口描述其功能就足够的类,考虑使用暴露接口使得在程序集外部创建内部的类的方法,使用接口调用内部的类。这样在日后替换实现时也很方便,只要两个类实现同样的接口,方便扩展修改。

此外,更少的共有类型也会让单元测试变得更加简单。考虑使用内部类来降低某些简单类的可见性。

原则16:通过定义并实现接口替代继承

理解抽象基类(abstract class)和接口(interface)的区别:

  • 接口是一种契约式的设计方式,一个实现某个接口的类型,必须实现接口中约定的方法。抽象基类则为一组相关的类型提供了一个共同的抽象。也就是说抽象基类描述了对象是什么,而接口描述了对象将如何表现其行为。
  • 接口不能包含实现,也不能包含任何具体的数据成员。而抽象基类可以为派生类提供一些具体的实现。
  • 向抽象基类中添加一个方法,所有派生类都将自动包含这个方法。而向接口中添加一个成员,则会破坏所有实现该接口的类。所以,一般来说接口是固定的:我们将一组功能封装在一个接口中,作为其他类型的实现契约;而基类则可以在日后进行扩展,这些扩展也会成为每个派生类的一部分。
  • 基类描述并实现了一组相关类型间共用的行为。接口则定义了一组具有原子性的功能,供其他不相关的具体类型来实现。
  • 虽然接口不能提供实现,但是可以通过扩展方法的方式模拟提供实现。并且这两种模型也可以混合使用,既继承基类也实现接口。这里就用到了扩展方法,Ienumerable接口和Enumerable类就是如此,实现了Ienumerable接口的类都可以使用Enumerable类中的扩展方法。
  • 可以将接口作为参数和返回值。使用接口做参数,任何实现了该接口的类都可以直接使用该方法。根据接口编程要比根据基类编程能为其他开发人员提供更好的灵活性。
  • 可以将接口作为返回值,使类不需要把所有的接口和属性暴露出去。通过返回指定的接口,可以选择仅为使用者提供那些必要的方法和属性。而实现该接口的细节则无关。
  • 不想关的类型也可以实现同一个接口,可以简化一些公共的功能。例如几个没有关系的类实现了几个相同的变量,想要用一个公共的方法打印这几个变量。这时可以定义一个接口,包含这几个变量,然后将接口作为参数来实现这个公共的方法。
  • 有时候,使用接口还可以帮助我们避免struct拆箱所带来的代价。当struct装箱时,该装箱对象实际上支持struct支持的所有借口。所以通过接口指针来访问该struct时,不必拆箱就可以访问到内部的数据,实现接口所定义的部分功能。

理解好两者之间的差别,我们便可以创造更富表现力、更能应对变化的设计。使用类层次来定义相关的类型。用接口暴露功能,并让不同的类型实现这些接口。

原则17:理解接口方法和虚方法的区别

第一眼看来,实现接口和覆写虚方法似乎没有什么区别,是在一个类型中实现另一个类型中声明的成员。实际上,实现接口和覆写虚方法之间的差别很大。

  • 接口中声明的成员方法默认情况下并非虚方法,所以,派生类不能覆写基类中实现的非虚接口成员。若要覆写的话,将接口方法声明为virtual即可。
  • 基类可以为接口中的方法提供默认的实现,随后,派生类也可以声明其实现了该接口,并从基类中继承该实现。
  • 当一个父类实现了Imsg接口的Message方法时,其子类直接声明一个新的Message方法后,对其子类用接口调用Imsg的Message方法还是会使用父类的实现。而若子类中用new来声明Message方法,此时在用接口调用就会使用子类的实现,new关键字相当于告诉接口用子类中的实现来覆盖了父类的接口实现。
  • 在实现一个接口的同时,可以不实现接口中的方法,而是将方法声明为抽象方法。
  • 还可以在派生类中将该方法密封(sealed),让其不会再被覆写;也可以在接口指定的方法中调用虚函数,然后在派生类中只需要重写虚函数的实现即可。
  • 基类可以为接口中的方法提供默认的实现。如果父类种实现了Message方法,而子类声明实现了Imsg接口,那么子类中就可以不写Message方法。派生类可以把该接口声明成为契约的一部分,即使它并没有提供任何Imsg接口中方法的实现。只要类中某个公开可访问的方法与接口的签名想匹配,那么接口的契约即可被满足。但是使用这种方法就无法使用显示接口调用。
  • 实现接口拥有的选择要比创建和覆写虚方法多。我们可以为类层次创建密封(sealed)的实现,虚实现或者抽象的契约。还可以创建密封的实现,并在实现接口的方法中提供虚方法进行调用。我们也可以决定派生类应该如何以及何时修改基类中实现的接口成员的默认行为。

原则18:用委托实现回调

笔者给儿子Scott交代了一项任务,他每完成其中的一部分,就会把任务的进度告诉我,在这个过程中,我依然可以继续做自己的事情。如果发生了重要的情况,或是需要帮忙,那么他可以随时叫我(即便有些情况不太重要,也可以说给我听)。回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。

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

  • 委托为我们提供了类型安全的回调定义。虽然大多数常见的委托应用都和事件有关,但这并不是C#委托应用的全部场合。当类之间有通信的需要,并且我们期望一种比接口所提供的更为松散的耦合机制时,委托便是最佳的选择。
  • 委托允许我们在运行时配置目标并通知多个客户对象。委托对象中包含一个方法的应用,该方法可以是静态方法,也可以是实例方法。也就是说,使用委托,我们可以和一个或多个在运行时联系起来的客户对象进行通信。
  • 其实Func<T,bool>与Predicate是同一个意思,只不过编译器会把两者分开对待而已,也就是说,即便两个委托是用同一套参数及返回类型来定义的,也依然要按照两个来算,编译器不允许在它们之间相互转换。
  • 由于回调和委托在C#中非常常用,以至于C#特地以lambda表达式的形式为其提供了精简语法。
  • 由于历史原因,所有的委托都是多播委托(multicast delegate),也就是会把添加到委托中的所有目标函数(target function)都视为一个整体去按顺序在同一线程执行。这就导致有两个问题需要注意:第一,程序在执行这些目标函数的过程中可能发生异常,只要其中一个目标抛出异常,调用链就会中断,从而导致其余的那些目标函数都得不到调用;第二,程序会把最后执行的那个目标函数所返回的结果当成整个委托的结果。这就会带来一个问题:例如一个返回值不为空的委托,调用者使用该委托,但是返回值确是最后一个返回值,而不是所期望的返回值。解决方法是:由于每个委托都会以列表的形式来保存其中的目标函数,因此只要在该列表上面迭代,并把这些目标函数轮流执行一遍就可以了,只要发现有一个函数返回false,就不再执行列表中的其他函数了。

总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET程序里面,需要回调客户端的地方应该考虑用委托来做。

原则19:用事件模式实现通知

public event EventHandler<T> Log是.NET中事件的泛型实现,类似于Action和Func的效果。其包括两个参数,分别是object sender,TEventArgs e。第一个是发送者,往往填this值自己;第二个是泛型的数据类型。若是使用非泛型版本的,则代表没有数据,第二个参数可以填null。(数据类型要从EventArgs继承)

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

  • 事件是一种内建的委托,用来为事件处理函数提供类型安全的方法签名。任意数量的客户对象都可以将自己的处理函数注册到事件上,然后处理这些事件,这些客户对象无需在编译器就给出,事件也不必非要有订阅者才能正常工作。
  • 优先定义公有的事件字段。编译器会自动生成一个私有的事件字段,和对这个私有字段的Add和Remove操作符的封装,有点类似于属性的自动生成。所以除非需要添加额外的逻辑,否则都应该有限创建公有的事件字段。

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

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

若将引用类型通过公有接口暴露给外界,那么对象的使用者即可绕过我们定义的方法和属性来更改对象的内部结构,这会导致常见的错误。
共有四种不同的策略可以防止类型内部的数据结构遭到有意或无意的修改:

  1. 值类型。当客户代码通过属性来访问值类型成员时,实际返回的是值类型的对象副本。
  2. 常量类型。如System.String。
  3. 定义接口。将客户对内部数据成员的访问限制在一部分功能中。
  4. 包装器(wrapper)。提供一个包装器,仅暴露该包装器,从而限制对其中对象的访问。

原则21:仅用new修饰符处理基类更新

  • 非虚方法是静态绑定的。任何地方的任何引用了该方法的代码都会调用到同一个方法。运行时不会查看定义在派生类中的其他版本。而虚方法是动态绑定的,运行时根据对象的运行时类型调用正确的版本。
  • 使用new操作符修饰类成员可以重新定义继承自基类的非虚成员。
  • new修饰符只是用来解决升级基类所造成的基类方法和派生类方法冲突的问题。
  • new操作符必须小心使用。若随心所欲的滥用,会造成对象调用方法的二义性。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值