值类型
值类型是CLR模型中最接近C++结构的。这些类型是一些带有平凡结构(例如,一个4字节整数)或复杂结构的值。当你声明一个类的类型的变量时,你不用自动创建一个类的实例。你只是创建了一个指向类的引用,初始化指向空。但是当你声明一个值类型的变量,这个值类型的实例立即被分配,通过变量声明本身,因为值类型是一个主要的数据结构。同样的,一个值类型必须有实例字段或大小的定义。一个0大小的值类型(没有实例字段或指定全部的大小)表示了无效的元数据;然而,正如大多数其它类型,加载器比正式的元数据验证规则更加宽容:当它遇到一个0大小的值类型,加载器默认为它分配1个字节的大小。
值类型是值传递的类型,而不是靠引用传递的引用类型。这意味着,在a和b都是值类型的时候,代码a=b;,被解释为复制b的内容到a;在a和b都是引用类型的时候,这行代码被解释为把指向某个类的实例的引用从b复制到a。因此最终我们以两个同样的实例作为结束:在a和b都是值类型的情况;并以两个同样的指向同一个实例的引用作为结束,在a和b都是引用类型的情况。
虽然值类型的实例创建于一个具有这个值类型的变量被声明的时候,默认的实例构造器方法(它应该用来定义正被讨论的值类型)在此时并没有被调用。(参见第10章获取关于实例构造器方法的信息。)声明一个变量创建了一个值类型的“空”实例,而且如果这个实例有一个默认的实例构造器,它就应该被显示调用。
请不要问我为什么在分配类型的实例时,运行时不自动执行值类型的实例构造器(如果有效的话)?——这个问题和“为什么运行时忽略为字段和参数指定的默认值?”有相同的修辞效果(详细内容参见第9章和10章。)正确的答案是“因为运行时是按照这种方式构建的”。
值的装箱和拆箱
作为一个数据结构,值类型有时必须被表示为一个对象,用来满足某些一般API的需求,这将期望对象引用作为输入型参数。CLR提供了方法来生成值类型的一个类的表示以及从它的类表示中提取一个值类型。这些操作,相应地称为装箱和拆箱,为每个值类型定义的。
回忆本章的开始,类型可以被分类为值类型或引用类型。简单的说,装箱将一个值类型转换为引用类型(一个对象引用),而拆箱并不仅仅是相反的。你可以装箱任何的值类型并获取一个对象引用,但这并不意味着,然而,你可以拆箱任何对象并获取一个值类型:在.NET系统中,每个值类型都有它的引用类型“hat”,但是反之并不亦然。为什么是这样的,当从任意引用类型提取出数据部分是明显可能的,那确实是修辞问题中的一个。
当我们声明一个值类型变量时,我们就创建了一个数据结构。当我们包装这个变量,一个对象(一个类的引用)就会被创建
值类型的实例成员
值类型,像其它类型一样,可以有静态成员和实例成员,包括方法和字段。为了访问一个类的实例成员,你需要提供实例指针(在C++中被认为是this)。在值类型的情况,你可以轻易使用一个托管的引用作为一个实例指针。
让我们猜想一下,例如,你有一个4字节的整数类型变量。(除了或许更少字节的整数类型,还有什么能够比这更平凡的么?)这个值类型被定义为.NET Framework类库中的[mscorlib]System.Int32。取代以装箱这个变量并获取一个引用——指向把System.Int32当作类的实例,你可以完全获取到指向这个变量的引用并调用这个值类型的实例方法,比方说,ToString(),这会返回一个字符串以表示正在讨论的整数:
值类型有虚方法么?是的,它们可以。然而,为了调用值类型的虚方法,你必须首先装箱这个值类型。我必须澄清,虽然,你需要装箱这个值类型只有在你调用它的虚方法作为一个虚方法的时候,通过虚拟表分配,使用callvirt指令(方法和方法调用指令在第10、12和13章讨论)。如果你调用了一个值类型的虚方法就像一个简单的实例方法那样,使用call指令,不需要对值类型装箱。这是我为什么在前面的代码段中调用ToString()之前不需要对J进行装箱,尽管ToString()是一个虚方法。
值类型的派生
所有的值类型都派生于[mscorlib]System.ValueType类。更简单的说,每个从[mscorlib]System.ValueType派生的类型都是一个值类型,附带有一个重要的例外:[mscorlib]System.Enum类,是所有枚举的父类(在下一节讨论)。
和C++不同——从一个结构派生出另一个结构是很平常的,CLR对象模型不允许任何值类型的派生。所有的值类型必须是密闭的。(而且你可能认为我太懒了以至于在没有图7-1中画出来自值类型的进一步派生分支!)至于为什么所有的值类型必须是密闭的,恐怕这是那些带有修辞色彩的问题中的另外一个。
枚举
枚举(又称为枚举类型或enums)是一个由值类型组成的特殊的子集。所有的枚举都派生于[mscorlib]System.Enum类,是派生于[mscorlib]System.ValueType的唯一的引用类型。枚举可能是所有具有某些结构的类型中最简单的,而且关于它们的规则是最严格的。
和其它值类型的装箱形式不同,枚举不显示一个“真实类”的任何特性。枚举只能有字段作为成员——没有方法、属性或事件。枚举不能实现接口;由于枚举不具有方法,所以实现接口的问题就没有实际意义了。
这里是一个简单枚举的实例:
枚举即使带有字段也是没有活动余地的:一个枚举必须正好带有一个实例字段并至少一个静态字段。一个枚举的实例字段表示了这个枚举的当前实例的值并且必须是整数、布尔值或字符类型的一种。实例字段的类型是这个枚举的基础类型。枚举本身作为一个值类型,在除装箱之外的所有操作中,可以与它的基础类型完全互换。如果一个操作,除了装箱,希望一个布尔类型作为它的参数,可以替代的使用一个基于布尔枚举类型的变量,反之亦然。然而,一个装箱操作,总是产生一个被装箱的项而不是一个被装箱的基础类型。
静态字段代表了枚举本身的值并且具有这个枚举的类型。作为枚举的值,这些字段必须不仅是静态的(由类型的所有实例共享)而且是文本——它们代表了定义在元数据中的常理。文本字段不是真正的字段因为在枚举被加载并布局的时候它们并不占据由加载器分配的内存。(第9章讨论了字段的这一点以及其它方面。)
一般而言,你可以认为枚举是对它的基础类型的一种约束——一组预定义的、有限的值(然而,CLR并不支持这种约束)。同样地,一个枚举显然不能有任何特殊的布局需求并且必须将布局标记设置为auto。
委托
委托是一种特殊的引用类型,为了特殊的意图而设计:表示方法指针。所有的委托都派生于[mscorlib]System.MulticastDelegate类型,后者依次派生于[mscorlib]System. Delegate类型。委托本身是密闭的(就像值类型),因此没有类型能从中派生。
强加在委托结构上的限制,和那些强加在枚举结构上的限制一样严格。委托没有字段、事件和属性。它们只有实例方法,要么两个要么四个,并且这些方法的名称和签名都是预定义的。
委托的两个必须的方法是实例构造器(.ctor)和Invoke。实例构造器返回void(正如其它实例构造器一样)并且接收两个参数:指向定义了委托的方法类型的对象引用,以及指向委托的托管方法的方法指针。(参见第10章获取更多关于实例构造器的细节。)
这导致了一个问题:如果你可以获取一个方法指针本身,为什么还是需要委托呢?为什么不直接使用方法指针呢?你可以这么做,但是你需要引进方法指针类型的字段或者变量来保存这些指针——而方法指针类型被认为是一个安全风险(因为指针的值可以在它从一个特定的函数中获取后被修改)并且被认为是不可信的。如果一个模块是不可信的,它只能被来自一个本地的驱动在完全信任的模式下执行,此时所有的安全检查都失效了。另一个缺点是,在调用非托管方法时,托管方法指针不可以被封送为非托管的方法指针,然而,委托是可以的。(参见第18章获取关于托管和非托管代码互操作的信息。)
委托是安全的、不可信的、类型安全的方法指针的表达式。首先是因为委托表达式中的方法指针不会被篡改而且同样是更优越的方法指类型。除此之外,委托可以提供额外有用的特性,正如我马上所要描述的。
第二个必须的方法(Invoke)必须和委托的方法具有相同的签名。两个必须的方法(.ctor和Invoke)足以允许委托用于异步调用的了,这是非常有用的方法调用——当调用线程被阻止,直到这个调用方法返回才得以继续。第一个方法(.ctor)创建了一个委托实例并将其绑定到委托方法上。Invoke方法用于创建一个对委托方法的异步调用。
委托还可以被用于异步调用,当被调用的方法在一个单独的由CLR出于这个意图创建的线程中执行的时候。委托必须定义两个额外的方法,BeginInvoke和EndInvoke,才可以被异步调用。
BeginInvoke是一个线程的开始器。它得到委托方法的所有参数以及另外两个:[mscorlib]System.AsyncCallback类型的一个委托,表示了一个回调方法,在调用完成的时候该方法被调用,还有一个对象,用来指出调用线程的最后状态。BeginInvoke返回接口[mscorlib] System.IAsyncResult的一个实例,携带着作为最后一个参数传递的对象。还记得由于接口、委托和对象都是引用类型,在我说到“得到一个委托”或“返回一个接口”的时候,我实际上意味着一个引用。
如果你想在调用完成时立刻得到通知,你必须指定AsyncCallback委托。这个相应的回调方法是在异步调用完成时被调用的。这种事件驱动的技术是对异步调用起反应的最广泛的使用方法。
你可能会选择另一种方法来监视异步调用线程的状态:对主线程进行轮流检测(polling)。返回的接口具有get_IsCompleted()方法,它在异步调用完成的时候返回true。你可以时不时地从主线程调用这个方法来查明这个调用是否完成了。
你还可以调用这个返回接口的另一个方法,get_AsyncWaitHandle,这会返回一个等待句柄, [mscorlib]System.Threading.WaitHandle类的一个实例。在你得到这个句柄之后,你可以任何你想要的方式来监视它(类似于Win32 API中WaitForSimpleObject和WaitForMultipleObjects的使用)。如果你很好奇,那么就反编译Mscorlib.dll并看一下这个类。
如果你已经使用了轮流检测的技术,你可以放弃回调函数并指定null来取代System.AsyncCallback委托实例。
EndInvoke方法得到这个由BeginInvoke返回的System.IAsyncResult接口,作为它的唯一参数并返回void。这个方法等待异步调用的完成,阻止当前调用的线程,因此在BeginInvoke之后立即调用它,这等价于使用Invoke的异步调用。EndInvoke必须最终被调用从而清除相应的运行时线程表入口,但是它应该在你得知异步调用完成的时候被执行。
委托的所有4个方法都是虚的,而且它们的实现是由CLR本身提供的——用户不需要写这些方法的主体。当定义一个委托时,我们可以简单的声明一些方法而不提供对它们的实现,正如这里所示:
内嵌类型
内嵌类型是定义在其他类型中的类型(类、接口、值类型)。然而,定义在另一个类型中并不使得内嵌类型与成员类和Java内嵌类有相似之处。内嵌类型的实例指针(this)和它的外包类型是绝不相关的。在外包类被创建的时候,内嵌类并不自动访问到这个外包类的this指针。
此外,实例化这个外包类并不涉及到实例化内嵌在其中的类。内嵌类必须被独立的实例化。实例化一个内嵌类并不需要外包类也被实例化。
类型的内嵌并不是关于成员资格和接点的实例化;而全都是关于可见性的。正如在本章前面“类的特性”所解释的,在任何内嵌级别的内嵌类都有它们各自指定的可见性标记。当一个类型内嵌在另一个类型中时,这个内嵌类型的可见性被外包类类的可见性所“过滤”。如果,例如,一个类,它的可见性被设置为nested public,内嵌在一个私有的类中,这个内嵌类在程序集外部将会是不可见的,尽管它指定了自身的可见性。
这个可见性的过滤工作贯穿于内嵌的所有级别。一个内嵌类的最终的可见性是由它自身声明的可见性定义的,然后顺此由它的所有外包类的可见性直接或间接地限制。
内嵌类在ILAsm中的定义和在其它语言中的定义一样——就是说,内嵌类被声明在它们的外包类声明的词法范围内:
根据这个声明,Nestd2类内嵌在Nestd1类中,后者依次内嵌在MyNameSpace.Encl这个非内嵌类之中。
内嵌类的全名不会以任何方式受到它们的外包类的影响。外包类的命名空间和名称都不会自动成为(或被要求成为)内嵌类的全名的一部分。内嵌类的全名在外部类的范围内必须是唯一的,这意味着一个类不可能有两个同样名称的类内嵌在其中。
由于内嵌类是由它们的全名和它们的外包类确定的(这将依次由它的范围和全名来确定),内嵌类在ILAsm中被引用为一个外包类引用、内嵌符号/(forward slash)以及这个内嵌类的全名的串联:
根据这些定义,类Nestd1和Nestd2将被相应的引用为MyNameSpace.Encl/ Nestd1和MyNameSpace.Encl/ Nestd1/ Nestd2。内嵌类的名称在它们的内嵌类中必须是唯一的,而不是顶级类的全名,这在模块或(对于公有类)程序集中必须是唯一的。
不同于C#为所有层次关系使用句点分隔符而无需区别——从而One.Two.Three可能意味着“类Three属于命名空间One.Two”或“类Three内嵌在命名空间One的类Two中”或者甚至是“内嵌在类One中的类Two的Three字段”——ILAsm使用了不同的分隔符来区别不同的层次。句点用于层次上的类的全名;/则指出了内嵌的层次;而在经典的C++中使用的::,表示类和其成员的的关系。我使用限定词“经典”是因为Visual C++的托管版本使用了::来代替带句点名称中的句点分隔符,因此它具有和C#同样的不明确的问题,只是取代了不明确的One.Two.Three,它使用了同样不明确的One::Two::Three。这实在是一个巨大的区别。
到目前为止,讨论主要集中在什么内嵌类型不是这样的。一个更重要的需要注意的否定是,内嵌类对于它们的外包类不会产生影响。如果你想声明一个结构的子结构,你就必须在外部值类型中声明一个内嵌的值类型(子结构),然后定义这个子结构类型的一个字段:
现在我需要说关于内嵌类的一些肯定的事情。内嵌类的成员有权使用外包类的所有成员而无一例外,包括访问私有成员在内。在这点上,内嵌的成员资格比继承性更加强壮,比C++中的类的成员资格更加强壮,这里成员类无权访问它们自身的私有成员。当然,为了访问外包类的实例成员,内嵌类型成员应该首先获取指向外包类的实例指针。这种“全面披露”(full disclosure)的方针只是单向工作的;外包类无权访问内嵌类的私有成员。
内嵌类可以用作其它不需要被内嵌的类型的基类:
当然,类Z,派生于一个内嵌类(Y),不具有访问外包类(X)的私有成员的权限。“全面披露”特性是不可以继承的。
内嵌类可以从它的外包类中派生。在这种情形中,它保留了对外包类的私有成员的访问,而且它还获得了覆写外包类的虚方法的能力。外包类不可以从它的任何内嵌类中派生。
注意:元数据验证规则规定了内嵌类必须定义在相同的模块中以作为它的外包类。然而,在ILAsm中,定义一个内嵌类的唯一方法是将其声明在外包类的词法范围内,这意味着即使是尝试也不可以在ILAsm中违背这条验证规则。