编译器如何处理范型?
C++ 模板和 Java
语言中提议的范型等效都是它们各自编译器的功能。这些编译器在编译时根据对范型或模板类型的引用来构造代码。这会导致代码臃肿并降低结构之间的类型等效(即使类型变量相同)。相反,CLR
范型不采用这种工作方式。
CLR
中的范型是平台本身出类拔萃的功能。要通过这种方式实现它就需要更改整个
CLR(包括新的和修改过的中间语言指令),并更改元数据、类型加载器、实时
(JIT) 编译器、语言编译器等等。这对 CLR
中的运行时扩展有两个重要的好处。
首先,即使范型的每个结构(例如 Node首先,即使范型的每个结构(例如
Node<Form> 和 Node<String>)都有自己独特的类型标识,但
CLR 能够在类型实例化期间重用许多真正的
JIT
编译的代码。这极大地降低了代码膨胀,并且也是切实可行的,因为范型的各种实例化都是在运行时才展开的。在编译时,构造类型的所有内容就是类型引用。当程序集
A 和 B
都引用在第三方程序集中定义的范型时,它们的构造类型就会在运行时展开。这意味着,除了共享
CLR
类型标识(在适当的时候)以外,来自程序集
A 和 B
的类型实例化也共享运行时资源,如本机代码和扩展的元数据。
类型等效是构造类型运行时扩展的第二个好处。以下为一个示例:引用
AssemblyA.dll 中构造 Node <Int32>
的代码和引用 AssemblyB.dll 中构造 Node
<Int32>
的代码都会在运行时创建具有相同 CLR
类型的对象。通过这种方式,如果两个程序集由同一个应用程序使用,则它们的
Node <T>
类型的结构会解析为相同的类型,并且它们的对象可以自由交换。应该注意的是,编译时扩展会使得这种逻辑上很简单的等效变得有问题或者无法实现。
在运行库级别(而非编译器级别)实现范型还有其他一些好处。其中一个好处是范型信息会在编译和执行期间保留下来,因此在代码生存期的任何时刻都可以访问它。例如,反射提供对范型元数据的完全访问。另一个好处是
Visual Studio .NET 中丰富的 IntelliSense
支持,以及范型代码所带来的舒心的调试体验。相反,Java
范型和 C++
模板在运行时会失去它们的范型标识。
另一个好处(也是 CLR
的支柱)是交叉语言使用 —
使用一种托管语言定义的范型可以由用另一种托管语言编写的代码引用。同时,由于许多繁重的工作都是在这个平台上完成的,所以语言供应商在他们的编译器中置入范型支持的可能性与日剧增。
在运行时类型扩展的众多好处中,我最喜欢的那一个就显得微不足道了。范型代码只限明确用于类型构造实例化的操作使用。这种限制的附加好处是,使
CLR 范型比与它们相应的 C++
模板更好理解,也更加有用。让我们看一下
CLR 中对范型的限制。
规则和限制
一个困扰使用 C++
模板的编程人员的问题是许多对类型结构所作的特殊尝试都会失败,包括类型参数的类型变量在实现由模板化代码调用的方法时会失败。同时,这些情况下的编译器错误也很令人困扰,而且可能看起来与根本问题不相关。采用构造类型的运行时扩展以后,类似的错误会变成
JIT
编译器错误或类型加载错误,而不是编译时错误。CLR
架构师决定了对于范型来说,这种实现是不可接受的。
相反,他们决定了对于范型(例如 Node
<T>)的任何可能的类型实例化,即使类型实例化实际发生在运行时,该范型也必须在编译时被证实为一种有效类型。同样,有问题的类型结构周围的扩展错误不可能出现。为了实现这个目标,架构师通过一组规则和限制来约束范型的功能,从而保证在尝试扩展其中一个范型实例化之前这种范型有效。
有一些规则限制了您通常可以编写的代码的类型。这些规则的本质可以归纳为一句话:范型代码只有在用于范型的每个可能的构造实例时才有效。否则,范型代码无效,并且也不能正确编译(或者可以编译,但无法在运行时通过验证)。
public class GenericMath {
public T Min<T>(T item1, T item2) {
if (item1 < item2) {
return item1;
}
return item2;
}
}
这段代码在 CLR 范型中是无效的。C#
编译器产生的错误如下所示:
invalid.cs(4,11): error CS0019: Operator '<' cannot be applied to
operands of type 'T' and 'T'
同时,除了细微的语法区别外,与这基本相同的代码在
C++
模板中是允许的。为什么对范型有这样的限制呢?原因是:在
C# 中,“<”运算符只能用于特定的类型。然而,前面代码片段中的类型参数
T 可以在运行时扩展为任何 CLR
类型。前面的代码示例不是在运行时被认为是无效的,而是在编译时被认为是无效的。
除了运算符,更多可管理的类型使用(例如方法调用)也应用了相同的限制。以下对
Min <T>
方法的修改也是无效的范型代码:
class GenericMath {
public T Min<T>(T item1, T item2) {
if (item1.CompareTo(item2) < 0) {
return item1;
}
return item2;
}
}
这段代码无效的原因与前面的示例是一样的。虽然类库中的许多类型都实现了
CompareTo
方法,而且该方法也很容易由您的自定义类型实现,但不能保证这个方法适用于可用作
T 的参数的任何可能的类型。
但您也可以看出,范型代码中并非完全禁止方法调用。GetHashCode
方法在两个参数化变量中调用,Node<T>
类型在它的参数化 m_data 字段中调用了
ToString 方法。为什么允许 GetHashCode 和
ToString,而不允许 CompareTo 呢?原因在于,GetHashCode
和 ToString 都是在 System.Object
类型中定义的,而每个可能的 CLR
类型也是从这个类型派生的。这意味着,类型
T 的每个可能的扩展都实现了 ToString 和
GetHashCode 成员函数。
如果要使范型可用于集合类以外的任何类,则范型代码需要能够调用由
System.Object
定义的方法以外的方法。不过要记住,只有当用于范型的任何可能的构造实例时,范型代码才有效。有一个办法可以解决这两个看似相互矛盾的要求,那就是
CLR 范型中称为约束的功能。
您应该知道,约束是范型或方法定义的一个可选组件。在可作为变量用于范型代码上的一个类型参数的类型中,一个范型可以定义任意数量的约束,而每个约束可以应用任一个限制。通过限制可在范型结构中使用的类型,对引用受限类型参数的代码的限制就可以放松一些。
因为对类型参数 T 应用了约束,所以 Min
<T> 和 Max <T> 在其条目上调用
CompareTo 是有效的。
where T : IComparable
这个约束表明 Min <T>
方法的任何结构都必须为实现 IComparable
接口的类型的参数 T
提供一个类型变量。这个约束限制了 Min
<T>
的可能实例化的种类,但提高了方法中代码的灵活性,比如现在可以在类型
T 的变量上调用 CompareTo。
约束通过允许范型代码调用扩展类型上的任意方法,从而使范型算法成为可能。虽然约束要求使用额外的语法才能定义范型代码,但约束不会改变引用代码的语法。引用代码的唯一区别在于,类型变量必须遵守对范型的约束。例如,以下用于
Max <T> 的引用代码是有效的:
GenericMath.Min(5, 10);
这是因为 5 和 10 都是整数,而且 Int32
类型实现了 IComparable
接口。然而,试图实现以下 Max <T>
结构会产生编译器错误:
GenericMath.Min(new Object(), new Object());
MinMax.cs(32,7): error CS0309: The type 'object' must be convertible
to 'System.IComparable' in order to use it as parameter 'T' in the
generic type or method 'GenericMath.Min(T, T)'
对于 T,System.Object
是一个无效的类型变量,因为它没有实现对
T 的约束要求实现的 IComparable
接口。当类型变量与范型代码上的类型参数不兼容时,约束就可能会使编译器产生如前面示例中所示的描述性错误。
目前,范型支持三种类型的约束:接口约束、基类约束和构造函数约束。接口约束指定一个接口,该参数的所有类型变量都必须与这个接口兼容。任意数量的接口约束都可以应用于给定的类型参数。
基类约束与接口约束类似,但每个类型参数只能包含一个基类约束。如果没有为类型参数指定约束,则应用
Object 的隐式基类约束。
构造函数约束通过约束实现公共默认构造函数的类型的类型变量,使得范型代码能够创建由类型参数指定的类型的实例。目前,构造函数约束只支持默认或无参数构造函数。
where
子句用于为给定的类型参数定义约束或约束列表。每个
where
子句只应用于一个类型参数。范型或方法定义可以没有
where 子句,也可以有与类型参数一样多的
where 子句。一个给定的 where
子句可以包含一个约束,也可以包含由逗号分隔的约束列表。
范型的基本规则(范型所有可能的实例化都是有效的,或者范型本身是无效的)还有其他一些有趣的副作用。第一个是强制类型转换。在范型代码中,类型参数类型的变量可能只能与它的基类约束类型或基类约束类型的基类进行相互的强制转换。这意味着,如果类型参数
T 没有约束,它就只能与 Object
引用进行相互强制转换。然而,如果将 T
约束为进一步派生的类型(例如 FileStream),则范型定义可以包括
T 与 FileStream 以及与 FileStream
的所有基类(直到 Object)之间的相互强制转换。
在这个示例中,T
没有约束,并且被视为未绑定。它具有
Object 的隐式基类约束。T 与 Object
类之间的相互强制转换以及与接口类型之间的相互强制转换在编译时是有效的。但编译器不允许未绑定的
T 强制转换为其他类型(如 Int32)。范型的强制转换规则也适用于转换,因此不允许使用强制转换语法来进行类型转换(比如从
Int32 转换成 Int64);范型不支持类似的转换。
void Foo<T>() {
T x = null; // compiler error when T is unbounded
•••
虽然像这样赋空值很常使用,但可能存在一个问题。如果将
T
扩展为值类型,会出现什么情况呢?对一个值变量赋空值没有意义。幸运的是,C#
编译器提供了特殊的语法,以保证正确的结果,而不用管
T 的运行时类型:
void Foo<T>() { T x = T.default; // OK for any
T}
等号右边的表达式称为默认值表达式。如果将
T 扩展为引用类型,则 T.default 会解析为
null。如果将 T
扩展为值类型,则对于该变量,T.default
是所有位都为零的值。
如果 T 未绑定,则不允许对参数化变量 T
赋空值,因此可能有人会认为以下语句也是无效的,但事实不是如此:
void Foo<T>(T x) {
if (x == null) { // Ok
•••
}
}
如果此处允许为 null,则将 T
扩展为值类型时会出现什么情况呢?如果 T
是值类型,则前面示例中的 Boolean
表达式会强制为 false。与赋空值的情况不同,对于
T 的任何扩展,空值比较是有意义的。
对于任何可能的实例化,在编译时确认范型有效这个前提的确对范型代码有影响。然而,我发现与
C++ 中的模板相比,CLR
中范型周围的附加结构有明显的作用。总之,约束及其周围的基础结构是
CLR 范型中我最喜欢的一个方面。
范型接口和委托
范型类、结构和方法是 CLR
范型的主要功能。范型接口和委托是真正起支持作用的功能。范型接口如果单独使用,则用处有限。但是,当与范型类、结构或方法一起使用时,范型接口(和委托)就会有重要的作用。
GenericMath.Min <T> 和 GenericMath.Max
<T> 方法都将 T 约束为与 IComparable
接口兼容。这使得这些方法可以调用方法的参数化变量上的
CompareTo。原因在于,如果接口采用一个或多个对象参数(例如
CompareTo 的 obj
参数),则调用值类型的非范型接口会导致装箱。
对于 GenericMath.Min <T>,如果该方法的实例化将
T
扩展为值而不是引用,则每次调用这个方法都会导致
CompareTo
方法的参数装箱。在这种时候,范型接口就可以派上用场了。
代码中重构了 GenericMath
方法,以通过范型接口 IComparable <T>
来约束 T。现在,如果 Min <T> 或 Max
<T> 的实例化使用值类型作为 T
的变量,则对 CompareTo
的接口调用就是接口结构的一部分,它的参数是值类型,而且没有发生装箱。
范型委托具有类似于范型接口的好处,但它是面向方法而非面向类型。