第二章阐述了基于CLR的程序是如何由一个或多个“分子”——程序集构建的。而这些程序集是由一个或多个“原子”——模块构建的。本章将进一步细分“原子”,即把模块分为“亚原子”——类型。本章的重点是通用类型系统[Common
Type System(CTS)],它超出了某个特定编程语言的范畴。不过,为了更加直观地说明CTS,我们需要选择一门编程语言,作为通用类型的载体。因此,本章以C#编程语言为例,阐述CTS的概念和机制。读者不必过多地关注编程语言的语法,而应将重点放在CTS的核心概念上。
类型概述
类型是CLR程序的生成块(building block)(我们说,程序集是.NET Framework的生成块,它们构成基本部署单元、版本控制、重新使用、激活范围和安全权限。在这里,作者将类型视为CLR程序的生成块。开发人员在程序集的上下文中创作类型。)。一旦开发人员确定了如何把工程划分成一个或多个程序集,那么,他们大部分的时间都在考虑类型是如何工作的,以及类型之间是如何相互联系的。编程语言(例如,C#和VB.NET)都有几种表示类型的构件(例如,类、结构、枚举等),但最终所有这些类型,都会映射到CLR的类型定义。
CLR类型(CLR type)是命名的可重用的抽象体。CLR类型的描述存放在CLR模块的元数据中。该模块还包含使类型工作所需要的CIL或者本机代码。完全限定的CLR类型名包括三个部分:程序集名字、可选的命名空间前缀和类型名称。你可以通过第二章描述的自定义特性来控制程序集名字;并且使用多个不同的编程语言构件来控制命名空间前缀和类型名称。例如,
示例3.1中的C#代码定义了一个类型,它的类型名称是Customer,命名空间前缀是AcmCorp.LOB。如第二章中所描述的,命名空间前缀通常与程序集名字匹配,但这只是一个约定,并非刻意的要求。
示例3.1:在C#中定义一个类型
namespace AcmeCorp.LOB {
public sealed class Customer {
// 类型名是AcmeCorp.LOB.Customer
}
}
CLR类型定义由零个或多个成员(member)组成。类型的成员控制类型如何使用,以及类型如何工作。类型的每个成员都有自己的访问修饰符(access
modifier)(例如,public、internal)控制对于成员的访问。类型的可访问成员将被经常引用,组合在一起就是类型的合同(contract)。
本章阐述了通用类型系统,它比大多数编程语言所能处理的类型要宽泛的多。提交给ECMA 的CLI部分被划分出了一个CTS的子集,它能被所有CLI兼容的语言支持。这个子集被称为公共语言规范[Common
Language Specification(CLS)]。组件的开发者们被强烈推荐使用符合CLS的类型和成员,以增强组件的可访问性功能。最后,CLI定义了一个特性System.CLSCompliant,它指示编译器对所有公有成员实施CLS遵从性检查
。CLS的基本限制是缺乏对无符号整型和指针的支持,以及关于如何使用重载的限制。
除了控制对给定成员的访问,开发人员还能够控制类型的实例是否需要访问该成员(如果该成员被声明为静态的,则就不能通过类型的实例对它进行访问。)。多数成员能被定义为按实例(per
instance)或按类型(per type)访问。按实例访问成员(per-instance member)需要通过这个类型的实例才能访问它。按类型访问成员(per-type
member)则没有这种要求。在C#或VB.NET中的成员默认是按实例访问的,你可以通过关键字将它改成按类型访问。例如,在C#中这个关键字是static
,VB.NET中则是Shared。
CTS有三种基本类型的成员:字段、方法和嵌套类型。字段是一个命名的存储单元,它隶属于所声明的类型。方法是一个命名的操作,它可以被调用和执行。嵌套类型则是一种简单的辅助类型,它被定义为声明类型的实现的一部分。所有其他类型成员(例如:属性、事件)是以附加元数据的形式出现的方法(属性和事件实际上也是方法)。
类型的字段控制内存如何分配。CLR使用类型的字段来决定分配多少内存给这个类型。CLR会给static字段分配一次内存:即在类型被首次加载的时候。CLR每次分配一个类型实例时,都会为non-static(instance)[非静态(实例)]字段分配内存。在分配内存时,CLR初始化所有的static字段,并且为它们赋予默认值。对于数值类型,默认值是零;对于布尔类型,默认值是false;对于对象引用,默认值是null。CLR也会初始化堆分配的(heap-allocated)实例字段,同样赋予上述默认值。
CLR保证static字段和堆分配(heap-allocated)实例的字段的初始化状态。CLR将把局部变量分配在堆栈中。你可以通过添加特性到给定方法的元数据中,以标明该方法的局部变量将被自动初始化为它的默认值。例如,VB.NET语言添加这个特性后,CLR将自动初始化局部变量作为方法序幕(prolog)(所谓方法序幕,就是指方法在正式调用之前所做的一系列准备工作)的一部分。C#编译器也添加了这个特性;然而,C#需要局部变量被显式地初始化。为避免引入安全漏洞,CLR验证器需要这个特性出现在可验证的方法(verifiable
methods)(可验证的方法主要是指托管执行的方法(参见第十章))上。
看一个使用字段的例子,考虑示例3.2的C#代码。字段声明的注释标明了CLR给字段分配内存时所使用的初始化值。就customerCount来说,类型
被首次使用之前内存会被分配和初始化。对于所有其他字段,每当新的AcmCorp.LOB实例被分配在堆上时,内存都会被分配和初始化。如图3.1所示。注意在这个例子中balance字段有多份拷贝,但customerCount字段只有一份拷贝,为了访问customerCount字段,可以使用声明的类型名对字段进行简单地限定,如下所示:
AcmeCorp.LOB.Customer.customerCount = 3;
int x = AcmeCorp.LOB.Customer.customerCount - 7;
示例3.2:C#中的字段
namespace AcmeCorp.LOB {
public sealed class Customer {
internal static int customerCount; // 初始化为 0
internal bool isGoodCustomer; // 初始化为false
internal string lastName;// 初始化为null
internal double balance;// 初始化为0.0
internal byte extra;// 初始化为0
internal char firstInitial;// 初始化为'\0'
}
}
图3.1:CLR字段
为了访问某个实例字段,则需要该类型的一个有效实例:
AcmeCorp.LOB.Customer o = new AcmeCorp.LOB.Customer();
o.balance = 3;
if (!o.isGoodCustomer) {
o.firstInitial = 'I';
o.lastName = "Deadbeat";
}
注意,这个例子使用C#的new 操作符在堆中分配了一个新的实例。
默认情况下,确切的内存布局是不透明的(在非托管C++中,开发人员能够明确把握内存的使用情况。对于基于CLR的开发,开发人员对内存的可控性应比较低)。CLR将使用虚拟的内存布局,并且经常会重新排序字段以优化访问和使用,如图3.1所示。注意声明的顺序是:isGoodCustomer、lastName、banlance、extra和firstInitial。如果CLR以类型声明的顺序布局字段,它将不得不在字段间插入空间量(padding),以避免对个别字段的不对齐访问——这将会影响性能。为了避免这点,CLR对字段重新排序以便不再有不必要的空间量。因此,在作者的32位IA-32机器上,这意味着最终采用的顺序是:balance、lastName、firstInitial、isGoodCustomer和extra。这种布局的结果是取消不必要的空间量,并能很好地对齐数据。然而,CLR确切的布局策略并没有正式的文档,并且,对于不同版本的CLR也不可能只依赖某一种特定的策略。
有时需要一个对字段进行约束,让它成为常量值,也就是在它的生存期内不能被改变。CLR提供两种方式将字段声明为常量值。第一种方式所适用的字段,它的常量值是在编译时计算的——这是效率最高的:字段的静态值仅仅作为一个字面值存储在类型的元数据模块中,在运行时它并不是一个真正的字段。准确地说,编译器需要内联任何到字面字段的访问,从本质上讲,它是将字面值嵌入到指令流中。在C#中声明字面字段,必须使用const关键字。这还需要一个初始化表达式,使得它的值能够在编译时计算出来。下面是这种字段声明的例子:
public sealed class Customer {
public const int MAX_CUSTOMER_AGE = 128 * 365;
}
任何试图修改这个字段的做法,都将作为编译时错误被捕获。
字面字段的初始化值在编译时必须是已知的。对于第二种方式,CLR允许程序员将字段声明为不变的(immutable),它将一个字段声明为initonly,并动态地初始化。如果将initonly特性应用到一个字段,那么,一旦构造函数执行完毕,就不允许再对字段值修改。在C#中要指定一个initonly字段,就必须使用readonly关键字。你可以通过使用初始化表达式指定初始化值,或简单地在类型的构造函数方法中赋值。无论哪种情况,被使用的值都能顾及到程序执行状态的动态方面。下面是一个有关initonly字段的示范例子,它是用C#编写的:
public sealed class Customer {
public readonly long created = System.DateTime.Now.Ticks;
}
注意,这段代码动态地生成了created字段的初始化值,它是基于当前时间的。也就是说,在新的实例构造函数执行完毕后,假如created的值被设置,就不能再改变它。
开发人员使用类型的字段来指定对象的状态。他们通过方法指定一个对象的行为。方法被称为操作,它隶属于某个类型。你可以声明一个方法返回某个类型的值或不返回任何值。在C#和C++中,后者是通过void关键字说明没有返回类型。在VB.NET中,你可以使用Sub关键字声明不返回任何值;通过Function关键字将方法声明为返回某个类型的值。
同字段一样,你可以通过访问修饰符(如private或public)来限制对方法的访问。同样,你也可以把方法指定为按实例访问或者按类型(static)访问。访问静态方法不需要类型的实例,而调用非静态方法则需要类型的实例[但有些语言,例如C++,在调用非虚拟(non-virtual)、非静态(non-static)方法时,允许使用空引用]。考虑下面的类型声明:
namespace AcmeCorp.LOB {
public sealed class Customer {
public static int GetCount() { return 0; }
public static void ResetCount() { }
public void ClearStatus() { }
public byte GetExtraInfo() { return 0; }
}
}
这个类型有四个方法声明。其中,有两个方法(GetCount和ResetCount)是静态的,不需要通过实例调用。你可以通过类型名加以限定来访问这些方法,如下所示:
int c = AcmeCorp.LOB.Customer.GetCount();
AcmeCorp.LOB.Customer.ResetCount();
其他两个方法(ClearStatus和GetExtraInfo)则需要通过一个有效实例来调用,如下所示:
AcmeCorp.LOB.Customer o
= new AcmeCorp.LOB.Customer();
if (o.GetExtraInfo() == 42)
o.ClearStatus();
一些编程语言(例如,C++)允许程序员用实例或者类型作为限定条件来调用静态方法。某些编程语言(例如,C#)不允许程序员按实例访问静态成员。对于你所选择的语言可以参照相应的语言参考。
除了返回类型化的值,方法还能接受参数(parameters)(parameter和argument的译法一直比较混乱,它们都有参数的意思。parameter一般通指参数。就某个方法或者函数来说,parameter指形参,即在方法定义中出现的参数;argument则是实参,在方法调用时由调用方提供的。在本书中,parameter译为参数,而argument译为自变量)。方法参数充当附加的局部变量,为方法体使用。你可以静态地指定每个参数的类型和名字,作为方法声明的一部分。在调用时,调用方动态地提供每个参数的值。
默认情况下,方法的参数是由调用方提供的值的独立拷贝,并且,在方法体内改变参数值将不会影响调用方。这种参数传递风格被称为传值(pass-by-value)。如果在调用方和被调用方(例如,方法体)间共享唯一的参数拷贝,那么必须使用特定编程语言的构件,显式地声明参数是传引用(pass-by-reference)的。在VB.NET中,你可以使用ByVal或ByRef参数修饰符指定这种模式。在C#中默认情形是传值,如果要改变为传引用,那么就要添加ref或者out参数修饰符。这两个关键字都是表示传引用,其中out关键字标明该参数不需要初始化。这个特别信息对于CLR验证器和RPC[远程过程调用(Remote
Processing Call)]封送引擎(marshaling engine)都是有用的。`
考虑示例3.3显示的C#类型定义。在这个例子中,Recalc方法接受三个参数:第一个参数(initialBalance)是传值,这意味着方法体拥有这个值的方法体自己的私有拷贝,另外两个参数被声明为传引用,这表示方法体对参数的任何修改,都会相应地改变调用方的参数。在这个例子的CheckJohnSmith方法中,这意味着Recalc方法能修改current和sol这两个局部变量。然而,对于传值的局部变量(initial),却不会看到Recalc方法体对它所作的任何修改(因为方法体对传值参数所作的改变,只是改变属于它自己的一个值的拷贝)。
示例3.3:C#中的方法参数
namespace AcmeCorp.LOB {
public sealed class Customer {
public static void Recalc(double initialBalance,
ref double currentBalance,
out bool overdrawn)
{
initialBalance = initialBalance / 2; // 按比例缩小
currentBalance -= 0.02; // 附加费用
overdrawn = currentBalance < initialBalance;
}
public static void CheckJohnSmith() {
double initial = 1000.00;
double current = 1000.00;
bool sol;
Recalc(initial, ref current, out sol);
Debug.Assert(initial == 1000.00);
Debug.Assert(current == 999.98);
Debug.Assert(sol == false);
}
}
}
一般情况下,所给出方法的参数个数是固定的。为了允许使用可变参数列表的特征,CLR允许方法的最后一个参数使用[System.ParamArrayAttribute]特性。ParamArrayAttribute特性只能应用到方法的最后一个参数上,并且方法参数类型必须被声明为数组类型。这就是说,[System.ParamArrayAttribute]相当于编译器的提示,用于支持可变数量的参数,这些参数的类型匹配那个数组的元素类型。在C#中,通过params关键字添加[System.ParamArrayAttribute]特性:
public sealed class Dialer {
public static void DialEm(string message,
params string[] numbers) {
for (int i = 0; i < numbers.Length; i++)
Util.Dial(message, numbers[i]);
}
public static void CallFred() {
DialEm("Hi Fred!", "310-555-1716", "781-555-9895");
}
}
注意,这个例子声明的DialEm方法,有ParamArray参数,它使调用方(在这里是CallFred方法)可以传递它想要的多个字符串参数,就像独立的参数一样;被调用方(在这里是DialEm方法)将把参数列表的那一部分视为单个数组。
方法体可以无限制地访问它的声明类型的成员。对于它的声明类型的基类型,方法体也可以无限制地访问被声明为protected或public的成员。
大多数编程语言允许方法访问其声明类型的成员,而不用进行明确限定,尽管明确限定是允许的。为了限定static成员名字,可以用类型名;如果要限定实例成员名字,每种语言提供了一个关键字,它对应于调用该方法的实例。在C#和C++中,这个关键字是this。在VB.NET中,这个关键字是一个听起来更友好的名字Me。无论是哪一种,this或Me都是有效的表达式,它的类型对应于声明的类型,因此可以把它当作一个参数传递,或者把它赋值给一个变量或者字段。注意,静态方法没有this或Me变量,并且在没有预先获得一个有效实例的情况下不能访问非静态成员。
许多编程语言支持方法名的重载(overload),它接受略微不同的参数列表。为了支持这个特征,CLR能包含使用同一个名字的多个方法定义,而这些方法定义的参数列表,要么是参数的个数不同,要么是参数的类型不同。CLR还允许基于返回值类型的重载;不过很少有语言支持这点,而且CLS中也是禁止的。CLS允许基于传引用与传值的重载。然而,CLR不允许基于C#的ref和out关键字的差别的重载,因为它们不是方法签名中的一部分。更合适的说法是,ref和out都是简单地标明参数将作为托管指针而传递(更多信息参考第十章)。区别于ref和out的附加元数据特性也不是方法签名的一部分,而是参数预定用法的额外提示。
CLR没有试图禁止可能会引起歧义的重载。例如,如果重载是基于给定参数的类型进行选择的,那么,通过数值演变或者类型关系(或者两者都有),多重重载就可能是合法的。CLR会欣然接受你定义这样的类型;那就是说,对于一个给定的调用点,并不是每个编译器都会使用同样的规则来选择使用哪个重载版本。一些编译器会使用语言相关的试探法,还有一些编译器会简单地放弃并返回一个编译时错误。这就是为什么应该明智地使用重载的原因之一,尤其对于作为类型的使用者的编程语言,不能将它认为是先知先觉的(作者在这里使用了拉丁文”a
priori”,意为先验的,先知的,意思是说,不能以为编译器什么都能解决)。
第三种也是最后一种类型成员是嵌套类型。简单地说,嵌套类型是一种在另一个类型的范围之内声明的类型。嵌套类型比较有代表性的运用构建辅助对象(例如,迭代器、序列化器),它支持声明类型的实例。示例3.4是C#中嵌套类型的一个例子:
示例3.4:C#中的嵌套类型
namespace AcmeCorp.LOB {
public sealed class Customer {
public sealed class Helper {
private static int incAmount;
public static void IncIt() {
// 合法——嵌套类型中的方法能够访问它的包含类型的私有成员
nextid += incAmount;
}
}
private static int nextid;
public static void DoWork() {
// 合法——IncIt是一个public成员
Helper.IncIt();
// 非法——incAmount是private成员
Helper.incAmount++;
}
}
}
与“顶级(top-level)”类型相比,嵌套类型有两个基本优点:其一是,嵌套类型的名字是在外部类型名范围之内,这是减少命名空间污染的一个措施;更重要的是,你能够使用与保护字段和方法相同的访问修饰符,用于保护对嵌套类型的访问。
不像Java的内部类,CLR的嵌套类型总是被当作是声明类型的静态成员,它不隶属于任何特定实例。嵌套类型的名字由外部类型名字限定。为了CLR反射的目的,你可以使用“+”分隔声明类型的名字和嵌套类型的名字。在示例3.4所示的例子中,Helper类型的CLR类型名是AcmeCorp.LOB.Customer+Helper。每种编程语言都有它自己的分隔符。在C++中分隔符是“::”。在VB.NET和C#中,分隔符是“.”,这意味着,在这个C#例子中,Helper类型可以用AcmeCorp.LOB.Customer.Helper符号来引用(注意Customer和Helper之间的句点)。
嵌套类型的最大好处可能就是:它们的方法与声明类型的成员之间相关联的方式。因为嵌套类型被认为是声明类型实现的一部分,所以,嵌套类型的方法被赋予了特权。嵌套类型的方法可以对声明类型的私有成员进行无限制地访问。反之不然,声明类型不具有对嵌套类型成员访问的特别权限。注意在上述的例子中,Helper.IncIt方法可以自由地访问声明类型的私有字段nextid。反之,Customer.DoWork方法则不能访问嵌套类型的私有字段incAmount。