UML软件工程组织

CLR 中的泛型简介
转自:microsoft 作者:Jason Clark

泛型是对 CLR 类型系统的扩展,它允许开发人员定义那些未指定某些细节的类型。相反,当用户代码引用该代码时,就会指定这些细节。引用泛型的代码填充缺少的细节,并根据其特定需求对类型进行调整。泛型的命名反映了该功能的目标:允许在编写代码时不指定可能限制其使用范围的细节。代码本身就是泛型。稍后,我会对它进行更详细的介绍。

还需要多长时间才能提供泛型?Microsoft 计划在发布 CLR 代码代号为 "Whidbey" 时提供泛型,在本专栏出版之后,应当会发布 Whidbey CLR 的测试版。同时,在 CLR 的测试版中,预计会对语言和编译器进行更新,以便充分利用泛型。最后,为了包含泛型支持,Microsoft 的研究组已经修改了共享源公共语言实现 (CLI) — 代码代号为 "Rotor"。

泛型预览

正如使用任何新技术一样,明白它的好处所在会有所帮助。那些熟悉 C++ 模板的用户将会发现,泛型在托管代码中具有相似的用途。但是,我不愿意对 CLR 泛型和 C++ 模板进行过多比较,因为泛型具有一些额外的好处,它不存在以下两个常见问题:代码臃肿和开发人员混淆。

CLR 泛型具有一些好处,如编译时类型安全、二进制代码重用、性能和清晰性。我将简要介绍这些好处,您在阅读本专栏的其余文章时,会更详细地了解它们。例如,假设有两个集合类:SortedList(Object 引用的集合)和 GenericSortedList<T>(任意类型的集合)。

类型安全?当用户向 SortedList 类型的集合内添加 String 时,String 会隐式强制转换为 Object。同样,如果从该列表中检索 String 对象,则它必须在运行时从 Object 引用强制转换到 String 引用。这会造成编译时缺少类型安全,从而使开发人员感到厌烦,并且易于出错。相反,如果使用 GenericSortedList<String>(T 的类型被设置为 String),就会使所有的添加和查找方法使用 String 引用。这允许在编译时(而非运行时)指定和检查元素的类型。

二进制代码重用?为了进行维护,开发人员可以选择使用 SortedList ,通过从它派生 SortedListOfString 来实现编译时的类型安全。此方法有一个问题,那就是必须对于每个需要类型安全列表的类型都编写新代码,而这会很快变成非常费力的工作。使用 GenericSortedList<T>,需要执行的全部操作就是将具有所需元素类型的类型实例化为 T。泛型代码还有一个附加价值,那就是它在运行时生成,因此,对于无关元素类型的两个扩展(如 GenericSortedList<String> 和 GenericSortedList<FileStream>)能够重新使用同一个实时 (JIT) 编译代码的大部分。CLR 只是处理细节 — 代码不再臃肿!

性能?关键在于:如果类型检查在编译时间进行,而不是在运行时间进行,则性能增强。在托管代码中,引用和值之间的强制转换既会导致装箱又会导致取消装箱,而且避免这样的强制转换可能会对性能产生同样的负面影响。最近针对一个由一百万个整数组成的数组进行了快速排序法基准测试,结果表明泛型方法比非泛型方法快三倍。这是由于完全避免了对这些值进行装箱。如果针对由字符串引用组成的数组进行同样的排序,则由于无需在运行时执行类型检查,因此使用泛型方法后性能提高了 20%。

清晰性?泛型的清晰性体现在许多方面。约束是泛型的一个功能,它们会禁止对泛型代码进行不兼容的扩展;使用泛型,您将不再面临那些困扰 C++ 模板用户的含混不清的编译器错误。在 GenericSortedList<T> 示例中,集合类将有一个约束,该约束使集合类只处理可进行比较并依此进行排序的 T 类型。同样,通常可以使用名为类型推理的功能来调用泛型方法,而无需使用任何特殊语法。当然,编译时类型安全可以使应用程序代码更加清晰。 我将在本专栏中详细介绍约束、类型推理和类型安全。

