泛型是 C# 2.0 的最强大的功能。通过泛型可以定义类型安全的数据结构,而无须使用实际的数据类型。这能够显著提高性能并得到更高质量的代码,因为您可以重用数据处理算法,而无须复制类型特定的代码。在概念上,泛型类似于 C++ 模板,但是在实现和功能方面存在明显差异。本文讨论泛型处理的问题空间、它们的实现方式、该编程模型的好处,以及独特的创新(例如,约束、一般方法和委托以及一般继承)。您还将了解在 .NET Framework 的其他领域(例如,反射、数组、集合、序列化和远程处理)中如何利用泛型,以及如何在所提供的基本功能的基础上进行改进。
泛型问题陈述
考虑一种普通的、提供传统 Push() 和 Pop() 方法的数据结构(例如,堆栈)。在开发通用堆栈时,您可能愿意使用它来存储各种类型的实例。在 C# 1.1 下,您必须使用基于 Object 的堆栈,这意味着,在该堆栈中使用的内部数据类型是难以归类的 Object,并且堆栈方法与 Object 交互:
public class Stack { object[] m_Items; public void Push(object item) {...} public object Pop() {...} }
代码块 1 显示基于 Object 的堆栈的完整实现。因为 Object 是规范的 .NET 基类型,所以您可以使用基于 Object 的堆栈来保持任何类型的项(例如,整数):
Stack stack = new Stack(); stack.Push(1); stack.Push(2); int number = (int)stack.Pop();
代码块 1. 基于 Object 的堆栈
public class Stack { readonly int m_Size; int m_StackPointer = 0; object[] m_Items; public Stack():this(100) {} public Stack(int size) { m_Size = size; m_Items = new object[m_Size]; } public void Push(object item) { if(m_StackPointer >= m_Size) throw new StackOverflowException(); m_Items[m_StackPointer] = item; m_StackPointer++; } public object Pop() { m_StackPointer--; if(m_StackPointer >= 0) { return m_Items[m_StackPointer]; } else { m_StackPointer = 0; throw new InvalidOperationException("Cannot pop an empty stack"); } } }
但是,基于 Object 的解决方案存在两个问题。第一个问题是性能。在使用值类型时,必须将它们装箱以便推送和存储它们,并且在将值类型弹出堆栈时将其取消装箱。装箱和取消装箱都会根据它们自己的权限造成重大的性能损失,但是它还会增加托管堆上的压力,导致更多的垃圾收集工作,而这对于性能而言也不太好。即使是在使用引用类型而不是值类型时,仍然存在性能损失,这是因为必须从 Object 向您要与之交互的实际类型进行强制类型转换,从而造成强制类型转换开销:
Stack stack = new Stack(); stack.Push("1"); string number = (string)stack.Pop();
基于 Object 的解决方案的第二个问题(通常更为严重)是类型安全。因为编译器允许在任何类型和 Object 之间进行强制类型转换,所以您将丢失编译时类型安全。例如,以下代码可以正确编译,但是在运行时将引发无效强制类型转换异常:
Stack stack = new Stack(); stack.Push(1); //This compiles, but is not type safe, and will throw an exception: string number = (string)stack.Pop();
您可以通过提供类型特定的(因而是类型安全的)高性能堆栈来克服上述两个问题。对于整型,可以实现并使用 IntStack:
public class IntStack { int[] m_Items; public void Push(int item){...} public int Pop(){...} } IntStack stack = new IntStack(); stack.Push(1); int number = stack.Pop();
对于字符串,可以实现 StringStack:
public class StringStack { string[] m_Items; public void Push(string item){...} public string Pop(){...} } StringStack stack = new StringStack(); stack.Push("1"); string number = stack.Pop();
等等。遗憾的是,以这种方式解决性能和类型安全问题,会引起第三个同样严重的问题 — 影响工作效率。编写类型特定的数据结构是一项乏味的、重复性的且易于出错的任务。在修复该数据结构中的缺陷时,您不能只在一个位置修复该缺陷,而必须在实质上是同一数据结构的类型特定的副本所出现的每个位置进行修复。此外,没有办法预知未知的或尚未定义的将来类型的使用情况,因此还必须保持基于 Object 的数据结构。结果,大多数 C# 1.1 开发人员发现类型特定的数据结构不实用,并且选择使用基于 Object 的数据结构,尽管它们存在缺点。
什么是泛型
通过泛型可以定义类型安全类,而不会损害类型安全、性能或工作效率。您只须一次性地将服务器实现为一般服务器,同时可以用任何类型来声明和使用它。为此,需要使用 < 和 > 括号,以便将一般类型参数括起来。例如,可以按如下方式定义和使用一般堆栈:
public class Stack { T[] m_Items; public void Push(T item) {...} public T Pop() {...} } Stack stack = new Stack(); stack.Push(1); stack.Push(2); int number = stack.Pop();
代码块 2 显示一般堆栈的完整实现。将代码块 1 与代码块 2 进行比较,您会看到,好像 代码块 1 中每个使用 Object 的地方在代码块 2 中都被替换成了 T,除了使用一般类型参数 T 定义 Stack 以外:
public class Stack {...}
在使用一般堆栈时,必须通知编译器使用哪个类型来代替一般类型参数 T(无论是在声明变量时,还是在实例化变量时):
Stack stack = new Stack();
编译器和运行库负责完成其余工作。所有接受或返回 T 的方法(或属性)都将改为使用指定的类型(在上述示例中为整型)。
代码块 2. 一般堆栈
public class Stack { readonly int m_Size; int m_StackPointer = 0; T[] m_Items; public Stack():this(100) {} public Stack(int size) { m_Size = size; m_Items = new T[m_Size]; } public void Push(T item) { if(m_StackPointer >= m_Size) throw new StackOverflowException(); m_Items[m_StackPointer] = item; m_StackPointer++; } public T Pop() { m_StackPointer--; if(m_StackPointer >= 0) { return m_Items[m_StackPointer]; } else { m_StackPointer = 0; throw new InvalidOperationException("Cannot pop an empty stack"); } } }
注 T 是一般类型参数(或类型参数),而一般类型为 Stack。Stack 中的 int 为类型实参。
该编程模型的优点在于,内部算法和数据操作保持不变,而实际数据类型可以基于客户端使用服务器代码的方式进行更改。
泛型实现
表面上,C# 泛型的语法看起来与 C++ 模板类似,但是编译器实现和支持它们的方式存在重要差异。正如您将在后文中看到的那样,这对于泛型的使用方式具有重大意义。
注 在本文中,当提到 C++ 时,指的是传统 C++,而不是带有托管扩展的 Microsoft C++。
与 C++ 模板相比,C# 泛型可以提供增强的安全性,但是在功能方面也受到某种程度的限制。
在一些 C++ 编译器中,在您通过特定类型使用模板类之前,编译器甚至不会编译模板代码。当您确实指定了类型时,编译器会以内联方式插入代码,并且将每个出现一般类型参数的地方替换为指定的类型。此外,每当您使用特定类型时,编译器都会插入特定于该类型的代码,而不管您是否已经在应用程序中的其他某个位置为模板类指定了该类型。C++ 链接器负责解决该问题,并且并不总是有效。这可能会导致代码膨胀,从而增加加载时间和内存足迹。
在 .NET 2.0 中,泛型在 IL(中间语言)和 CLR 本身中具有本机支持。在编译一般 C# 服务器端代码时,编译器会将其编译为 IL,就像其他任何类型一样。但是,IL 只包含实际特定类型的参数或占位符。此外,一般服务器的元数据包含一般信息。
客户端编译器使用该一般元数据来支持类型安全。当客户端提供特定类型而不是一般类型参数时,客户端的编译器将用指定的类型实参来替换服务器元数据中的一般类型参数。这会向客户端的编译器提供类型特定的服务器定义,就好像从未涉及到泛型一样。这样,客户端编译器就可以确保方法参数的正确性,实施类型安全检查,甚至执行类型特定的 IntelliSense。
有趣的问题是,.NET 如何将服务器的一般 IL 编译为机器码。原来,所产生的实际机器码取决于指定的类型是值类型还是引用类型。如果客户端指定值类型,则 JIT 编译器将 IL 中的一般类型参数替换为特定的值类型,并且将其编译为本机代码。但是,JIT 编译器会跟踪它已经生成的类型特定的服务器代码。如果请求 JIT 编译器用它已经编译为机器码的值类型编译一般服务器,则它只是返回对该服务器代码的引用。因为 JIT 编译器在以后的所有场合中都将使用相同的值类型特定的服务器代码,所以不存在代码膨胀问题。
如果客户端指定引用类型,则 JIT 编译器将服务器 IL 中的一般参数替换为 Object,并将其编译为本机代码。在以后的任何针对引用类型而不是一般类型参数的请求中,都将使用该代码。请注意,采用这种方式,JIT 编译器只会重新使用实际代码。实例仍然按照它们离开托管堆的大小分配空间,并且没有强制类型转换。
泛型的好处
.NET 中的泛型使您可以重用代码以及在实现它时付出的努力。类型和内部数据可以在不导致代码膨胀的情况下更改,而不管您使用的是值类型还是引用类型。您可以一次性地开发、测试和部署代码,通过任何类型(包括将来的类型)来重用它,并且全部具有编译器支持和类型安全。因为一般代码不会强行对值类型进行装箱和取消装箱,或者对引用类型进行向下强制类型转换,所以性能得到显著提高。对于值类型,性能通常会提高 200%;对于引用类型,在访问该类型时,可以预期性能最多提高 100%(当然,整个应用程序的性能可能会提高,也可能不会提高)。本文随附的源代码包含一个微型基准应用程序,它在紧密循环中执行堆栈。该应用程序使您可以在基于 Object 的堆栈和一般堆栈上试验值类型和引用类型,以及更改循环迭代的次数以查看泛型对性能产生的影响。
应用泛型
因为 IL 和 CLR 为泛型提供本机支持,所以大多数符合 CLR 的语言都可以利用一般类型。例如,下面这段 Visual Basic .NET 代码使用代码块 2 的一般堆栈:
Dim stack As Stack(Of Integer) stack = new Stack(Of Integer) stack.Push(3) Dim number As Integer number = stack.Pop()
您可以在类和结构中使用泛型。以下是一个有用的一般点结构:
public struct Point { public T X; public T Y; }
可以使用该一般点来表示整数坐标,例如:
Point point; point.X = 1; point.Y = 2;
或者,可以使用它来表示要求浮点精度的图表坐标:
Point point; point.X = 1.2; point.Y = 3.4;
除了到目前为止介绍的基本泛型语法以外,C# 2.0 还具有一些泛型特定的语法。例如,请考虑代码块 2 的 Pop() 方法。假设您不希望在堆栈为空时引发异常,而是希望返回堆栈中存储的类型的默认值。如果您使用基于 Object 的堆栈,则可以简单地返回 null,但是您还可以通过值类型来使用一般堆栈。为了解决该问题,您可以使用 default() 运算符,它返回类型的默认值。
下面说明如何在 Pop() 方法的实现中使用默认值:
public T Pop() { m_StackPointer--; if(m_StackPointer >= 0) { return m_Items[m_StackPointer]; } else { m_StackPointer = 0; return default(T); } }
引用类型的默认值为 null,而值类型(例如,整型、枚举和结构)的默认值为全零(用零填充相应的结构)。因此,如果堆栈是用字符串构建的,则 Pop() 方法在堆栈为空时返回 null;如果堆栈是用整数构建的,则 Pop() 方法在堆栈为空时返回零。
多个一般类型
单个类型可以定义多个一般类型参数。例如,请考虑代码块 3 中显示的一般链表。
代码块 3. 一般链表
class Node { public K Key; public T Item; public Node NextNode; public Node() { Key = default(K); Item = defualt(T); NextNode = null; } public Node(K key,T item,Node nextNode) { Key = key; Item = item; NextNode = nextNode; } } public class LinkedList { Node m_Head; public LinkedList() { m_Head = new Node(); } public void AddHead(K key,T item) { Node newNode = new Node(key,item,m_Head.NextNode); m_Head.NextNode = newNode; } }
该链表存储节点:
class Node {...}
每个节点都包含一个键(属于一般类型参数 K)和一个值(属于一般类型参数 T)。每个节点还具有对该列表中下一个节点的引用。链表本身根据一般类型参数 K 和 T 进行定义:
public class LinkedList {...}
这使该列表可以公开像 AddHead() 一样的一般方法:
public void AddHead(K key,T item);
每当您声明使用泛型的类型的变量时,都必须指定要使用的类型。但是,指定的类型实参本身可以为一般类型参数。例如,该链表具有一个名为 m_Head 的 Node 类型的成员变量,用于引用该列表中的第一个项。m_Head 是使用该列表自己的一般类型参数 K 和 T 声明的。
Node m_Head;
您需要在实例化节点时提供类型实参;同样,您可以使用该链表自己的一般类型参数:
public void AddHead(K key,T item) { Node newNode = new Node<K,T>(key,item,m_Head.NextNode); m_Head.NextNode = newNode; }
请注意,该列表使用与节点相同的名称来表示一般类型参数完全是为了提高可读性;它也可以使用其他名称,例如:
public class LinkedList {...}
或:
public class LinkedList {...}
在这种情况下,将 m_Head 声明为:
Node m_Head;
当客户端使用该链表时,该客户端必须提供类型实参。该客户端可以选择整数作为键,并且选择字符串作为数据项:
LinkedList list = new LinkedList(); list.AddHead(123,"AAA");
但是,该客户端可以选择其他任何组合(例如,时间戳)来表示键:
LinkedList list = new LinkedList(); list.AddHead(DateTime.Now,"AAA");
有时,为特定类型的特殊组合起别名是有用的。可以通过 using 语句完成该操作,如代码块 4 中所示。请注意,别名的作用范围是文件的作用范围,因此您必须按照与使用 using 命名空间相同的方式,在项目文件中反复起别名。
代码块 4. 一般类型别名
using List = LinkedList; class ListClient { static void Main(string[] args) { List list = new List(); list.AddHead(123,"AAA"); } }
一般约束
使用 C# 泛型,编译器会将一般代码编译为 IL,而不管客户端将使用什么样的类型实参。因此,一般代码可以尝试使用与客户端使用的特定类型实参不兼容的一般类型参数的方法、属性或成员。这是不可接受的,因为它相当于缺少类型安全。在 C# 中,您需要通知编译器客户端指定的类型必须遵守哪些约束,以便使它们能够取代一般类型参数而得到使用。存在三个类型的约束。派生约束指示编译器一般类型参数派生自诸如接口或特定基类之类的基类型。默认构造函数约束指示编译器一般类型参数公开了默认的公共构造函数(不带任何参数的公共构造函数)。引用/值类型约束将一般类型参数约束为引用类型或值类型。一般类型可以利用多个约束,您甚至可以在使用一般类型参数时使 IntelliSense 反射这些约束,例如,建议基类型中的方法或成员。
需要注意的是,尽管约束是可选的,但它们在开发一般类型时通常是必不可少的。没有它们,编译器将采取更为保守的类型安全方法,并且只允许在一般类型参数中访问 Object 级别功能。约束是一般类型元数据的一部分,以便客户端编译器也可以利用它们。客户端编译器只允许客户端开发人员使用遵守这些约束的类型,从而实施类型安全。
以下示例将详细说明约束的需要和用法。假设您要向代码块 3 的链表中添加索引功能或按键搜索功能:
public class LinkedList { T Find(K key) {...} public T this[K key] { get{return Find(key);} } }
这使客户端可以编写以下代码:
LinkedList list = new LinkedList(); list.AddHead(123,"AAA"); list.AddHead(456,"BBB"); string item = list[456]; Debug.Assert(item == "BBB");
要实现搜索,您需要扫描列表,将每个节点的键与您要查找的键进行比较,并且返回键匹配的节点的项。问题在于,Find() 的以下实现无法编译:
T Find(K key) { Node current = m_Head; while(current.NextNode != null) { if(current.Key == key) //Will not compile break; else current = current.NextNode; } return current.Item; }
原因在于,编译器将拒绝编译以下行:
if(current.Key == key)
上述行将无法编译,因为编译器不知道 K(或客户端提供的实际类型)是否支持 == 运算符。例如,默认情况下,结构不提供这样的实现。您可以尝试通过使用 IComparable 接口来克服 == 运算符局限性:
public interface IComparable { int CompareTo(object obj); }
如果您与之进行比较的对象等于实现该接口的对象,则 CompareTo() 返回 0;因此,Find() 方法可以按如下方式使用它:
if(current.Key.CompareTo(key) == 0)
遗憾的是,这也无法编译,因为编译器无法知道 K(或客户端提供的实际类型)是否派生自 IComparable。
您可以显式强制转换到 IComparable,以强迫编译器编译比较行,除非这样做需要牺牲类型安全:
if(((IComparable)(current.Key)).CompareTo(key) == 0)
如果客户端使用的类型不是派生自 IComparable,则会导致运行时异常。此外,当所使用的键类型是值类型而非键类型参数时,您可以对该键执行装箱,而这可能具有一些性能方面的影响。
派生约束
在 C# 2.0 中,可以使用 where 保留关键字来定义约束。在一般类型参数中使用 where 关键字,后面跟一个派生冒号,以指示编译器该一般类型参数实现了特定接口。例如,以下为实现 LinkedList 的 Find() 方法所必需的派生约束:
public class LinkedList where K : IComparable { T Find(K key) { Node current = m_Head; while(current.NextNode != null) { if(current.Key.CompareTo(key) == 0) break; else current = current.NextNode; } return current.Item; } //Rest of the implementation }
您还将在您约束的接口的方法上获得 IntelliSense 支持。
当客户端声明一个 LinkedList 类型的变量,以便为列表的键提供类型实参时,客户端编译器将坚持要求键类型派生自 IComparable,否则,将拒绝生成客户端代码。
请注意,即使该约束允许您使用 IComparable,它也不会在所使用的键是值类型(例如,整型)时,消除装箱所带来的性能损失。为了克服该问题,System.Collections.Generic 命名空间定义了一般接口 IComparable:
public interface IComparable { int CompareTo(T other); bool Equals(T other); }
您可以约束键类型参数以支持 IComparable,并且使用键的类型作为类型参数;这样,您不仅获得了类型安全,而且消除了在值类型用作键时的装箱操作:
public class LinkedList where K : IComparable {...}
实际上,所有支持 .NET 1.1 中的 IComparable 的类型都支持 .NET 2.0 中的 IComparable。这使得可以使用常见类型(例如,int、string、GUID、DateTime 等等)的键。
在 C# 2.0 中,所有约束都必须出现在一般类的实际派生列表之后。例如,如果 LinkedList 派生自 IEnumerable 接口(以获得迭代器支持),则需要将 where 关键字放在紧跟它后面的位置:
public class LinkedList : IEnumerable where K : IComparable {...}
通常,只须在需要的级别定义约束。在链表示例中,在节点级别定义 IComparable 派生约束是没有意义的,因为节点本身不会比较键。如果您这样做,则您还必须将该约束放在 LinkedList 级别,即使该列表不比较键。这是因为该列表包含一个节点作为成员变量,从而导致编译器坚持要求:在列表级别定义的键类型必须遵守该节点在一般键类型上放置的约束。
换句话说,如果您按如下方式定义该节点:
class Node where K : IComparable {...}
则您必须在列表级别重复该约束,即使您不提供 Find() 方法或其他任何与此有关的方法:
public class LinkedList where KeyType : IComparable { Node<KeyType,DataType> m_Head; }
您可以在同一个一般类型参数上约束多个接口(彼此用逗号分隔)。例如:
public class LinkedList where K : IComparable,IConvertible {...}
您可以为您的类使用的每个一般类型参数提供约束,例如:
public class LinkedList where K : IComparable where T : ICloneable {...}
您可以具有一个基类约束,这意味着规定一般类型参数派生自特定的基类:
public class MyBaseClass {...} public class LinkedList where K : MyBaseClass {...}
但是,在一个约束中最多只能使用一个基类,这是因为 C# 不支持实现的多重继承。显然,您约束的基类不能是密封类或静态类,并且由编译器实施这一限制。此外,您不能将 System.Delegate 或 System.Array 约束为基类。
您可以同时约束一个基类以及一个或多个接口,但是该基类必须首先出现在派生约束列表中:
public class LinkedList where K : MyBaseClass, IComparable {...}
C# 确实允许您将另一个一般类型参数指定为约束:
public class MyClass where T : U {...}
在处理派生约束时,您可以通过使用基类型本身来满足该约束,而不必非要使用它的严格子类。例如:
public interface IMyInterface {...} public class MyClass where T : IMyInterface {...} MyClass obj = new MyClass();
或者,您甚至可以:
public class MyOtherClass {...} public class MyClass where T : MyOtherClass {...} MyClass obj = new MyClass();
最后,请注意,在提供派生约束时,您约束的基类型(接口或基类)必须与您定义的一般类型参数具有一致的可见性。例如,以下约束是有效的,因为内部类型可以使用公共类型:
public class MyBaseClass {} internal class MySubClass where T : MyBaseClass {} 但是,如果这两个类的可见性被颠倒,例如: internal class MyBaseClass {} public class MySubClass where T : MyBaseClass {}
则编译器会发出错误,因为程序集外部的任何客户端都无法使用一般类型 MySubClass,从而使得 MySubClass 实际上成为内部类型而不是公共类型。外部客户端无法使用 MySubClass 的原因是,要声明 MySubClass 类型的变量,它们需要使用派生自内部类型 MyBaseClass 的类型。
构造函数约束
假设您要在一般类的内部实例化一个新的一般对象。问题在于,C# 编译器不知道客户端将使用的类型实参是否具有匹配的构造函数,因而它将拒绝编译实例化行。
为了解决该问题,C# 允许约束一般类型参数,以使其必须支持公共默认构造函数。这是使用 new() 约束完成的。例如,以下是一种实现代码块 3 中的一般 Node 的默认构造函数的不同方式。
class Node where T : new() { public K Key; public T Item; public Node NextNode; public Node() { Key = default(K); Item = new T(); NextNode = null; } }
可以将构造函数约束与派生约束组合起来,前提是构造函数约束出现在约束列表中的最后:
public class LinkedList where K : IComparable,new() {...}
引用/值类型约束
可以使用 struct 约束将一般类型参数约束为值类型(例如,int、bool 和 enum),或任何自定义结构:
public class MyClass where T : struct {...}
同样,可以使用 class 约束将一般类型参数约束为引用类型(类):
public class MyClass where T : class {...}
不能将引用/值类型约束与基类约束一起使用,因为基类约束涉及到类。同样,不能使用结构和默认构造函数约束,因为默认构造函数约束也涉及到类。虽然您可以使用类和默认构造函数约束,但这样做没有任何价值。可以将引用/值类型约束与接口约束组合起来,前提是引用/值类型约束出现在约束列表的开头。