List
- 实现原理:
List内部是用数组实现的,而不是链表,并且当没有给予指定容量时,初始的容量为0,每次容量不够的时候,整个数组的容量都会扩充一倍。 - Add:每次增加一个元素的数据,Add接口都会首先检查的是容量还够不够,如果不够则用 EnsureCapacity 来增加容量。
- Remove:元素删除的原理其实就是用 Array.Copy 对数组进行覆盖。IndexOf 启用的是 Array.IndexOf 接口来查找元素的索引位置,这个接口本身内部实现是就是按索引顺序从0到n对每个位置的比较,复杂度为O(n)。
- Insert:Insert 插入元素时,使用的用拷贝数组的形式,将数组里的指定元素后面的元素向后移动一个位置。
- Contains:Contains 接口使用的是线性查找方式比较元素,对数组进行迭代,比较每个元素与参数的实例是否一致,如果一致则返回true,全部比较结束还没有找到,则认为查找失败。
- Enumerator枚举迭代:每次获取迭代器时,Enumerator 每次都是被new出来,如果大量使用迭代器的话,比如foreach就会造成大量的垃圾对象,所以尽量不要用 foreach,因为 List 的 foreach 会增加有新的 Enumerator 实例,最后由GC垃圾回收掉。
- Sort:Array.Sort 使用的是快速排序方式进行排序,从而我们明白了 List 的 Sort 排序的效率为O(nlogn)。
- 总结:List 的效率并不高,只是通用性强而已,大部分的算法都使用的是线性复杂度的算法,这种线性算法当遇到规模比较大的计算量级时就会导致CPU的大量损耗。可以提前告知 List 对象最多会有多少元素在里面,这样的话 List 就不会因为空间不够而抛弃原有的数组,去重新申请数组了。
Dictionary
- 实现原理:
健值对的映射关系通过Hash函数(算法)来实现,但由于不同的key可能映射到相同的value,这种情况称为哈希冲突。
解决哈希冲突的方法有:开放定址法、再哈希法、链地址法、建立一个公共溢出区等,Dictionary用的便是拉链法。
拉链法的原理:将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为n,则可将散列表定义为一个由n个头指针组成的指针数 组T[0..n-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。 - 总结:从效率上看,同List一样最好在实例化对象时,即 new 时尽量确定大致数量会更加高效,另外用数值方式做Key比用类实例方式作为Key值更加高效率。
从内存操作上看,大小以3->7->17->37->….的速度,每次增加2倍多的顺序进行,删除时,并不缩减内存。
委托(delegate)与事件(Event)
委托:
可以被视为一个更高级的函数指针,它不仅仅能把地址指向另一个函数,而且还能传递参数、获得返回值等多个信息,系统还为委托对象自动生成了同步、异步的调用方式,开发人员使用 BeginInvoke、EndInvoke 方法就可以避开 Thread类 从而直接使用多线程调用。
Delegate类中有个变量是用来存储函数地址的,当变量操作 =(等号) 时,把函数地址赋值给变量存起来。不过这个存储函数地址的变量是个可变数组,你可以认为是个链表,每次直接赋值时会换一个链表。
Delegate委托类还重写了 +=,-= 这两个操作符,其实就是对应 MulticastDelegate(其父类) 的 Combine 和 Remove 方法,当对函数操作 += 和 -= 时,相当于把函数地址推入了链表尾部,或者移出了链表。事件:
事件就是在委托delegate上,又做了一次封装,这次封装的意义是,限制用户直接操作delegate委托实例中变量的权限。
封装后,用户不再能够直接用赋值(即使用 = 等号操作符)操作来改变委托变量了,只能通过注册或者注销委托的方法来增减委托函数的数量,更好的维护了delegate的秩序。
装箱和拆箱
值类型和引用类型:值类型的变量会直接存储数据,如byte,short,int,long,float,double,decimal,char,bool 和 struct 统称为值类型,而引用类型的变量持有的是数据的引用,其真实数据存储在数据堆中,
栈和堆:
栈是本着先进后出的数据结构(LIFO)原则的存储机制,它是一段连续的内存,所以对栈数据的定位比较快速, 而堆则是随机分配的空间, 处理的数据比较多, 无论如何, 至少要两次定位。堆内存的创建和删除节点的时间复杂度是O(logn)。栈创建和删除的时间复杂度则是O(1),栈速度更快。
栈中的生命周期是必须确定的,销毁时必须按次序销毁,从最后分配的块部分开始销毁,创建后什么时候销毁必须是一个定量,所以在分配和销毁时不灵活,基本都用于函数调用和递归调用中,这些生命周期比较确定的地方。相反堆内存可以存放生命周期不确定的内存块,满足当需要删除时再删除的需求,所以堆内存相对于全局类型的内存块更适合,分配和销毁更灵活。(托管堆会GC)装箱步骤
第一步:在堆内存中新分配一个内存块(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex)。
第二步:将值类型的实例字段拷贝到新分配的内存块中。
第三步:返回内存堆中新分配对象的地址。这个地址就是一个指向对象的引用了。装箱拆箱的问题:
装箱、拆箱时生成的是全新的对象,不断得分配和销毁内存会不但大量消耗CPU,也同时增加了内存碎片,降低了性能。优化:
- 通过泛型来避免拆箱、装箱。
- 通过继承统一的接口提前拆箱、装箱,避免多次重复拆箱、装箱。
垃圾回收机制 GC
GC的优点
- 提高软件系统的内聚。
- 降低编程复杂度,使程序员不必分散精力去处理析构。
- 不妨碍设计师进行系统抽象。
- 减少由于内存运用不当产生的Bug。
- 成功的将内存管理工作从程序的编写时,脱离至运行时,使不可预估的管理漏洞变为可预估的。
算法工作原理
垃圾收集器的本质,就是跟踪所有被引用到的对象,整理不再被引用的对象,回收相应的内存。
一种常用的做法是引用计数器,但因为需要遍历所有对象,并维护它们的引用情况,所以效率较低,并且在出现“环引用”时很容易造成内存泄露。
.NET中采取了**标记与清除(Mark Sweep)**算法。
“标记”——垃圾的识别:
从应用程序的root出发,利用相互引用关系,遍历其在堆上动态分配的所有对象,没有被引用的对象不被标记,即成为垃圾;存活的对象被标记,即维护成了一张“根-对象可达图”。其实,CLR会把对象关系看做“树图”。
检测并标记对象引用,在.Net中是利用栈来完成的,在不断的入栈与出栈中完成检测。“清除”——回收内存:
启用Compact算法,对内存中存活的对象进行移动,修改它们的指针,使之在内存中连续,这样空闲的内存也就连续了,这就解决了内存碎片问题,当再次为新对象分配内存时,CLR不必在充满碎片的内存中寻找适合新对象的内存空间,所以分配速度会大大提高。
大对象除外,不会消耗性能去移动大对象,通常,大对象具有很长的生存期,当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,移动大对象所带来的开销超过了整理这部分堆所能提高的性能。
Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很重要的。
代龄
代龄就是对Heap中的对象按照存在时间长短进行分代,最短的分在第0代,最长的分在第2代,第2代中的对象往往是比较大的。(Generation的层级与FrameWork版本有关,可以通过调用GC.MaxGeneration得知)。
通常,GC会优先收集那些最近分配的对象(第0代),这与操作系统经典内存换页算法“最近最少使用”算法如出一辙。
.Net GC将堆空间按对象的生存期长短分成3代:新分配的对象在第0代(0代空间最大长度通常为256K),按地址顺序分配,它们通常是一些局部变量;第1代(1代空间最大长度通常为2 MB)是经过0代垃圾收集后仍然驻留在内存中的对象,它们通常是一些如表单,按钮等对象;第2代是经历过几次垃圾收集后仍然驻留在内存中的对象,它们通常是一些应用程序对象。
当内存吃紧时(例如0代对象充满),GC便被调入执行引擎——也就是CLR——开始对第0代的空间进行标记与压缩工作、回收工作,这通常小于1毫秒。如果回收后内存依然吃紧,那么GC会继续回收第1代(回收操作通常小于10毫秒)、第2代,当然GC有时并不是按照第0、1、2代的顺序收集垃圾的,这取决于运行时的情况,或是手动调用GC.Collect(i)指定回收的代。当对第2代回收后任然无法获得足够的内存,那么系统就会抛出OutOfMemoryException异常。
补充
. NET提供了三种释放资源的方式:Dispose,Close,析构函数(也就是Finalize方法)
什么是托管资源,非托管资源:托管资源是由CLR全权负责的资源,CLR不负责的资源位非托管资源。
GC回收垃圾需要两次轮询,第一次调用析构函数,第二次才是内存回收(如果没有析构,则只需一次内存回收)
推荐一下:可视化垃圾回收算法
反射
什么是反射
是.NET Framework 的功能,并不是C#语言特有的功能。
简单理解:给我一个对象,我能在不用new操作符的情况下,也不知道是什么静态类型的情况下,创建一个同类型的对象,还能访问这个对象的各个成员。
注意:
这相当于进一步解耦(如果用new操作符,意味着类型依赖)
反射是托管语言(C#,Java)和原生语言(C,C++)的最大区别之一
单元测试、依赖注入、泛型编程都是基于反射机制的
一般情况下,我们在使用反射时并不能感觉到,因为大多数我们使用的都是封装好的反射
使用情况
很多时候程序的逻辑并不是在写的时候就能确定,有时需要用户交互时才确定,此时程序已经属于运行状态(Dynamic,动态)。
如果要程序员在静态(static)编写时,去枚举用户可能做的操作,会让程序变得十分臃肿,可读性、可维护性都很烂,并且枚举用户可能做的操作这件事是很难实现的。
这是我们需要的这种:以不变应万变的能力,就是反射机制。
简单的说反射是为了方便动态地运行时加载。
关于.Net Framework的一些知识点
公共语言运行时 CLR
CLR(Common Language Runtime)是一个运行时环境,保证应用和底层操作系统之间必要的分离,是.NET Framework的主要执行引擎,是可由面向CLR的多种编程语言使用的“运行时”。
CLR的核心功能(内存管理、程序集加载、安全性、异常处理和线程同步等)由面向CLR的所有语言使用。
CLR的结构:
- CLS:公共语言规范,获取各种语言转换成统一的语法规范,是.Net跨语言开发的基础。
- CTS:通用类型系统,将各种语言中的数据类型转换成统一的类型。
- JIT:实时编译器(即时编译器)用于将转换之后的语言编译为二进制语言,交给CPU执行。
IL代码 (托管代码)
本地代码编译器是面向特定CPU架构的代码。
而每个面向CLR的编译器生成的都是IL代码 (中间语言代码 )。IL代码有时称为托管代码,因为CLR要管理它的执行。
这提供了平台移植性。
JIT(即时编译器)
JIT(Just-in-time compilation, JIT)编译器会使用即时编译,对不同架构的机器生成不同的机器码,大部分的代码优化都在这里完成。JIT 只有在运行时才会工作,当生成(Build)项目时,JIT 不会工作。编译器在编译时,会对代码进行检查,对于只调用过一次的代码,不会JIT优化。多次使用的代码(例如for循环)才会进行JIT。
通常,程序有两种运行方式:预先编译(AOT)与动态编译。预先编译的程序在执行前全部被翻译为机器码,而动态编译则是一句一句,边运行边翻译。即时编译则混合了这二者,一句句编译源代码,但是会将翻译过的代码缓存起来以降低性能损耗。
.NET的运行机制
各种语言(c#,F#,j#等对应的源程序)-> 经过CLS,CTS第一次编译 -> 统一规范语言(中间语言)MSIL(.EXE,.DLL) -> JIT第二次编译 -> 二进制语言 -> 运行在CPU中。
元数据
IL代码由面向CLR的编译器产生,但它并不是编译器产生的提供给运行时仅有的东西。编译器同样产生有关原始代码的元数据。它提供给CLR关于代码更多的东西,例如:各种类型的定义、各种类型成员的签名以及其他数据。基本上,元数据是类型库、注册表内容和其它用于COM的信息。尽管如此,元数据还是直接和执行代码合并在一起,并不处在 隔离的位置。
简单地说,元数据是整个microsoft .net framework开发平台的关键,它实现了编程语言、类型和对象的无缝集成。
程序集
程序集(assembly)是一个抽象的概念。
首先,他是一个或多个模块/资源文件的逻辑性分组。其次,程序集是重用、安全性以及版本控制的最小单元。取决于你对编译器或者工具的选择,即可以生成单文件程序集,也可以生成多文件程序集。在CLR的世界中,程序集相当于一个“组件”。利用“程序集”这个概念性的东西,可以将一组文件当成一个单独的实体来对待。
对于一个可重用的、可保护的、可版本控制的组件,程序集把它的逻辑表示和物理表示分开。具体如何将代码和资源划分到不同的文件中,完全取决于个人。程序集的模块中,还包含与引用的程序集有关的信息。这些信息使程序集能够自描述(self-describing)。换句话说,CLR能够判断出为了执行程序集中代码,程序集的直接依赖对象(immediate dependency)是什么。
托管程序集同时包含元数据和IL。IL是与CPU无关的机器语言,是Microsoft在请教了外面的几个商业及学术性语言/编译器的作者之后,费劲心思开发出来的。IL比大多数CPU机器语言都要高级。IL能访问和操作对象类型,并提供了指令来创建和初始化对象,调用对象上的虚方法以及直接操作数据元素。甚至可以提供抛出和捕捉异常的指令来实现错误处理。可将IL视为一种面向对象的机器语言。
《Effective C#》中提高Unity中C#代码质量的22条准则
借鉴于游戏开发领域大神毛星云。
原则1:尽可能地使用属性,而不是可直接访问的数据成员
属性(property)概述:属性允许将数据成员作为共有接口的一部分暴露出去,同时仍旧提供面向对象环境下所需的封装。属性这个语言元素可以让你像访问数据成员一样使用,但其底层依旧是使用方法实现的。使用属性,可以非常轻松的在get和set代码段中加入检查机制。
正因为属性是用方法实现的,所以它拥有方法所拥有的一切语言特性:
- 属性增加多线程的支持是非常方便的。你可以加强 get 和 set 访问器(accessors)的实现来提供数据访问的同步。
- 属性可以被定义为virtual。
- 可以把属性扩展为abstract。
- 可以使用泛型版本的属性类型。
- 属性也可以定义为接口。
- 在C# 2.0之后,你可以给get和set定义不同的访问权限,来更好的控制类成员的可见性。
- 为了和多维数组保持一致,我们可以创建多维索引器,在不同的维度上使用相同或不同类型。
无论何时,需要在类型的公有或保护接口中暴露数据,都应该使用属性。如果可以也应该使用索引器来暴露序列或字典。
原则2:偏向于使用运行时常量而不是编译时常量
对于常量,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:理解几个等同性判断之间的关系
值类型和引用类型的相同判断不必多说。
当我们创建自己的类型时(无论是类还是struct),应为类型定义“等同性”的含义。
C#提供了4种不同的函数来判断两个对象是否“相等”:
- public static bool ReferenceEquals (object left, object right):判断两个不同变量的对象标识(object identity)是否相等,无论比较的是引用类型还是值类型,该方法判断的依据都是对象标识,而不是对象内容。
- public static bool Equals (object left, object right):用于判断两个变量的运行时类型是否相等。
- **public virtual bool Equals(object right)**:用于重载
- **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
在.NET中,每个对象都有一个散列码,其值由System.Object.GetHashCode()决定。
实现自己的GetHashCode( )时,要遵循上述三条原则:
- 如果两个对象相等(由operation==定义),那么他们必须生成相同的散列码。否则,这样的散列码将无法用来查找容器中的对象。
- 对于任何一个对象A,A.GetHashCode()必须保持不变。
- 对于所有的输入,散列函数应该在所有整数中按随机分别生成散列码。这样散列容器才能得到足够的效率提升。
原则7:理解短小方法的优势
将C#代码翻译成可执行的机器码需要两个步骤:
- C#编译器将生成IL,并放在程序集中。
- 随后,JIT将根据需要逐一为方法(或是一组方法,如果涉及内联)生成机器码。
短小的方法让JIT编译器能够更好地平摊编译的代价。短小的方法也更适合内联。
除了短小之外,简化控制流程也很重要。控制分支越少,JIT编译器也会越容易地找到最适合放在寄存器中的变量。
所以,短小方法的优势,并不仅体现在代码的可读性上,还关系到程序运行时的效率。
原则8:选择变量初始化而不是赋值语句
成员初始化器是保证类型中成员均被初始化的最简单的方法——无论调用的是哪一个构造函数。
初始化器将在所有构造函数执行之前执行。使用这种语法也就保证了你不会再添加的新的构造函数时遗漏掉重要的初始化代码。
综上,若是所有的构造函数都要将某个成员变量初始化成同一个值,那么应该使用初始化器。
原则9:正确地初始化静态成员变量
C#提供了有静态初始化器和静态构造函数来专门用于静态成员变量的初始化。
静态构造函数是一个特殊的函数,将在其他所有方法执行之前以及变量或属性被第一次访问之前执行。可以用这个函数来初始化静态变量,实现单例模式或执行类可用之前必须进行的任何操作。
和实例初始化一样,也可以使用初始化器语法来替代静态的构造函数。若只是需要为某个静态成员分配空间,那么不妨使用初始化器的语法。而若是要更复杂一些的逻辑来初始化静态成员变量,那么可以使用静态构造函数。
使用静态构造函数而不是静态初始化器最常见的理由就是处理异常。在使用静态初始化器时,我们无法自己捕获异常。而在静态构造函数中却可以做到。
原则10:使用构造函数链(减少重复的初始化逻辑)
编写构造函数很多时候是个重复性的劳动,如果你发现多个构造函数包含相同的逻辑,可以将这个逻辑提取到一个通用的构造函数中。这样既可以避免代码重复,也可以利用构造函数初始化器来生成更高效的目标代码。
C#编译器将把构造函数初始化器看做是一种特殊的语法,并移除掉重复的变量初始化器以及重复的基类构造函数调用。这样使得最终的对象可以执行最少的代码来保证初始化的正确性。
构造函数初始化器允许一个构造函数去调用另一个构造函数。而C# 4.0添加了对默认参数的支持,这个功能也可以用来减少构造函数中的重复代码。你可以将某个类的所有构造函数统一成一个,并为所有的可选参数指定默认值。其他的几个构造函数调用某个构造函数,并提供不同的参数即可。
原则11:实现标准的销毁模式
GC可以高效地管理应用程序使用的内存。不过创建和销毁堆上的对象仍旧需要时间。若是在某个方法中创建了太多的引用对象,将会对程序的性能产生严重的影响。
这里有一些规则,可以帮你尽量降低GC的工作量:
- 若某个引用类型(值类型无所谓)的局部变量用于被频繁调用的例程中,那么应该将其提升为成员变量。
- 为常用的类型实例提供静态对象。
- 创建不可变类型的最终值。比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类。
原则12:区分值类型和引用类型
C#中,class对应引用类型,struct对应值类型。
C#不是C++,不能将所有类型定义成值类型并在需要时对其创建引用。C#也不是Java,不像Java中那样所有的东西都是引用类型。你必须在创建时就决定类型的表现行为,这相当重要,因为稍后的更改可能带来很多灾难性的问题。
值类型无法实现多态,因此其最佳用途就是存放数据。引用类型支持多态,因此用来定义应用程序的行为。
一般情况下,我们习惯用class,随意创建的大都是引用类型,若下面几点都肯定,那么应该创建struct值类型:
- 该类型主要职责在于数据存储吗?
- 该类型的公有接口都是由访问其数据成员的属性定义的吗?
- 你确定该类型绝不会有派生类型吗?
- 你确定该类型永远都不需要多态支持吗?
用值类型表示底层存储数据的类型,用引用类型来封装程序的行为。这样,你可以保证类暴露出的数据能以复制的形式安全提供,也能得到基于栈存储和使用内联方式存储带来的内存性能提升,更可以使用标准的面向对象技术来表达应用程序的逻辑。而倘若你对类型未来的用图不确定,那么应该选择引用类型。
原则13:保证0为值类型的有效状态
在创建自定义枚举值时,请确保0是一个有效的选项。若你定义的是标志(flag),那么可以将0定义为没有选中任何状态的标志(比如None)。即作为标记使用的枚举值(即添加了Flags特性)应该总是将None设置为0。
原则14:保证值类型的常量性和原子性
常量性的类型使得我们的代码更加易于维护。不要盲目地为类型中的每一个属性都创建get和set访问器。对于那些目的是存储数据的类型,应该尽可能地保证其常量性和原子性。
原则15:限制类型的可见性
在保证类型可以完成其工作的前提下。你应该尽可能地给类型分配最小的可见性。也就是,仅仅暴露那些需要暴露的。尽量使用较低可见性的类来实现公有接口。可见性越低,能访问你功能的代码越少,以后可能出现的修改也就越少。
原则16:通过定义并实现接口替代继承
理解抽象基类(abstract class)和接口(interface)的区别:
- 接口是一种契约式的设计方式,一个实现某个接口的类型,必须实现接口中约定的方法。抽象基类则为一组相关的类型提供了一个共同的抽象。也就是说抽象基类描述了对象是什么,而接口描述了对象将如何表现其行为。
- 接口不能包含实现,也不能包含任何具体的数据成员。而抽象基类可以为派生类提供一些具体的实现。
- 基类描述并实现了一组相关类型间共用的行为。接口则定义了一组具有原子性的功能,供其他不相关的具体类型来实现。
理解好两者之间的差别,我们便可以创造更富表现力、更能应对变化的设计。使用类层次来定义相关的类型。用接口暴露功能,并让不同的类型实现这些接口。
原则17:理解接口方法和虚方法的区别
实现接口和覆写虚方法之间的差别很大:
- 接口中声明的成员方法默认情况下并非虚方法,所以,派生类不能覆写基类中实现的非虚接口成员。若要覆写的话,将接口方法声明为virtual即可。
- 基类可以为接口中的方法提供默认的实现,随后,派生类也可以声明其实现了该接口,并从基类中继承该实现。
- 实现接口拥有的选择要比创建和覆写虚方法多。我们可以为类层次创建密封(sealed)的实现,虚实现或者抽象的契约。还可以创建密封的实现,并在实现接口的方法中提供虚方法进行调用。
原则18:用委托实现回调
在C#中,回调是用委托来实现的,主要要点如下:
- 委托为我们提供了类型安全的回调定义。虽然大多数常见的委托应用都和事件有关,但这并不是C#委托应用的全部场合。当类之间有通信的需要,并且我们期望一种比接口所提供的更为松散的耦合机制时,委托便是最佳的选择。
- 委托允许我们在运行时配置目标并通知多个客户对象。委托对象中包含一个方法的应用,该方法可以是静态方法,也可以是实例方法。也就是说,使用委托,我们可以和一个或多个在运行时联系起来的客户对象进行通信。
- 由于回调和委托在C#中非常常用,以至于C#特地以lambda表达式的形式为其提供了精简语法。
- 由于一些历史原因,.NET中的委托都是多播委托(multicast delegate)。多播委托调用过程中,每个目标会被依次调用。委托对象本身不会捕捉任何异常。因此,任何目标抛出的异常都会结束委托链的调用。
原则19:用事件模式实现通知
事件提供了一种标准的机制来通知监听者,而C#中的事件其实就是观察者模式的一个语法上的快捷实现。
事件是一种内建的委托,用来为事件处理函数提供类型安全的方法签名。任意数量的客户对象都可以将自己的处理函数注册到事件上,然后处理这些事件,这些客户对象无需在编译器就给出,事件也不必非要有订阅者才能正常工作。
在C#中使用事件可以降低发送者和可能的通知接受者之间的耦合,发送者可以完全独立于接受者进行开发。
原则20:避免返回对内部类对象的引用
若将引用类型通过公有接口暴露给外界,那么对象的使用者即可绕过我们定义的方法和属性来更改对象的内部结构,这会导致常见的错误。
共有四种不同的策略可以防止类型内部的数据结构遭到有意或无意的修改:
- 值类型。当客户代码通过属性来访问值类型成员时,实际返回的是值类型的对象副本。
- 常量类型。如System.String。
- 定义接口。将客户对内部数据成员的访问限制在一部分功能中。
- 包装器(wrapper)。提供一个包装器,仅暴露该包装器,从而限制对其中对象的访问。
原则21:仅用new修饰符处理基类更新
使用new操作符修饰类成员可以重新定义继承自基类的非虚成员。
new修饰符只是用来解决升级基类所造成的基类方法和派生类方法冲突的问题。
new操作符必须小心使用。若随心所欲的滥用,会造成对象调用方法的二义性。
原则22:尽量减少在公有API中使用动态对象
(这条准则在Unity中请忽略,因为Unity版本的mono并没有实现.NET 4.0中的dynamic language runtime)
动态对象有一些“强制性”,即所有打交道的对象都变成动态的。若是某个操作的某个参数是动态的,那么其结果也会是动态的。若是每个方法返回了一个动态对象,那么所有使用过该对象的地方也变成了动态对象。
所以,若你要在程序中使用动态特性,请尽量不要在公有接口中使用,这样可以将动态类型现在在一个单独的对象或类型中。
应该将动态对象限制在最需要的地方,然后立即将动态对象转换为静态类型。当你的代码依赖于其他环境中创建的动态类型时,可以用专门的静态类型封装这些动态对象,并提供静态的公有接口。
Reference
[1] Unity3D高级编程: 主程手记 陆泽西
[2] CLR via C#(第4版)
[3] 内存管理与CG优化 -曾志伟
[4] 【《Effective C#》提炼总结】提高Unity中C#代码质量的22条准则 -毛星云