一个简单的示例

Whidbey CLR 版本将通过类库中的一套泛型集合类来提供这些现成的好处。但是,可通过为应用程序定义其自己的泛型代码,使其进一步受益于泛型。为了解释这是如何完成的,我将首先修改一个简单的链接列表节点类,使其成为泛型类类型。

using System;

// Definition of a node type for creating a linked list
class Node {
Object m_data;
Node m_next;

public Node(Object data, Node next) {
m_data = data;
m_next = next;
}

// Access the data for the node
public Object Data {
get { return m_data; }
}

// Access the next node
public Node Next {
get { return m_next; }
}

// Get a string representation of the node
public override String ToString() {
return m_data.ToString();
}
}

// Code that uses the node type
class App {
public static void Main() {

// Create a linked list of integers
Node head = new Node(5, null);
head = new Node(10, head);
head = new Node(15, head);

// Sum-up integers by traversing linked list
Int32 sum = 0;
for (Node current = head; current != null;
current = current.Next) {
sum += (Int32) current.Data;
}

// Output sum
Console.WriteLine("Sum of nodes = {0}", sum);
}
}

图1

图 1 中的 Node 类只是包括一些基本内容。它有两个字段:m_data(引用节点的数据)和 m_data(引用链接列表中的下一项)。这两个字段都是由构造函数方法设置的。确实只有两个其他点缀性功能,第一个功能是通过名为 Data 和 Next 的只读属性访问 m_data 和 m_next 字段。第二个功能是对 System.Object 的 ToString 虚拟方法进行重写。

图 1 还显示了使用 Node 类的代码。该引用代码会受到某些限制。问题在于,为了能在许多上下文中使用,其数据必须为最基本的类型,即 System.Object。这意味着使用 Node 时,就会失去任何形式的编译时类型安全。使用 Object 意味着算法或数据结构中的“任意类型”会强迫所使用的代码在 Object 引用和实际数据类型之间进行强制转换。应用程序中的任何类型不匹配错误只有在运行之后才被捕获。如果在运行时尝试进行强制转换,这些错误会采用 InvalidCastException 形式。

此外,如果要向 Object 引用赋予任何基元值(如 Int32),则需要对实例进行装箱。装箱涉及到内存分配和内存复制,以及最后对已装箱值进行的垃圾回收。最后,正如可在 图 1中看到的那样,从 Object 引用强制转换为值类型(如 Int32)会导致取消装箱(也包括类型检查)。 由于装箱和取消装箱会损害该算法的整体性能,因此您会明白为什么用 Object 就意味着“任何类型”都具有一定的缺点。

class Node<T> {
T m_data;
Node<T> m_next;

public Node(T data, Node<T> next) {
m_data = data;
m_next = next;
}

// Access the data for the node
public T Data {
get { return m_data; }
set { m_data = value; }
}

// Access the next node
public Node<T> Next {
get { return m_next; }
set { m_next = value; }
}

// Get a string representation of the node
public override String ToString() {
return m_data.ToString();
}
}

图2

使用泛型重写 Node 是解决这些问题的完美方法。让我们看一下 图 2 中的代码,您将发现 Node 类型被重写为 Node<T> 类型。具有泛型行为的类型(如 Node<T>)是参数化类型,并且可被称作 Parameterized Node、Node of T 或泛型Node。稍后我将介绍这个新的 C# 语法;让我们首先深入研究一下 Node<T> 与 Node 有何不同。

Node<T> 类型与 Node 类型在功能和结构上相似。二者均支持为任何给定类型的数据构建链接列表。但是,Node 使用 System.Object 来表示“任意类型”,而 Node<T> 不指定该类型。相反,Node<T> 使用名为 T 且作为类型占位符的类型参数。当使用者代码使用 Node<T> 时,名为 T 的类型参数最终由 Node<T> 的参数来指定。

