第1章 CLR的执行模型
托管模块的各个组成部分:PE32或PE32+头,CLR头,元数据,IL(中间语言)代码。
高级语言通常只公开了CLR的所有功能的一个子集。然而,IL汇编语言允许开发人员访问CLR的所有功能。
JITCompiler函数负责将一个方法的IL代码编译成本地CPU指令。由于IL是“即时”(just in time)编译的,所以通常将CLR的这个组件称为JITter或者JIT编译器。
Microsoft定义了一个“公共语言规范”(Common Language Specification, CLS),它定义了所有语言都必须支持的一个最小功能集。
第2章 生成、打包、部署和管理应用程序及类型
响应文件(response file)是一个文本文件,其中包含一组编译器命令行开关。
除了在命令行上显式指定的文件,编译器还会自动查找两个名为CSC.rsp的文件(本地的和全局的) 。
本地和全局响应文件中的某个设置发生冲突,将以本地文件的设置为准。类似地,命令行上显式指定的设置将覆盖本地响应文件中的设置。
为了增加趣味性,在ILDasm中选择“视图”|“统计”,会显示有趣的信息。
AssemblyVersion 这个版本号非常重要,它唯一性地标识了一个程序集。一个程序集将与所引用的程序集的一个特定的版本紧密绑定到一起。
第3章 共享程序集和强命名程序集
显然,只根据文件名来区分程序集是不够的。CLR必须提供对程序集进行唯一性标识的机制。这正是“强命名程序集”的来历。一个强命名的程序集具有4个重要attributes,它们共同对程序集进行了唯一性标识:一个文件名(不计扩展名)、一个版本号、一个语言文化(culture)标识以及一个公钥。
由于公钥是非常大的数字,所以经常使用从公钥派生的一个小的哈希值。这个哈希值称为公钥标记(public key token)。
CLR在做出安全或信任决策时,永远都不会使用公钥标记,因为几个公钥可能在哈希处理之后得到同一个公钥标记。
如果一个程序集要由多个应用程序访问,必须把它放到一个已知的目录中,而且CLR在检测到对该程序集的一个引用时,必须知道自动检查该目录。这个已知的位置称为全局程序集缓存(Global Assembly Cache, GAC).
将一个强命名的程序集安装到GAC时,系统会执行一次检查,核实含有清单的那个文件没有被篡改。这个检查只在安装时执行一次。相反,从非GAC的一个目录加载强命名程序集时,CLR会校验程序集的清单文件,核实文件的内容未被篡改,造成该文件每次加载都会带来额外的性能开销。
第4章 类型基础
is操作符、as操作符永远不会抛出异常。
is操作符使用如下时:
if (o is Employee)
{
Employee e = (Employee) o;
}
CLR实际会检查两次对象的类型,无疑对性能造成一定影响。C#专门提供了as操作符,目的就是简化这种代码的写法,同时提升其性能。
Employee e = o as Employee;
if (e != null) { }
如果o不兼容于Employee类型,as操作符会返回null,这只造成CLR校验一次对象的类型。if语句只是检查e是否为null,这个检查的速度比校验对象的类型快得多。
C#编译器提供了一个名为外部别名(extern alias)的功能,它解决了这个虽然罕见但仍有可能发生的问题:通过编程来区分不同的程序集,而非仅能区分不同的命名空间。外部别名还允许从同一个程序集的两个(或更多)不同的版本中访问一个类型。欲知外部别名的详情,请参见C#语言规范。
除了全局性地打开或关闭溢出检查,程序员还可在代码的特定区域控制溢出检查。C#通过提供checked和unchecked操作符来实现这种灵活性。
第5章 基元类型、引用类型和值类型
如果你定义的一个类型重写了Equals方法,那么还应重写GetHashCode方法,确保相等性算法和对象哈希码算法是一致的。这是因为在System.Collections.Hashtable类型、System.Collections.Generic.Dictionary类型以及其他一些集合的实现中,要求两个对象为了相等,必须具有相同的哈希码。
不要混淆dynamic和var。用var声明一个局部变量只是一种简化语法,它要求编译器根据一个表达式推断具体的数据类型。var关键字只能用于声明方法内部的局部变量,而dynamic关键字可用于局部变量、字段和参数。表达式不能转型为var,但能转型为dynamic。必须显式初始化用var声明的变量,但无需初始化用dynamic声明的变量。
代码使用dynamic表达式/变量来调用一个成员时,编译器会生成特殊的IL代码来描述所需的操作。这种特殊的代码称为payload(有效载荷)。
虽然能用动态功能简化语法,但也要看是否值得。毕竟,加载所有这些程序集以及额外的内存消耗,会对性能产生额外的影响。
第6章 类型和成员基础
构建程序集时,可以使用在System.Runtime.CompilerServices命名空间中定义的一个名为InternalsVisibleTo的attribute来标明它认为是“友元”的其他程序集。
设计一个类型时,应尽量减少所定义的虚方法的数量。 首先,调用虚方法的速度比调用非虚方法慢。其次,JIT编译器不能内嵌(inline)虚方法,这进一步影响了性能。第三,虚方法使组件的版本控制变得更脆弱。第四,定义一个基类型时,经常需要提供一组重载的简便方法(convenience method)。如果希望这些方法是多态的,最好的办法就是使最复杂的方法成为虚方法,使所有重载的简便方法成为非虚方法。
第8章 方法
不要在构造器中调用会影响所构造对象的任何虚方法。原因是假如这个虚方法在当前要实例化的类型的派生类型中进行了重写,就会调用重写的实现。但在继承层次结构中,字段尚未完全初始化。所以,调用虚方法将导致无法预测的行为。
关于类型构造器的性能:对于进行了内联初始化的静态字段(会在类的类型定义表中生成一个添加了BeforeFieldInit元数据标记的记录项),只要在访问之前初始化就可以了,具体什么时间无所谓。而显式类型构造器可能包含具有副作用的代码,所以需要在精确拿捏运行的时间。
扩展方法有潜在的版本控制问题。如果Microsoft未来为他们的StringBuilder类添加了一个IndexOf实例方法,而且和我的代码试图调用的原型一样,那么在重新编译我的代码时,编译器会绑定到Microsoft的IndexOf实例方法。这样一来,我的程序就会有不同的行为。这个版本控制问题是使用扩展方法时必须慎重的另一个原因。
关于分部方法,有一些额外的规则和原则需要谨记:
- 它们只能在分部类或结构中声明。
- 分部方法的返回类型始终是void,任何参数都不能用out修饰符来标记。分部方法可以有ref参数,可以是泛型方法,可以是实例或静态方法,而且可标记为unsafe。
- 如果两者都应用了定制attribute,那么attribute会合并到一起。
- 分部方法总是被视为private方法。
第9章 参数
注意,如果方法是从模块的外部调用的,更改参数的默认值具有潜在的危险性。call site在它的调用中嵌入默认值。如果以后更改了参数的默认值,但没有重新编译call site所在的代码,它在调用你的方法时就会传递旧的默认值。可考虑将默认值0/null作为哨兵值使用:
//不要这样做:
private static String MakePath(String filename = "Untitled") {
return String.Format(@"C:\{0}.txt", filename);
}
//而是要这样做:
private static String MakePath(String filename = null) {
return String.Format(@"C:\{0}.txt", filename ?? "Untitled");
}
这里使用了空接合操作符(??)
对于以传引用的方式传给方法的变量,它的类型必须与方法签名中声明的类型相同,原因是保障类型安全。
参数和返回类型的指导原则:声明方法的参数类型时,应尽量指定最弱的类型,最好是接口而不是基类(偶理解为基本类型)。例如,如果要写一个方法来处理一组数据项,最好是用接口(比如IEnumerable<T>)来声明方法的参数,而不要用强数据类型(比如List<T>)或者更强的接口类型(比如ICollection<T>或IList<T>)。
第10章 属性
匿名类型
利用C#的匿名类型功能,可以使用非常简洁的语法来声明一个不可变(immutable)的元组类型(tuple)。元组类型是含有一组属性的类型,这些属性通常以某种方式相互关联。
var o1 = new { Name = "Jeff", Year = 1964 };
这行代码创建了一个匿名类型,我没有在new关键字后指定类型名称,所以编译器会为我自动创建一个类型名称,而且不会告诉我这个名称具体是什么。
编译器在定义匿名类型时是非常“善解人意”的。如果它看到你在源代码中定义了多个匿名类型,而且这些类型具有相同的结构,那么它只会创建一个匿名类型定义,但创建该类型的多个实例。 所谓“相同的结构”,是指在这些匿名类型中,每个属性都有相同的类型和名称,而且这些属性的指定顺序相同。这样可以检查两个对象是否包含相等的值,并将对一个对象的引用赋给正在指向另一个对象的变量。还可以创建一个隐式类型的数组:
var people = new[] {
o1, //o1参见上一节
new { Name = "Kristin", Year = 1970 },
new { Name = "Aidan", Year = 2003 },
new { Name = "Grant", Year = 2008 }
};
匿名类型的实例不能泄露到一个方法的外部。如果想传递一个元组,应考虑System.Tuple类型。
和匿名类型相似,一旦创建好一个Tuple,它就不可变了(所有属性都只读)。
当然,非常重要的一点在于,Tuple的生产者(写它的人)和消费者(用它的人)必须对Item#属性返回的内容有一个清楚的理解。对于匿名类型,属性的实际名称是根据定义匿名类型的源代码来确定的。对于Tuple类型,属性一律被Microsoft称为Item#,我们无法对此进行人任何改变。
第11章 事件
如果定义一个事件成员,意味着类型要提供以下能力。
- 方法可登记它对该事件的关注。
- 方法可注销它对该事件的关注。
- 该事件发生时,登记了的方法会收到通知。
关于设计要公开事件的类型这一节,写得很详细,其中有线程安全的考虑,书P228-233建议反复阅读。
第13章 接口
接口继承的一个重要特点是,凡是能使用具名接口类型的实例的地方,都能使用实现了接口的一个类型的实例。
在运行时,可将变量从一种接口类型转型为另一种,只要该对象的类型实现了这两个接口。
String s = "Jeffrey";
IComparable comparable = s;
IEnumerable enumerable = (IEnumerable)comparable;
在C#中定义一个显示接口方法时,不允许指定可访问性(比如public或private)。但是,编译器生成方法的元数据时,其可访问性会被自动设为private,防止其他代码在使用类的实例时直接调用接口方法。要调用接口方法,只能通过接口类型的一个变量来进行。
第14章 字符、字符串和文本处理
System.Security.SecureString类可用它保护敏感的字符串数据,比如密码和信用卡资料。
构造一个SecureString对象时,它会在内部分配一个非托管内存块,其中包含一个字符数组。之所以要使用非托管内存,是为了避开垃圾回收器的“罗网”。
使用一个SecureString,编译时要为C#编译器指定 /unsafe 开关选项。
第16章 数组
如果只是需要把数组中的某些元素复制到另一个数组,可以选择System.Buffer的BlockCopy方法,它的执行速度比Array的Copy方法快。不过,Buffer的BlockCopy方法只支持基元类型,它不提供像Array的Copy方法那样的转型能力。
如果需要可靠地将一个数组中的元素复制到另一个数组,应该使用System.Array的ConstrainedCopy方法。该方法保证在不破坏目标数组中的数据的前提下完成复制,或者抛出一个异常。
注意,Array.Copy方法执行的是浅拷贝。换言之,如果数组元素是引用类型,新数组将引用现有的对象。
如果性能是首要目标,请避免在堆上分配托管的数组对象。相反,应在线程栈上分配数组,这是通过C#的stackalloc语句来完成的。stackalloc语句只能创建一维0基、由值类型元素构成的数组,而且值类型绝对不能包含任何引用类型的字段。
unsafe {
const Int32 width = 20;
Char* pc = stackalloc Char[width]; //在栈上分配数组
...
}
通常,因为数组是引用类型,所以在一个结构中定义的数组字段实际只是指向数组的一个指针或引用;数组本身在结构的内存的外部。不过,也可像下面代码中的CharArray结构那样,直接将数组嵌入结构中。这样就是在栈上分配数组。
internal unsafe struct CharArray {
//这个数组以内联的方式嵌入结构 内联(内嵌)数组
public fixed Char Characters[20];
}
第18章 定制attribute
应用一个attribute时,C#允许用一个前缀明确指定attribute要应用于的目标元素。但在一些情况下,必须指定前缀向编译器清楚表明我们的意图。
[return: SomeAttr]
public int SomeMethod()
{ return SomeValue; }
为了告诉编译器自定义attribute的合法应用范围,需要向attribute类应用System.AttributeUsageAttribute类的一个实例。
AttributeUsageAttribute的其中一个属性是Inherited,它指出attribute在应用于基类时,是否同时应用于派生类和重写的方法。注意,.NET Framework只认为类、方法、属性、事件、字段、方法返回值和参数等目标元素是可继承的。
调用Attribute的GetCustomAttribute或者GetCustomAttributes方法时,这些方法会在内部调用attribute类的构造器,而且可能调用属性的set访问器方法。这样一来,就相当于允许未知的代码在AppDomain中运行,所以是一个潜在的安全隐患。
使用System.Reflection.CustomAttributeData类,可以在查找attribute的同时禁止执行attribute类中的代码。
第20章 异常和状态管理
如果仅仅使用throw关键字本身来重新抛出一个异常对象,CLR就不会重置堆栈的起点。
在你的代码中,如果确定状态已损坏到无法修复的程度,就应销毁所有损坏的状态,防止它造成更多的伤害。然后,重新启动应用程序,将状态初始化到一个良好的状态,并寄希望于状态不再损坏。这时,可以调用AppDomain的Unload方法来卸载整个AppDomain。
如果觉得状态过于糟糕,以至于整个进程都应该终止,那么应该调用Environment的静态FailFast方法。
为了在异常中添加额外的数据或上下文,可在异常对象的Data属性(一个键/值对的集合)中添加数据,然后重新抛出(only throw) 。
关于约束执行区域(CER),RuntimeHelpers.PrepareConstrainedRegions是一个非常特别的方法。JIT编译器如果发现在一个try块前调用了这个方法,就会提前编译与try关联的catch和finally块中的代码。JIT编译器会加载任何程序集,创建任何类型对象,调用任何静态构造器,并对任何方法进行JIT编译。如果其中任何操作造成异常,这个异常会在线程进入try块之前发生。
第21章 自动内存管理(垃圾回收)
使用System.Runtime.ConstrainedExecution.CriticalFinalizerObject 类型确保终结。CLR以一种非常特殊的方式对待该类及其派生类:1.对Finalize方法进行提前编译;2.在调用了非CriticalFinalizerObject 派生类型的Finalize方法之后,才调用CriticalFinalizerObject 派生类型的Finalize方法。
System.Runtime.InteropServices中的CriticalHandle类,除了不提供引用计数器功能,其他方面与SafeHandle类相同。CriticalHandle类及其派生类通过牺牲安全性来换取更好的性能。
所有定义了Finalize方法的类型都应同时实现本节描述的Dispose模式,使类型的用户对资源的生存期有更多的控制。但是,类型也可实现Dispose模式,但不定义Finalize方法。
如果类定义了一个字段,而且该字段的类型实现了Dispose模式,那么类本身也应实现Dispose模式。Dispose方法应dispose字段引用的对象。
建议只有在以下两种情况下才调用Dispose或Close:确定必须清理资源(例如,为了删除一个已打开的文件);或者确定可以安全地调用Dispose或Close,并希望将对象从终结列表中删除,禁止对象提升(到另一代),从而提升性能。
如果一个类要包装数量有限的本地资源,就应该使用System.Runtime.InteropServices的HandleCollector类的一个实例来提示垃圾回收器它实际需要消耗资源的多少个实例。该类的对象会在内部监视这个计数,当计数变大时,就强制执行垃圾回收。
在内部,GC.AddMemoryPressure和HandleCollector.Add方法会调用GC.Collect方法,强迫垃圾回收在第0代达到预算前启动。
System.Runtime命名空间提供了一个MemoryFailPoint类,它允许在开始一个内存消耗巨大的算法之前检查是否有充裕的内存。但要注意,请求的内存尚未物理性地分配。这意味着算法只是更有可能获得所需的内存并成功运行。这个类的目的只是帮助你写更健壮的应用程序。
一个很出色的工具可供监视应用程序的对象分配:CLR Profiler。
第22章 CLR寄宿和AppDomain
如果线程正在执行类型的类构造器、catch块或finally块中的代码、CER中的代码或者非托管代码,线程就不在安全点。
第23章 程序集加载和反射
反射的4种方式:
1.利用Type的InvokeMember来绑定并调用一个成员。
2.利用FieldInfo、MethodInfo、PropertyInfo等类可以一次绑定,多次调用。这个技术可以产生性能更好的代码。
3.绑定到一个对象或成员,然后创建一个委托来引用该对象或成员。这个技术能产生比上一个技术还要快的代码。
4.使用C#的dynamic基元类型来简化访问成员时使用的语法。
使用绑定句柄(RuntimeTypeHandle, RuntimeFieldHandle, RuntimeMethodHandle)来减少进程的内存耗用。
第24章 运行时序列化
可利用序列化创建对象的一个深拷贝,或者说一个克隆体。
序列化一个对象时,类型的全名和类型的定义程序集的名称会被写入流。默认情况下,BinaryFormatter会输出程序集的完整标识,其中包括程序集的文件名(无扩展名)、版本号、语言文化以及公钥信息。反序列化一个对象时,格式化器首先获取程序集标识信息,并通过Assembly.Load确保程序集加载到正在执行的AppDomain中。
控制序列化和反序列化过程的最佳方式就是使用OnSerializing, OnSerialized, OnDeserializing, OnDeserialized, NonSerialized和OptionalField等attribute。
可利用ISerializationSurrogate接口来接管一个特定的类型的序列化和反序列化工作(非常罕见)。
可利用System.Runtime.Serialization.SerializationBinder类,可以非常简单地将一个对象反序列化成一个不同的类型。为此,首先要定义自己的类型,让它从抽象类SerializaitonBinder派生。
第26章 计算限制的异步操作
如果回调方法的执行时间很长,计时器可能再次触发。这可能造成多个线程池线程同时执行你的回调方法。为解决这个问题,我的建议是:构造Timer时,为period参数指定Timeout.Infinite。这样,计时器就只触发一次。然后,在你的回调方法中,调用Change方法来指定一个新的dueTime,并再次为period参数指定Timeout.Infinite。
第28章 基元线程同步构造
当线程通过共享内存相互通信时,调用VolatileWrite来写入最后一个值,调用VolatileRead来读取第一个值。
自旋锁只应该用于保护那些会执行得非常快的代码区域。
有一个泛型方法Morph封装了Interlocked Anything 模式。它主要解决在多线程的情况下,原子方法的实现(参见书P720-721)。
|