运行时的类型
类型本身并不是万能的.类型真正有意思的地方在于,程序员使用类型的实例,并让它们相互作用.类型的实例(instance)既可以是对象,也可以是值,这取决于类型如何定义的.基本数据类型(primitive type)的实例是值,而绝大多数用户定义类型的实例是对象,尽管也存在能够产生值的类型.
每一个对象和每一个值都是一个确切类型的实例.实例和类型的从属关系通常是隐式的.例如,声明一个System.Int32类型的变量或字段,其结果是分配一块符合该类型的内存,它的存在与否取决于操纵它的执行代码.CLR(和基于CLR的编译器)只允许对那些属于System.Int32类型定义的值进行操作.实施这种值和System.Int32类型的从属关系并不需要额外的开销,因为编译器会在编译时去实施,并且CLR验证器会确保在代码加载后维护这种从属关系
每个对象也属于一个类型.然而,因为对象总是通过对象引用来访问的,所以,被引用对象的实际类型可能不匹配该引用的声明类型.当对象引用的是一个abstract类型时,就是这样的情形.显然,我们需要某种机制来明确对象与其类型的从属关系,以处理这种情形.接下来让我们分析CLR的对象头(object header)
CLR的每个对象都以一个固定大小的对象头开始,如图4.1所示.对象头不能通过程序直接访问,但它确实存在.对象头的确切格式没有正式的文档说明.因而,下面的描述是在IA-32体系结构上对CLR1.0版本进行实验分析推断而来的.CLI的其他几种实现在某种程度上也像是由这个格式衍生而来的.
对象头有两个字段:它的第一个字段是同步块(sync block)索引.你可以使用这个字段推迟该对象与附加资源的关联(例如,锁,COM对象);对象头的第二个字段是一个句柄(handle),它指向一个不透明的数据结构,用于表示该对象的类型.尽管这个句柄的位置也没有正式文档说明,但通过System.RuntimeTypeHandle类型可以对它显示地支持.有趣的是,在CLR的当前实现中,对象引用总是指向对象头的类型句柄字段.第一个用户自定义类型的字段总是sizeof(void*)字节,不管对象引用指向哪里.
给定类型的每个实例在对象头中都会有相同的类型句柄值.类型句柄(type handle)简而言之是指向一个非透明的,无正式文档说明的数据结构的指针.这个数据结构包含了类型的完整描述,以及一个指向类型元数据的内存表示的指针.数据结构的内容对于各种关键性能的操作(例如,虚方法的分发,对象分配)在某种意义上做优化处理,使之尽可能地块.有关该数据结构的第一个应用就使动态类型强制转换(dynamic type coercion)
当从一个对象引用的类型转换到另一个对象引用的类型时,必须考虑两个类型之间的关系.如果初始引用的类型被认定与新引用的类型兼容,那么,CLR要做的转换只是一个简单的IA-32 mov指令.这通常出现于这样的赋值情形中:当从一个派生类型的引用到一个直接或间接基类型的引用,或者到一个已知兼容的接口的引用(这就是向上类型转换,up-casting).另一方面,如果初始引用的类型与新引用的类型之间的兼容性不是已知的,那么,CLR必须执行一个运行时测试,以确定对象的类型与所需要的类型是否是兼容的.当赋值是从一个基类型或接口引用到一个深度派生的类型的引用时(这就是向下类型转换,down-casting),或者到一个互不相关的类型的引用时(平行类型转换,side-casting),这种测试总是必需的
为了支持向下类型转换和平行类型转换,CIL(公共中间语言,Common Intermediate Language)定义了两个操作码:isinst和castclass.这两个操作码分别带有两个参数:一个对象引用和一个用于表示所期望的引用类型的的元数据标记.两个操作码都会生成代码,用于检查对象的类型句柄,以决定对象的类型是否与请求的类型相兼容.两个操作码的不同之处在于它们报告测试结果的方式.如果测试成功,两个操作码只是简单地将对象引用保留在用于测试计算的堆栈上:如果测试失败,则两个操作码的表现各不相同.如果请求的类型不被支持,castclass操作码会抛出一个System.InvalidCastException类型的异常.相反,如果测试失败,isinst操作码只是简单地把一个空引用放到用于测试计算的堆栈上.由于异常的开销相对较高,所以只有当转换总是期望成功时,才使用产生castclass操作码的构件.相似地,如果两种结果都是预期的,则应该使用产生isinst操作码的构件.有趣的是,JIT编译器将CIL的isinst和castclass指令分别翻译为对内部的JIT_IsInstanceof函数和JIT_ChkCast函数的调用,这两个函数都没有文档说明.
isinst和castclass都是在利用被RunTimeTypeHandle引用的数据结构,尽管这个数据结构(在内部被称为CORINFO_CLASS_STRUCT)没有正式的文档说明,但它包含了许多关键的信息.如图4.2所示,每个类型都有一张接口表(interface table).它包含了类型所兼容的每个接口的入口项.在类型的接口表中的每个入口项包含了用于支持该接口的类型句柄.对接口类型的转换将通过这张表进行匹配.为了支持向直接或间接基类型的转换,这个数据结构还包含了一个指针,指向类型元数据的内存表示,而它则包括一个指向该类型的基类型元数据的指针.对于直接或间接基类型的强制转换,将会使用该数据结构的这个部分进行匹配.对于这两种情况,类型兼容性测试只是通过接口表进行简单的线性检查(linear search),接着通过元数据结构链表进行线性遍历(linear traversal).这意味着,对于支持大量接口或者多级基类型(或者两者都有)的类型,类型兼容性的运行时测试将比只支持少量接口或者扁平的类型层次结构(或者两者都有)的简单类型要慢的多
每种编程语言都以自己的方式公开isinst和castclass操作码.在C#中,isinst操作码通过as和is关键字公开.as关键字是一个二元操作符,它接收一个变量和一个类型名.C#接着发射(emit)适合的isinst指令,并且将返回的引用作为操作符的结果
C#的is操作符的工作方式与as相似,只是最后结果的引用变成了一个布尔值,它的值取决于引用是否为空.下面是使用is操作符的同样代码:
这段代码在语义上等价于前面的实例.不同的是在第二个示例中,IBillee引用是不可用的.
C#通过其强制类型转换操作符公开castclass操作码.C#强制类型转换使用和C同样的语法.考虑下面的示例
注意,在这个示例中,有一个异常处理程序将被用于处理潜在的失败.再次考虑到异常相对较高的开销,如果强制转换不能保证总是成功的,那么,采用isinst操作码的构件要更合适一些
尽管对于基于CLR工作的程序员来说,类型句柄和其引用的数据结构根本是不透明的,但存储在这个数据结构(以及该类型的下级元数据)上的大多数信息可以由程序员通过System.Type类型访问到.System.Type基于底层优化后的类型信息,向程序员提供了易于使用的外观(facade).你可以通过调用System.Type的静态方法GetTypeFromHandle,从类型句柄获取一个System.Type对象,也可以通过System.Type的TypeHandle属性还原类型句柄
每种编程语言都提供了一种自己的机制,用于将符号化类型名转换为System.Type对象.在C#中可以用typeof操作符.typeof操作符接收一个符号化的类型名,得到的结果是一个该类型的System.Type对象的引用.下面演示了typeof操作符的使用:
对于一个给定的类型,CLR保证在内存中恰好只有一个System.Type对象存在.这意味着在这个示例中,type和t2被保证引用的是同一个System.Type对象
上述例子假定所需要的类型名在编译阶段时是有效的.此外,所请求类型的程序集会变成该模块和程序集的静态依赖项.为了不用静态依赖项便支持类型的动态加载,首先需要使用Assembly.Load或Assembly.LoadFrom动态加载该类型的程序集.在该程序集被加载后,你就可以使用Assembly对象的GetType方法提取想要得到的类型.下面的代码与前面的示例在语义上是等价的:
这个版本的UseType方法与前面示例的不同之处在于:该版本显式地加载了包含该类型的程序集,而不是假定程序集依赖项会被自动解析。
前面的两个示例演示了如何获取一个基于类型名的System.Type对象.你还可以获取内存中的任何对象或值的System.Type对象.为此,你可以调用System.Object.GetType方法.前面我们已经谈到System.Object是通用类型,并且所有的类型都与System.Object兼容.GetType就是System.Object的方法之一.当GetType在一个值上被调用时,它只是简单地返回隐式属于该值的类型对象;当GetType方法在一个对象引用上被调用时,它将使用存储在对象头上的System.RuntimeTypeHandle,并且调用GetTypeFromHandle方法
在对象或值上调用GetType方法,你可以发现对象或值的类型在运行时的情形.GetType方法最简单的应用是检查两个对象引用是否指向相同类型的实例.
只有当o1和o2引用相同类型的实例时,该测试才会返回真.System.Type还能够通过IsSubclassOf方法和IsAssignableFrom方法支持类型兼容性测试.下面的代码测试了一个对象是否是另一个对象类型的子类的实例.
注意,在这个示例中,只有当其中一个对象是另一个对象的类型的直接或者间接基类型的实例时,测试才会返回真.也就是说,如果t1和t2引用相同的类型,System.Type.IsSubclassOf方法返回假.此外,如果t1和t2中引用了接口类型时,System.Type.IsSubclassOf方法将返回假.当然,这在该示例中是不可能发生的,因为System.Object.GetType方法保证决不会返回接口类型的引用.
由于System.Type.IsSubClassOf方法用处不大,因此,该方法有一个更有用一些的变体System.Type.IsAssignableFrom方法,它专门用于测试类型之间的兼容性.如果两个类型是相同的,其结果就为真.如果指定的类型派生于当前类型,那么结果为真;如果当前类型是一个接口,而指定的类型与当前类型兼容,那么结果也为真.考虑下面的示例:
在这个示例中,IsCompatible方法的工作方式类似于先前的IsRelatedType方法.区别在于:如果o1和o2引用相同类型的实例,现在的测试结果将会是真.我们还可能需要列举一个给定类型的基类型或者接口(或者两者都有).为了列举一个类型的接口,你可以调用System.Type.GetInterfaces方法,它返回一个Type对象的数组,每个被支持的接口对应一个Type对象.为了枚举一个类型的基类型,你可以递归地查看System.Type.BaseType属性.下面的代码将打印出与对象兼容的各个类型的列表:
这个例子使用AssemblyQualifiedName属性来获取类型的完全限定名.如果你想要一个更为友好的版本,那么可以使用FullName属性来获取命名空间的限定名,或者使用Name属性返回没有命名空间前缀的类型名称.这个例子还说明了CLR在基类型上和接口处理上的差别
用元数据编程
反射使得程序能够触及类型定义的所有方面,不管是在开发时还是在运行时。
CLR提供了丰富的实用部件用于轻松地生成代码.System.Reflection.Emit库允许基于CLR的程序发射类型,模块和程序集.IMetaDataEmit接口向C++/COM程序提供了相同的功能;最后,System.CodeDom提供了用于在内存中构造更高级的C#或VB.NET程序的功能,并在执行前把它们编译到模块和程序集.
图4.3显示了反射对象模型.注意,这个对象模型反映了下列事实,即类型属于模块,而模块又属于程序集.此外这个模型还反映了类型包含诸如字段和方法成员的事实
如图4.4所示,MemberInfo类型相当于大多数特定反射类型的通用基类型
MemberInfo类型有四个重要属性:Name属性把该成员名称作为一个字符串返回:MemberType属性返回一个System.Reflection.MemberTypes值,表明该成员是否是一个字段,方法或者另外一种成员;ReflectedType属性返回MemberInfo对象从属的System.Type对象;对于继承的情形,ReflectedType属性返回的类型可能是(也可能不是)与实际声明该成员的类型相同.要获取声明该成员的类型,必需使用DeclaringType属性
特殊的方法
丰富的元数据主要是为了更精确地保留和传达程序员的意图.例如,程序员经常会针对一个命名的值定义一对方法,典型的情形就是用一个方法get(获取)这个值,另一个方法set(设定)这个值.一个CLR类型可以包含附加的元数据,来表明该类型中哪些方法是以这种方式使用的.这种附加元数据被正式命名为属性(property)
与属性的思路一样,对于用作注册或取消事件处理程序(event handler)的指定方法,CLR提供了显式的支持.CLR事件(CLR event)是类型的命名成员,它引用同一类型中的其他方法.在被引用的这些方法中,有一个是用于注册事件处理程序的.另一个方法则用于取消该注册.一个给定的事件可以有多重事件处理程序,这些处理程序有着截然不同的名字.就像属性一样,事件属于一个类型.事件的类型必需派生于System.Delegate.
类型的事件可以通过System.Type.GetEvents和System.Type.GetEvent方法访问,这两个方法都返回System.Reflection.EventInfo来描述事件.EventInfo对象有一些属性用来表明事件的名称和类型。EventInfo最令人感兴趣的成员是GetAddMethod方法和GetRemovemethod方法.如图4.6所示,每个方法都会返回一个MethodInfo,依次描述事件的注册方法和取消方法.它们能够接收一个布尔值,用于控制是否返回非公有方法
不管是哪一种形式,C#编译器都会发射两个具有如下签名的方法定义:
元数据和可扩展性
你可以相当自由地使用元数据特性,而不必提供正式的定义.CLR元数据中的大多数构件都有一个32位特性(atrribute)字段,用于调整该特性所关联的类型,字段和方法的定义.
到目前为止讨论的数据特性有initonly(字段)(在C#中,通过关键字readonly可以声明一个initonly字段),beforefieldinit(类型)(C#编译器会在所有缺乏显式类型初始化器方法的类型上,设置一个beforefieldinit特性.而带有显式的类型初始化器方法的类型将不会被设置这个元数据特性),hidebysig(方法)(C#定义的类型总是使用按签名隐藏).为了使这些固化的特性可见,你既可以使用反射,也可以采用非托管的元数据接口IMetaDataImport
有时候编程语言的设计者会选择公开一个元数据特性,将其作为修饰符关键字(例如,C#的readonly字段修饰符).然而,为了避免关键字数量的膨胀,往往采用另一种机制,那就是自定义特性(custom atrribute).
自定义特性允许语言设计者支持任意的元数据特性, 而不用引入新的关键字到编程语言中.当自定义特性被用于支持CLI的预定义特性时,它们被称为伪定制特性(pseudo-custom attribute),原因就是当编译器发射CLR元数据时,这个特性将被转换成一个标准的固定特性.例如,CLI在类型的元数据中预定义了一个标志,以标明类型的实例是否支持对象的序列化.尽管这个特性在元数据中只是一个简单的二进制位,但它被System.SerializableAttribute伪定制特性控制.