class App {
public static void Main() {

// Create a linked list of integers
Node<Int32> head = new Node<Int32>(5, null);
head = new Node<Int32>(10, head);
head = new Node<Int32>(15, head);

// Sum up integers by traversing linked list
Int32 sum = 0;
for (Node<Int32> current = head; current != null;
current = current.Next) {
sum += current.Data;
}

// Output sum
Console.WriteLine("Sum of nodes = {0}", sum.ToString());
}
}

图3

图 3 中的代码使用了具有 32 位带符号整数的 Node<T>,这是通过构造类似类型名称:Node<Int32> 来实现的。在本例中,Int32 是类型参数 T 的类型变量。(顺便说一句,C# 还将接受Node<int>,以便将 T 指示为 Int32。) 如果该代码需要某种其他类型(如 String 引用)的链接列表,则这可通过将它指定为 T 的类型变量来完成,例如:Node<String>。

Node<T> 的好处在于:它的算法行为可被明确定义,而它所操作的数据类型仍保持未指定状态。因此,Node<T> 类型在工作方式上是具体的;而泛型在所处理的内容方面又是具体的。总之,诸如链接列表应当拥有的数据类型等细节最好留给使用 Node<T> 的代码来指定。

在讨论泛型时,最好先明确两种角色:定义代码和引用代码。定义代码包括既声明泛型代码存在又定义类型成员(如方法和字段)的代码。图 2 中显示的是类型 Node<T> 的定义代码。引用代码是用户代码,它使用预定义的泛型代码,并且该代码还可以内置到另一个程序集中。图 3 是 Node<T> 的引用代码示例。

考虑定义代码和引用代码非常有用,原因在于这两种角色都在实际的可使用泛型代码构造中起着一定的作用。图 3 中的引用代码使用 Node<T> 来构造一个名为 Node<T> 的新类型。Node<Int32> 是一个截然不同的类型,它由以下两个关键成分构建而成:Node<T>(由定义代码创建),参数 T 的类型变量 Int32(由引用代码指定)。只有使用这两个成分才能使泛型代码变得完整。

请注意,从面向对象的派生角度看,泛型类型(如 Node<T>)以及从泛型类型构造的类型(如 Node<Int32> 或 Node<String>)并不是相关类型。类型 Node<Int32>、Node<String> 和 Node<T> 类型是同辈,它们都是从 System.Object 直接派生而来。

C# Generic 语法

CLR 支持多种编程语言,因此,CLR 泛型将有多种语法。但是,无论采用哪种语法,用一种面向 CLR 的语言编写的泛型代码将可以由其他面向 CLR 的语言编写的程序使用。我将在本文中介绍 C# 语法,其原因是,在编写本文时,在三种较大的托管语言中,泛型的 C# 语法相当稳定。 但是,没有必要在 Visual Basic®.NET 和 Managed C++ 的 Whidbey 版本中支持泛型。

Defining Code Referencing Code
class Node<T> {
   T        m_data;
   Node<T>  m_next;
}
class Node8Bit : Node<Byte> {
 •••
}
struct Pair<T,U> {
   T  m_element1;
   U  m_element2;
}
Pair<Byte,String> pair;
pair.m_element1 = 255;
pair.m_element2 = "Hi";
interface IComparable<T> {
   Int32 CompareTo(T other);
}
class MyType : IComparable<MyType> { 
   public Int32 CompareTo(MyType other)
   { ... }
}
void Swap<T>(ref T item1, ref T item2) {
   T temp = item1;
   item1 = item2; item2 = temp;
}
Decimal d1 = 0, d2 = 2;
Swap<Decimal>(ref d1, ref d2);
delegate void EnumerateItem<T>(T item);
•••
   EnumerateItem<Int32> callback = 
     new EnumerateItem<Int32>(CallMe);
}
void CallMe(Int32 num) { ... }

图4

图 4 显示了泛型定义代码和泛型引用代码的基本 C# 语法。二者的语法区别反映了泛型代码所涉及的双方的不同职责。

