泛型
泛型可以让类、结构、接口、委托和方法按它们存储和操作的数据的类型进行参数化。使用过 Eiffel 或 Ada 泛型的用户或 C++ 模板的用户很快就能熟悉 C# 泛型,而且这些用户会发现 C# 泛型较之过去这些语言更加简便易用。
19.1.1 为什么要使用泛型?
如果没有泛型,通用数据结构可通过使用类型 object 实现任何数据类型的存储。例如,下面是一个简单的 Stack 类,它将数据存储在一个 object 数组中,它的两个方法 Push 和 Pop 分别使用 object接受和返回数据:
public class Stack
{
object[] items;
int count;
public void Push(object item) {...}
public object Pop() {...}
}
虽然使用类型 object 使得 Stack 类非常灵活,但这种方式仍存在某些缺陷。例如,我们可以将任何类型的值(例如一个 Customer 实例)推入堆栈。但是,当从堆栈中检索某个值时,必须将 Pop 方法的结果显式强制转换回相应的类型,这样的代码编写起来颇为繁琐,而且在运行时执行的类型检查会造成额外的开销从而影响性能:
Stack stack = new Stack();
stack.Push(new Customer());
Customer c = (Customer)stack.Pop();
再比如,当我们将一个值类型(例如 int)的值传递给 Push 方法时,则该值将自动被装箱。当以后检索该 int 时,必须使用显式类型强制转换将其取消装箱:
Stack stack = new Stack();
stack.Push(3);
int i = (int)stack.Pop();
这样的装箱和取消装箱操作由于涉及动态内存分配和运行时类型检查而额外增加了性能开销。
上述 Stack 类还有一个潜在的问题就是我们无法对放到堆栈上的数据的种类施加限制。实际上,可能会发生这种情况:将一个 Customer 实例推入堆栈,而在检索到该实例之后却意外地将它强制转换为错误的类型:
Stack stack = new Stack();
stack.Push(new Customer());
string s = (string)stack.Pop();
虽然上面的代码错误使用了 Stack 类,但是从技术角度讲该代码可以视作是正确的,编译器不会报告编译时错误。这个问题在该代码被执行之前不会暴露出来,但在执行该代码时会引发 InvalidCastException。
显然,如果能够指定元素类型,Stack 类将能够从中受益。有了泛型,我们便可以做到这一点。
19.1.2 创建和使用泛型
泛型提供了一种新的创建类型的机制,使用泛型创建的类型将带有类型形参 (type parameter)。下面的示例声明一个带有类型形参 T 的泛型 Stack 类。类型形参在 < 和 > 分隔符中指定并放置在类名后。Stack<T> 的实例的类型由创建时所指定的类型确定,该实例将存储该类型的数据而不进行数据类型转换。这有别于同 object 之间的相互转换。类型形参 T 只起占位符的作用,直到在使用时为其指定了实际类型。注意,这里的 T 用作内部项数组的元素类型、传递给 Push 方法的参数类型和 Pop 方法的返回类型:
public class Stack<T>
{
T[] items;
int count;
public void Push(T item) {...}
public T Pop() {...}
}
在使用泛型类 Stack<T> 时,将指定用于替换 T 的实际类型。在下面的示例中,指定了 int 作为 T 的类型实参 (type argument):
Stack<int> stack = new Stack<int>();
stack.Push(3);
int x = stack.Pop();
Stack<int> 类型称为构造类型 (constructed type)。在 Stack<int> 类型中,出现的每个 T 都被替换为类型实参 int。在创建 Stack<int> 的实例后,items 数组的本机存储是 int[] 而不是 object[]。无疑,这比非泛型的 Stack提供了更高的存储效率。同样,Stack<int> 的 Push 和 Pop 方法所操作的也是 int 类型的值。如果将其他类型的值推入堆栈则产生编译时错误。而且在检索值时也不再需要将它们显式强制转换为原始类型。
泛型提供了强类型机制,这意味着如果将一个 int 值推入 Customer 对象的堆栈将导致错误。正如 Stack<int> 仅限于操作 int 值一样,Stack<Customer> 仅限于操作 Customer 对象,编译器将对下面示例中的最后两行报告错误:
Stack<Customer> stack = new Stack<Customer>();
stack.Push(new Customer());
Customer c = stack.Pop();
stack.Push(3); // Type mismatch error
int x = stack.Pop(); // Type mismatch error
泛型类型声明可以含有任意数目的类型形参。上面的 Stack<T> 示例只有一个类型形参,而一个泛型 Dictionary 类可能具有两个类型形参,一个用于键的类型,一个用于值的类型:
public class Dictionary<K,V>
{
public void Add(K key, V value) {...}
public V this[K key] {...}
}
在使用上述 Dictionary<K,V> 时,必须提供两个类型实参:
Dictionary<string,Customer> dict = new Dictionary<string,Customer>();
dict.Add("Peter", new Customer());
Customer c = dict["Peter"];
19.1.3 泛型类型实例化
与非泛型类型类似,泛型类型的编译表示形式也是中间语言 (IL) 指令和元数据。当然,泛型类型的表示形式还要对类型形参的存在和使用进行编码。
在应用程序第一次创建构造泛型类型(例如 Stack<int>)的实例时,.NET 公共语言运行库的实时 (JIT) 编译器将泛型 IL 和元数据转换为本机代码,并在该过程中将类型形参替换为实际类型。然后,对该构造泛型类型的后续引用将使用相同的本机代码。从泛型类型创建特定构造类型的过程称为泛型类型实例化 (generic type instantiation)。
对于值类型,.NET 公共语言运行库为每次泛型类型实例化单独创建专用的本机代码副本。而对于所有的引用类型,则共享该本机代码的单个副本(因为在本机代码级别,引用不过是具有相同表示形式的
指针)。
19.1.4 约束
通常,泛型类的作用并不仅仅是根据类型形参存储数据。泛型类常常需要调用对象上的方法,对象的类型由类型形参给出。例如,Dictionary<K,V> 类中的 Add 方法可能需要使用 CompareTo 方法对键进行比较:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...} // Error, no CompareTo method
...
}
}
由于为 K 指定的类型实参可以是任何类型,对于 key 参数,能够假设存在的成员仅限于类型 object 声明的成员,例如 Equals、GetHashCode 和 ToString;因此上面的示例将发生编译时错误。当然,我们可以将 key 参数强制转换为含有 CompareTo 方法的类型。例如,可以将 key 参数强制转换为 IComparable:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (((IComparable)key).CompareTo(x) < 0) {...}
...
}
}
虽然这种解决方案可行,但它需要在运行时动态检查类型,因此会增加开销。另外它还将错误报告推迟到运行时,当键未实现 IComparable 时引发 InvalidCastException。
为了提供更强的编译时类型检查并减少类型强制转换,C# 允许为每个类型形参提供一个可选的约束 (constraint) 列表。类型形参约束指定了一个要求,类型必须满足该要求才能用作该类型形参的实参。约束使用单词 where 进行声明,后跟一个类型形参和一个冒号,再跟着一个逗号分隔的列表,列表项可以是类类型、接口类型甚或类型形参(还可以是特殊引用类型、值类型和构造函数约束)。
为了让 Dictionary<K,V> 类能够确保键总是实现 IComparable,可在类声明中为类型形参 K 指定一个约束,如下所示:
public class Dictionary<K,V> where K: IComparable
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
有了这个声明后,编译器将确保为 K 提供的任何类型实参均为一个实现了 IComparable 的类型。不仅如此,此约束声明还避免了在调用 CompareTo 方法之前显式将键参数强制转换为 IComparable;满足此类型形参约束的类型的所有成员都可作为该类型形参类型的值直接使用。
对于一个给定的类型形参,作为约束的接口和类型形参的数目不受限制,但只能有一个类。每个受约束的类型形参具有单独的 where 子句。在下面的示例中,类型形参 K 具有两个接口约束,而类型形参 E 具有一个类类型约束和一个构造函数约束:
public class EntityTable<K,E>
where K: IComparable<K>, IPersistable
where E: Entity, new()
{
public void Add(K key, E entity)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
在上面示例中,构造函数约束 new() 确保用作 E 的类型实参的类型具有无参数的公共构造函数,这样,泛型类便可以使用 new E() 创建该类型的实例。
需指出的是,类型形参约束应该谨慎使用。虽然它们提供更强的编译时类型检查,并在某些情况下改进了性能,但是它们也使泛型类型的使用受到限制。例如,泛型类 List<T> 可能约束 T 必须实现 IComparable,以便 List 的 Sort 方法能够对项进行比较。但是,即使在某些情形下 Sort 方法根本没有被调用,也会由于上述约束的存在而导致未实现 IComparable 的类型无法使用 List<T>。
19.1.5 泛型方法
在有些情况下,并不是整个类都需要某个类型形参,而是仅在某个特定方法中需要。通常,在创建采用泛型类型作为形参的方法时会遇到这种情况。例如,在使用上述 Stack<T> 类时,常见的使用模式可能是使用一行代码将多个值推入堆栈,我们可以很方便地编写一个方法在单个调用中完成此任务。对于特定的构造类型,例如 Stack<int>,该方法如下所示:
void PushMultiple(Stack<int> stack, params int[] values) {
foreach (int value in values) stack.Push(value);
}
然后,可以使用此方法将多个 int 值推入 Stack<int>:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);
但是,上面的方法仅适用于特定的构造类型 Stack<int>。为了将其用于任何 Stack<T>,必须将该方法编写为一个泛型方法 (generic method)。泛型方法在方法名后面的 < 和 > 分隔符中指定了一个或多个类型形参。这些类型形参可以在形参列表、返回类型和方法体内使用。泛型 PushMultiple 方法如下
所示:
void PushMultiple<T>(Stack<T> stack, params T[] values) {
foreach (T value in values) stack.Push(value);
}
使用此泛型方法,可以将多个项推入任何 Stack<T>。在调用泛型方法时,类型实参在方法调用中的尖括号中给出。例如:
Stack<int> stack = new Stack<int>();
PushMultiple<int>(stack, 1, 2, 3, 4);
此泛型 PushMultiple 方法较之前一个版本更具可重用性,因为它适用于任何 Stack<T>。但由于必须以类型实参的方式为该方法提供所需的 T,使其调用起来似乎没有前一版本方便。但是在许多情况下,编译器能够使用称为“类型推断”(type inferencing) 的过程,从传递给方法的其他实参推断出正确的类型实参。在上面的示例中,由于第一个标准实参是 Stack<int> 类型,后续的几个实参为 int 类型,编译器能够推断出类型形参一定是 int。这样,可以在不指定类型形参的情况下调用泛型 PushMultiple 方法:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);