目前的计划是让 CLR(从而让 C#)支持泛型类、结构、方法、接口和委托。图 4 的左侧显示了每种定义代码情况的 C# 语法示例。.请注意,尖括号表示类型参数列表。尖括号紧跟在泛型类型或成员的名称后面。同样,在类型参数列表中有一个或多个类型参数。参数还出现在泛型代码的整个定义中,用来替代特定的 CLR 类型或作为类型构造函数的参数。图 4 的右侧显示了与之相匹配的引用代码情况的 C# 语法示例。请注意,在此处,类型变量括在尖括号中;泛型标识符和括号构成一个截然不同的新标识符。另外还要注意,类型变量指定在从泛型构造类型或方法时所使用的类型。

让我们花一点时间来定义代码语法。当编译器遇到一个由尖括号分开的类型参数列表时,它可识别出您在定义泛型类型或方法。泛型定义中的尖括号紧跟在所定义的类型或方法的名称后面。

类型-参数列表指出要在泛型代码定义中保持未指定状态的一个或多个类型。类型参数的名称可以是 C# 中任何有效的标识符,它们可用逗号隔开。对于图 4中“定义代码”部分中的类型参数,需要注意下面一些事项:

在每个代码示例中,可以看到在整个定义中(通常将出现类型名称的位置)均使用了类型参数 T 或 U。

在 IComparable<T> 接口示例中,可以看到同时使用类型参数 T 和常规类型 Int32。在泛型代码的定义中,既可以使用未指定的类型(通过类型参数)又可以使用指定的类型(使用 CLR 类型名称)。

在 Node<T> 示例中,可以看到,类型参数 T 可以像在 m_data 的定义中一样独立使用,还可以像在 m_next 中一样用作另一个类型构造的一部分。用作另一个泛型类型定义的变量的类型参数(如 Node<T>),称作开放式泛型类型。用作类型参数的具体类型(如 Node<System.Byte>),称作封闭式泛型类型。

与任何泛型方法一样,图 4 中显示的示例泛型方法 Swap<T> 可以是泛型或非泛型类型的一部分,也可以是实例、虚拟或静态方法。

在本专栏中,我对于类型参数使用的是单字符名称(如 T 和 U),这主要是为了使情况更简单。但是,您会发现也可以使用描述性名称。例如,在产品代码中,Node<T> 类型可被等效地定义为 Node<ItemType> 或 Node<dataType>。

在撰写本文时,Microsoft 已经使库代码中的单字符类型参数名称标准化,以有助于区分这些名称与用于普通类型的名称。我个人比较喜欢在产品代码中使用 camelCasing 类型参数,因为这可将它们与代码中的简单类型名称相区分,而同时又具有一定的描述性。

在泛型引用代码中,未指定的类型会变成指定的类型。如果引用代码实际使用泛型代码,则这是十分必要的。如果您查看图 4 中“Referencing Code”部分中的示例,就会发现在所有情况中,新类型或方法都是通过将 CLR 类型指定为泛型的类型变量,从一个泛型构造的。在泛型语法中,诸如 Node<Byte> 和 Pair<Byte,String> 之类的代码表示从泛型类型定义构造的新类型的类型名称。

在深入介绍该技术本身之前,我将再介绍一个语法细节。在 图 4 中,当代码调用泛型方法(如 Swap<T> 方法)时,完全限定的调用语法包括任何类型变量。但是,有时可以选择将类型变量从调用语法中排除,如下面的两行代码所示:

Decimal d1 = 0, d2 = 2;
Swap(ref d1, ref d2);

这个简化的调用语法依赖一个名为类型推理的 C# 编译器功能,在该功能中,编译器使用传递给方法的参数类型来推导类型变量。在本例中,编译器从 d1 和 d2 的数据类型来推导,类型参数 T 的类型变量应当为 System.Decimal。如果存在多义性,类型推理对于调用方不工作,并且 C# 编译器将会产生一个错误,建议您使用包含尖括号和类型变量的完整调用语法。

间接

我的一个好朋友喜欢指出,大多数完美的编程解决方案都是围绕添加另一间接层次而设计的。指针和引用允许单个函数影响一个数据结构的多个实例。虚拟函数允许单个调用站点将调用传送到一组相似的方法 — 其中一些方法可在以后定义。这两个间接示例非常常见,以至于程序员通常注意不到间接本身。

间接的主要目的是为了提高代码的灵活性。泛型是一种间接形式,在这种形式中,定义不会产生可直接使用的代码。相反,在定义泛型代码中,会创建一个“代码工厂”。随后,引用代码使用该工厂代码来构造可直接使用的代码。

using System;

class App {
public static void Main() {
Boolean result;

// Constructs an instance of CompareHashCodes<T> for Int32
result = CompareHashCodes<Int32>(5, 10);

// Constructs an instance of CompareHashCodes<T> for String
result = CompareHashCodes<String>("Hi there", "Hi there");
}

// Generic method, a "method factory" of sorts
static Boolean CompareHashCodes<T>(T item1, T item2) {
return item1.GetHashCode() == item2.GetHashCode();
}
}

图5

让我们首先从泛型方法来了解这个设计思路。图 5 中的代码定义并引用了一个名为 CompareHashCodes<T> 的泛型方法。定义代码创建了一个名为 CompareHashCodes<T> 的泛型方法,但是图 5 中显示的代码都没有直接调用 CompareHashCodes<T>。相反,在 Main 中,引用代码使用 CompareHashCodes<T> 来构造两种不同的方法:CompareHashCodes<Int32> 和 CompareHashCodes<String>。这些构造方法是 CompareHashCodes<T> 的实例,它们是由引用代码来调用的。

通常会在某个方法的定义中直接定义该方法所执行的操作。与之相反,在泛型方法的定义中,会定义它的构造方法实例将执行的操作。除了充当如何构造特定实例的模型以外,泛型方法本身不执行任何操作。CompareHashCodes<T> 是一种泛型方法,通过它可以构造对哈希代码进行比较的方法实例。 构造实例(如 CompareHashCodes<Int32>)执行实际工作;它对整数的哈希代码进行比较。相反,CompareHashCodes<T> 是一个从可调用中删除的间接层。

泛型类型类似于从与其相对应的简单副本中删除的一个间接层。系统使用简单的类型定义(如类或结构)来创建内存中的对象。例如,类库中的 System.Collection.Stack 类型用于在内存中创建堆栈对象。在某种意义上,可以将 C# 中的新关键字或中间语言代码中的 newobj 指令视为一个对象工厂,该对象工厂在创建对象实例时,将托管类型用作每个对象的蓝图。

另一方面,泛型类型用于实例化封闭式类型,而不是对象实例。随后,可以使用从泛型类型构造的类型来创建对象。让我们回顾一下在 图 2 中定义的 Node<T> 类型以及如 图 3所示的它的引用代码。

托管应用程序永远不能创建 Node<T> 类型的对象,即使它是托管类型时也是如此。这是由于 Node<T> 缺乏足够的定义,因此无法被实例化为内存中的对象。但是,在执行应用程序的过程中,Node<T> 可用于实例化另一个类型。

Node<T> 是一个开放式泛型类型,并且只用于创建其他构造类型。如果使用 Node<T> 创建的构造类型是封闭式类型(如 Node<Int32>),则它可用于创建对象。图 3 中的引用代码使用 Node<Int32> 的方式与使用简单类型时大体相同。它创建 Node<Int32> 类型的对象,在这些对象上调用方法,等等。

泛型类型额外提供一个间接层,此功能非常强大。采用泛型类型的引用代码会产生定制的托管类型。在脑海中将泛型代码想象为从其简单副本中删除的一个间接层,这有助于凭直觉获知 CLR 中泛型的许多行为、规则和用法。

小结

本文介绍了泛型类型的好处 — 如何使用它们改善类型安全、代码重用和性能。本文还讲述了 C# 中的语法以及泛型如何导致另一层间接,从而提高灵活性。

 

 

版权所有:UML软件工程组织