Essential C# 2.0:第 11 章:泛型






4.84/5 (31投票s)
2006 年 8 月 30 日
37分钟阅读

155552
泛型将极大地改变 C# 1.0 的编码风格。在几乎所有程序员在 C# 1.0 代码中使用 object 的情况中,泛型在 C# 2.0 中都是更好的选择,以至于 object 应该作为可能使用泛型实现的标志。
|
|
引言
随着项目变得越来越复杂,您将需要更好的方法来重用和定制现有软件。为了促进代码重用,尤其是算法的重用,C# 包含了一个名为 **泛型** 的特性。正如方法因可以接受参数而强大一样,接受类型参数的类也具有更丰富的功能,而这正是泛型所实现的。与它们的前辈模板一样,泛型允许一次定义算法和模式实现,而不是为每种类型单独定义。但是,C# 实现了一个类型安全的模板版本,其语法略有不同,实现方式与 C++ 和 Java 中的前辈有很大差异。请注意,泛型是在 2.0 版本中添加到运行时和 C# 中的。
没有泛型的 C#
我将通过检查一个不使用泛型的类来开始讨论泛型。这个类是 System.Collections.Stack
,它的目的是表示对象的集合,使得添加到集合中的最后一个项是第一个检索出的项(称为后进先出,或 LIFO)。Stack
类中的两个主要方法 Push()
和 Pop()
分别用于向堆栈添加项和从中移除项。Listing 11.1 显示了堆栈类上 Pop()
和 Push()
方法的声明。
Listing 11.1:使用数据类型 Object 定义的 Stack
public class Stack
{
public virtual object Pop();
public virtual void Push(object obj);
// ...
}
程序经常使用堆栈类型集合来支持多次撤销操作。例如,Listing 11.2 在一个模拟 Etch A Sketch 游戏的程序中使用 stack
类进行撤销操作。
Listing 11.2:在类似于 Etch A Sketch 游戏的程序中支持撤销
using System;
using System.Collections;
class Program
{
// ...
public void Sketch()
{
Stack path = new Stack();
Cell currentPosition;
ConsoleKeyInfo key; // New with C# 2.0
do
{
// Etch in the direction indicated by the
// arrow keys that the user enters.
key = Move();
switch (key.Key)
{
case ConsoleKey.Z:
// Undo the previous Move.
if (path.Count >= 1)
{
currentPosition = (Cell)path.Pop();
Console.SetCursorPosition(
currentPosition.X, currentPosition.Y);
Undo();
}
break;
case ConsoleKey.DownArrow:
case ConsoleKey.UpArrow:
case ConsoleKey.LeftArrow:
case ConsoleKey.RightArrow:
// SaveState()
currentPosition = new Cell(
Console.CursorLeft, Console.CursorTop);
path.Push(currentPosition);
break;
default:
Console.Beep(); // New with C#2.0
break;
}
}
while (key.Key != ConsoleKey.X); // Use X to quit.
}
}
public struct Cell
{
readonly public int X;
readonly public int Y;
public Cell(int x, int y)
{
X = x;
Y = y;
}
}
Listing 11.2 的结果出现在 Output 11.1 中。
Output 11.1
使用声明为 System.Collections.Stack
的变量 path
,您可以通过 path.Push(currentPosition)
将自定义类型 Cell
传递给 Stack.Push()
方法来保存上一步。如果用户输入 Z
(或 Ctrl+Z
),则通过使用 Pop()
方法从堆栈中检索上一移动,将光标位置设置为上一位置,并调用 Undo()
来撤销上一步。(请注意,此代码还使用了一些 CLR 2.0 特定的控制台函数。)
尽管代码功能正常,但 System.Collections.Stack
类存在一个根本性的缺点。如 Listing 11.1 所示,Stack
类收集类型为 object
的变量。由于 CLR 中的每个对象都派生自 object
,因此 Stack
不提供任何验证,以确保您放入其中的元素是同质的或符合预期的类型。例如,您可以通过将 X 和 Y 连接起来并用小数点分隔来代替传递 currentPosition
,传递一个字符串。但是,编译器必须允许不一致的数据类型,因为在某些情况下,这是可取的。
此外,在使用 Pop()
方法从堆栈检索数据时,必须将返回值强制转换为 Cell
。但如果从 Pop()
方法返回的值不是 Cell
类型对象,则会抛出异常。您可以测试数据类型,但分散这些检查会增加复杂性。创建可以与多种数据类型配合使用但没有泛型的类的根本问题是,它们必须使用通用基类型,通常是 object
数据。
使用值类型(如 struct 或 int)与使用 object
的类会加剧这个问题。例如,如果您将值类型传递给 Stack.Push()
方法,运行时会自动装箱。类似地,检索值类型时,需要显式拆箱数据,并将从 Pop()
方法获得的 object
引用强制转换为值类型。虽然引用类型的扩展操作(强制转换为基类)对性能影响微乎其微,但值类型的装箱操作会引入非微不足道的开销。
要更改 Stack
类以使用前面的 C# 编程构造强制存储特定数据类型,您必须创建一个专门的堆栈类,如 Listing 11.3 所示。
Listing 11.3:定义专用堆栈类
public class CellStack
{
public virtual Cell Pop();
public virtual void Push(Cell cell);
// ...
}
由于 CellStack
只能存储 Cell
类型的对象,因此此解决方案需要自定义堆栈方法的实现,这不太理想。
新手主题
另一个示例:可空值类型
第 2 章介绍了通过在声明值类型变量时使用可空修饰符
?
来声明可以包含null
的变量。C# 仅在 2.0 版本开始支持此功能,因为正确的实现需要泛型。在引入泛型之前,程序员基本上面临两种选择。第一个选择是为每个需要处理 null 值的 `值类型` 声明一个可空 `数据类型`,如 Listing 11.4 所示。
Listing 11.4:声明存储
null
的各种值类型的版本struct NullableInt { public int Value {...} public bool HasValue {...} ... } struct NullableGuid { public Guid Value {...} public bool HasValue {...} ... } ...Listing 11.4 仅显示了
NullableInt
和NullableGuid
的实现。如果程序需要额外的可空值类型,您必须创建一个包含额外值类型的副本。如果可空实现发生更改(例如,如果它支持从 null 到可空类型的转换),您必须将修改添加到所有可空类型声明中。第二个选择是声明一个包含类型为
object
的Value
属性的可空类型,如 Listing 11.5 所示。Listing 11.5:声明包含类型为
object
的Value
属性的可空类型struct Nullable { public object Value {...} public bool HasValue {...} ... }虽然此选项只需要一个可空类型的实现,但在设置
Value
属性时,运行时总是会对值类型进行装箱。此外,调用Nullable.Value
来检索数据将不是强类型的,因此检索值类型将需要强制转换操作,这在运行时可能无效。这两个选项都不是特别有吸引力。为了应对此类困境,C# 2.0 引入了泛型的概念。事实上,可空修饰符
?
在内部使用泛型。
引入泛型类型
泛型提供了一种创建数据结构的方式,这些数据结构在声明变量时专门用于处理特定类型。程序员定义这些 **参数化类型**,以便特定泛型类型的每个变量具有相同的内部算法,但数据类型和方法签名可以根据程序员的偏好而变化。
为了最小化开发人员的学习曲线,C# 设计者选择了与 C++ 类似的模板概念相匹配的语法。因此,在 C# 中,泛型类和结构的语法使用相同的尖括号表示法来标识泛型声明所专门处理的数据类型。
使用泛型类
Listing 11.6 展示了如何指定泛型类使用的实际类型。您通过在实例化和声明表达式中使用尖括号表示法指定 Cell
类型,来指示 path
变量使用 Cell
类型。换句话说,当使用泛型数据类型声明变量(在此例中为 path
)时,C# 要求开发人员标识实际类型。Listing 11.6 展示了一个使用新 Stack
类的示例。
Listing 11.6:使用泛型 Stack
类实现撤销
using System;
using System.Collections.Generic;
class Program
{
// ...
public void Sketch()
{
Stack<Cell> path; // Generic variable declaration
path = new Stack<Cell>(); // Generic object instantiation
Cell currentPosition;
ConsoleKeyInfo key; // New with C# 2.0
do
{
// Etch in the direction indicated by the
// arrow keys entered by the user.
key = Move();
switch (key.Key)
{
case ConsoleKey.Z:
// Undo the previous Move.
if (path.Count >= 1)
{
// No cast required.
currentPosition = path.Pop();
Console.SetCursorPosition(
currentPosition.X, currentPosition.Y);
Undo();
}
break;
case ConsoleKey.DownArrow:
case ConsoleKey.UpArrow:
case ConsoleKey.LeftArrow:
case ConsoleKey.RightArrow:
// SaveState()
currentPosition = new Cell(
Console.CursorLeft, Console.CursorTop);
// Only type Cell allowed in call to Push().
path.Push(currentPosition);
break;
default:
Console.Beep(); // New with C#2.0
break;
}
} while (key.Key != ConsoleKey.X); // Use X to quit.
}
}
Listing 11.6 的结果出现在 Output 11.2 中。
Output 11.2
在 Listing 11.6 中所示的 path
声明中,您声明并创建了一个 System.Collections.Generic.Stack<T>
类的实例,并使用尖括号指定 path
变量使用的数据类型为 Cell
。结果是,添加到 path
和从 path
检索的每个对象都是 Cell
类型。换句话说,您不再需要强制转换 path.Pop()
的返回值,也不需要确保在 Push()
方法中只将 Cell
类型对象添加到 path
。在检查泛型的优点之前,下一节将介绍泛型类定义的语法。
定义简单的泛型类
泛型允许您编写算法和模式,并为不同的数据类型重用代码。Listing 11.7 创建了一个与 Listing 11.6 代码中使用的 System.Collections.Generic.Stack<T>
类类似的泛型 Stack<T>
类。在类声明后,您在尖括号中使用 **类型参数标识符** 或 **类型参数**(在此例中为 T
)。然后,泛型 Stack<T>
的实例在变量声明和实例化时会收集与变量声明对应的类型,此时编译器要求代码指定类型参数。在 Listing 11.7 中,您可以看到类型参数将用于内部的 Items
数组、Push()
方法的参数类型以及 Pop()
方法的返回类型。
Listing 11.7:声明泛型类 Stack<T>
public class Stack<T>
{
private T[] _Items;
public void Push(T data)
{
...
}
public T Pop()
{
...
}
}
泛型的优点
使用泛型类(例如前面使用的 System.Collections.Generic.Stack<T>
类而不是原始的 System.Collections.Stack
类型)有几个优点。
-
泛型支持强类型编程模型,防止使用参数化类成员显式意图之外的其他数据类型。在 Listing 11.7 中,参数化堆栈类将所有
Stack<Cell>
实例限制为Cell
数据类型。(语句path.Push("garbage")
会产生一个编译时错误,指示System.Collections.Generic.Stack<T>.Push(T)
没有可与字符串garbage
配合使用的重载方法,因为无法将其转换为Cell
。) -
编译时类型检查降低了运行时出现
InvalidCastException
类型错误的可能性。 -
将值类型与泛型类成员一起使用不再会导致强制转换为
object
;它们不再需要装箱操作。(例如,path.Pop()
和path.Push()
在添加项时不需要装箱,在移除项时不需要拆箱。) -
泛型减少了代码膨胀。泛型类型保留了特定类版本的优点,而没有开销。(例如,不再需要定义像
CellStack
这样的类。) -
性能提高,因为不再需要从对象强制转换,从而消除了类型检查操作。此外,由于不再需要为值类型进行装箱,性能也得到了提高。
-
泛型减少了内存消耗,因为避免了装箱,所以不再在堆上消耗内存。
-
代码更具可读性,因为减少了强制转换检查,并且需要更少的特定类型实现。
-
通过某种类型的 IntelliSense 协助编码的编辑器可以直接处理泛型类的返回参数。IntelliSense 工作无需强制转换返回数据。
其核心是,泛型提供了编写模式实现并随后在模式出现的地方重用这些实现的能力。模式描述了代码中反复出现的问题,而模板为这些重复模式提供了单一实现。
类型参数命名约定
就像命名方法参数一样,您在命名类型参数时应尽可能详细。此外,为了区分它是一个类型参数,名称应包含“T”前缀。例如,在定义如 EntityCollection<TEntity>
这样的类时,您可以使用类型参数名称“TEntity”。
只有当描述性名称不会增加任何价值时,您才不会使用描述性的类型参数名称。例如,在 Stack<T>
类中使用“T”是恰当的,因为“T”是类型参数的指示已经足够描述性;堆栈适用于任何类型。
在下一节中,您将了解约束。使用约束描述性类型名称是一个好习惯。例如,如果一个类型参数必须实现 IComponent
,则考虑使用类型名称“TComponent”。
泛型接口和结构
C# 2.0 支持在 C# 语言的所有部分中使用泛型,包括接口和结构。语法与类使用的语法相同。要定义带有类型参数的接口,请将类型参数放在尖括号中,如 Listing 11.8 中的 IPair<T>
示例所示。
Listing 11.8:声明泛型接口
interface IPair<T>
{
T First
{
get;
set;
}
T Second
{
get;
set;
}
}
此接口表示成对的相同对象,例如点的坐标、一个人的基因父母或二叉树的节点。对中包含的类型对两项都是相同的。
要实现该接口,您使用的语法与非泛型类相同。但是,实现泛型接口而不标识类型参数会强制该类成为泛型类,如 Listing 11.9 所示。此外,此示例使用结构而不是类,表明 C# 支持自定义泛型值类型。
Listing 11.9:实现泛型接口
public struct Pair<T>: IPair<T>
{
public T First
{
get
{
return _First;
}
set
{
_First = value;
}
}
private T _First;
public T Second
{
get
{
return _Second;
}
set
{
_Second = value;
}
}
private T _Second;
}
对泛型接口的支持对于集合类尤为重要,因为泛型在集合类中最普遍。没有泛型,开发人员依赖 System.Collections
命名空间中的一系列接口。与实现类一样,这些接口仅与类型 object
一起工作,因此,该接口强制所有对这些集合类的访问都需要强制转换。通过使用泛型接口,您可以避免强制转换操作,因为可以通过参数化接口实现更强的编译时绑定。
高级主题
在一个类上多次实现同一个接口
模板接口的一个副作用是,您可以使用不同的类型参数多次实现同一个接口。考虑 Listing 11.10 中的
IContainer<T>
示例。Listing 11.10:在单个类上复制接口实现
public interface IContainer<T> { ICollection<T> Items { get; set; } } public class Person: IContainer<Address>, IContainer<Phone>, IContainer<Email> { ICollection<Address> IContainer<Address>.Items { get{...} set{...} } ICollection<Phone> IContainer<Phone>.Items { get{...} set{...} } ICollection<Email> IContainer<Email>.Items { get{...} set{...} } }在此示例中,
Items
属性出现多次,使用具有不同类型参数的显式接口实现。没有泛型,这是不可能的,取而代之的是,编译器只允许一个显式的IContainer
.Items
属性。对 Person 的一个可能的改进是也实现
IContainer<object>
,并让项返回所有三个容器(Address、Phone 和 Email)的组合。
定义构造函数和析构函数
可能令人惊讶的是,泛型的构造函数和析构函数不需要类型参数来匹配类声明(即,不是 Pair<T>(){...}
)。在 Listing 11.11 的 Pair 示例中,构造函数使用 public Pair(T first, T second)
声明。
Listing 11.11:声明泛型类型的构造函数
public struct Pair<T>: IPair<T>
{
public Pair(T first, T second)
{
_First = first;
_Second = second;
}
public T First
{
get{ return _First; }
set{ _First = value; }
}
private T _First;
public T Second
{
get{ return _Second; }
set{ _Second = value; }
}
private T _Second;
}
指定默认值
Listing 11.1 包含一个构造函数,该构造函数接受 First
和 Second
的初始值,并将它们分配给 _First
和 _Second
。由于 Pair<T>
是一个结构,您提供的任何构造函数都必须初始化所有字段。然而,这会带来问题。考虑 Pair<T>
的一个构造函数,它在实例化时只初始化 Pair 的一半。
定义这样的构造函数(如 Listing 11.12 所示)会导致编译错误,因为构造函数结束时字段 _Second
未初始化。为 _Second
提供初始化存在问题,因为您不知道 T
的数据类型。如果它是引用类型,则 null
可以工作,但这不适用于 T
是值类型(除非它是可空的)。
Listing 11.12:未初始化所有字段,导致编译错误
public struct Pair<T>: IPair<T>
{
// ERROR: Field 'Pair<T>._second' must be fully assigned
// before control leaves the constructor
// public Pair(T first)
// {
// _First = first;
// }
// ...
}
为了处理这种情况,C# 2.0 允许一种动态方式来编码任何数据类型的默认值,使用 default
运算符,该运算符在第 8 章中首次讨论。在第 8 章中,我演示了如何使用 default(int)
指定 int
的默认值,而字符串的默认值使用 default(string)
(它返回 null
,正如所有引用类型一样)。在 T
的情况下,_Second
需要 T
,您使用 default(T)
(参见 Listing 11.13)。
Listing 11.13:使用 default
运算符初始化字段
public struct Pair<T>: IPair<T>
{
public Pair(T first)
{
_First = first;
_Second = default(T);
}
// ...
}
default
运算符在泛型上下文之外是允许的;任何语句都可以使用它。
多个类型参数
泛型类型可以使用任意数量的类型参数。最初的 Pair<T>
示例只有一个类型参数。为了支持存储一对对象,例如名称/值对,您需要将 Pair<T>
扩展为支持两个类型参数,如 Listing 11.14 所示。
Listing 11.14:声明具有多个类型参数的泛型
interface IPair<TFirst, TSecond>
{
TFirst First
{ get; set; }
TSecond Second
{ get; set; }
}
public struct Pair<TFirst, TSecond>: IPair<TFirst, TSecond>
{
public Pair(TFirst first, TSecond second)
{
_First = first;
_Second = second;
}
public TFirst First
{
get{ return _First; }
set{ _First = value; }
}
private TFirst _First;
public TSecond Second
{
get{ return _Second; }
set{ _Second = value; }
}
private TSecond _Second;
}
当您使用 Pair<TFirst,
TSecond>
类时,您在声明和实例化语句的尖括号中提供多个类型参数,然后在调用方法时为方法的参数提供匹配的类型,如 Listing 11.15 所示。
Listing 11.15:使用具有多个类型参数的类型
Pair<int, string> historicalEvent =
new Pair<int, string>(1914,
"Shackleton leaves for South Pole on ship Endurance");
Console.WriteLine("{0}: {1}",
historicalEvent.First, historicalEvent.Second);
类型参数的数量,即 **元数**,唯一地区分了类。因此,可以在同一个命名空间中定义 Pair<T>
和 Pair<TFirst, TSecond>
,因为元数不同。
嵌套泛型类型
嵌套类型将自动继承包含类型的类型参数。例如,如果包含类型包含类型参数 T
,那么类型 T
在嵌套类型中也将可用。如果嵌套类型包含自己的名为 T
的类型参数,那么这将隐藏包含类型中的类型参数,并且对嵌套类型中 T
的任何引用都将指向嵌套的 T
类型参数。幸运的是,在嵌套类型中重用相同的类型参数名称将导致编译器警告,以防止意外重叠(参见 Listing 11.16)。
Listing 11.16:嵌套泛型类型
class Container<T, U>
{
// Nested classes inherit type parameters.
// Reusing a type parameter name will cause
// a warning.
class Nested<U>
{
void Method(T param0, U param1)
{
}
}
}
使容器的类型参数在嵌套类型中可用的行为与嵌套类型的行为一致,其方式是包含类型的私有成员也可以从嵌套类型中访问。规则只是一个类型在其出现的花括号内的任何位置都可用。
具有类型兼容类型参数的泛型类之间的类型兼容性
如果您使用相同的泛型类声明具有不同类型参数的两个变量,则这些变量不是类型兼容的;它们不是 **协变** 的。类型参数区分了相同泛型类但类型参数不同的两个变量。例如,即使类型参数兼容,泛型类 Stack<Contact>
和 Stack<PdaItem>
的实例也不是类型兼容的。换句话说,没有内置支持将 Stack<Contact>
强制转换为 Stack<PdaItem>
,即使 Contact
派生自 PdaItem
(参见 Listing 11.17)。
Listing 11.17:具有不同类型参数的泛型之间的转换
using System.Collections.Generic;
...
// Error: Cannot convert type ...
Stack<PdaItem> exceptions = new Stack<Contact>();
要允许此操作,您需要微妙地强制转换类型参数的每个实例,可能是一个完整的数组或集合,这会隐藏潜在的显着性能成本。
约束
泛型支持定义类型参数约束的能力。这些约束强制类型符合各种规则。例如,考虑 Listing 11.18 中所示的 BinaryTree<T>
类。
Listing 11.18:声明一个不带约束的 BinaryTree<T>
类
public class BinaryTree<T>
{
public BinaryTree ( T item)
{
Item = item;
}
public T Item
{
get{ return _Item; }
set{ _Item = value; }
}
private T _Item;
public Pair<BinaryTree<T>> SubItems
{
get{ return _SubItems; }
set{ _SubItems = value; }
}
private Pair<BinaryTree<T>> _SubItems;
}
(一个有趣的旁注是,BinaryTree<T>
内部使用了 Pair<T>
,这是可能的,因为 Pair<T>
只是另一个类型。)
假设您希望树在将值分配给 SubItems
属性时对 Pair<T>
值进行排序。为了实现排序,SubItems
的 get 访问器使用提供的键的 CompareTo()
方法,如 Listing 11.19 所示。
Listing 11.19:需要类型参数支持接口
public class BinaryTree<T>
{
...
public Pair<BinaryTree<T>> SubItems
{
get{ return _SubItems; }
set
{
IComparable first;
// ERROR: Cannot implicitly convert type...
first = value.First.Item; // Explicit cast required
if (first.CompareTo(value.Second.Item) < 0)
{
// first is less than second.
...
}
else
{
// first and second are the same or
// second is less than first.
...
}
_SubItems = value;
}
}
private Pair<BinaryTree<T>> _SubItems;
}
在编译时,类型参数 T
是泛型的。这样写,编译器假定 T
上唯一可用的成员是继承自基类型 object
的成员,因为每个类型都有 object
作为祖先。(因此,只有像 ToString()
这样的方法才能用于类型参数 T
的键实例。)因此,编译器显示编译错误,因为 CompareTo()
方法未在类型 object
上定义。
您可以将 T
参数强制转换为 IComparable
接口,以便访问 CompareTo()
方法,如 Listing 11.20 所示。
Listing 11.20:需要类型参数支持接口或抛出异常
public class BinaryTree<T>
{
...
public Pair<BinaryTree<T>> SubItems
{
get{ return _SubItems; }
set
{
IComparable first;
first = (IComparable)value.First.Item;
if (first.CompareTo(value.Second.Item) < 0)
{
// first is less than second.
...
}
else
{
// second is less than or equal to first.
...
}
_SubItems = value;
}
}
private Pair<BinaryTree<T>> _SubItems;
}
然而,不幸的是,如果您现在声明一个 BinaryTree
类变量并提供一个不实现 IComparable
接口的类型参数,您将遇到运行时错误——特别是 InvalidCastException
。这会削弱泛型的优势。
语言对比:C++—模板
C# 和 CLR 中的泛型与其他语言中的类似构造有所不同。虽然其他语言提供类似的功能,但 C# 的类型安全性显著更高。C# 泛型是一种语言特性和平台特性,底层的 2.0 运行时在其引擎中对泛型有深度支持。
C++ 模板与 C# 泛型有很大不同,因为 C# 利用了 CIL。C# 泛型被编译成 CIL,导致在执行时仅当值类型使用时才发生特化,而对于引用类型仅发生一次。
C++ 模板不支持的一个独特功能是显式约束。C++ 模板允许您编译一个可能属于也可能不属于类型参数的方法调用。因此,如果成员在类型参数中不存在,则会发生错误,可能带有含糊不清的错误消息,并指向源代码中的意外位置。然而,C++ 实现的优点是运算符(
+
、-
等)可以作用于该类型。C# 不支持在类型参数上调用运算符,因为运算符是静态的——因此它们不能通过接口或基类约束来标识。错误的问题在于它只在 *使用* 模板时发生,而不是在定义它时发生。因为 C# 泛型可以声明约束,编译器可以在定义泛型时防止此类错误,从而更早地识别无效假设。此外,在声明泛型类型变量时,错误将指向变量的声明,而不是泛型实现中成员使用位置。
值得注意的是,Microsoft 对 C++ 的 CLI 支持包括泛型和 C++ 模板,因为它们各自具有独特的特性。
为了避免此异常,而是提供编译时错误,C# 允许您为泛型类中声明的每个类型参数提供可选的 **约束** 列表。约束声明了泛型所需的类型参数特征。您使用 where
关键字声明约束,后跟“参数-需求”对,其中参数必须是泛型类型中定义的参数之一,而需求是限制类或接口的继承、默认构造函数的存在或引用/值类型限制。
接口约束
为了满足排序要求,您需要在 BinaryTree
类中使用 CompareTo()
方法。要最有效地做到这一点,您可以对 T
类型参数施加约束。您需要 T
类型参数实现 IComparable
接口。语法如 Listing 11.21 所示。
Listing 11.21:声明接口约束
public class BinaryTree<T> where T: System.IComparable
{
...
public Pair<BinaryTree<T>> SubItems
{
get{ return _SubItems; }
set
{
IComparable first;
// Notice that the cast can now be eliminated.
first = value.First.Item;
if (first.CompareTo(value.Second.Item) < 0)
{
// first is less than second
...
}
else
{
// second is less than or equal to first.
...
}
_SubItems = value;
}
}
private Pair<BinaryTree<T>> _SubItems;
}
有了 Listing 11.21 中的接口约束添加,编译器将确保您每次使用 BinaryTree
类时都会指定一个实现 IComparable
接口的类型参数。此外,您不再需要显式地将变量强制转换为 IComparable
接口,然后再调用 CompareTo()
方法。即使是为了访问使用显式接口实现的成员,也不需要强制转换,在其他情况下,这些成员在没有强制转换的情况下是隐藏的。为了解析要调用的成员,编译器首先直接检查类成员,然后检查显式接口成员。如果没有任何约束可以解决该参数,则只允许 object
的成员。
如果您尝试使用 System.Text .StringBuilder
作为类型参数来创建 BinaryTree<T>
变量,您将收到编译器错误,因为 StringBuilder
不实现 IComparable
。错误类似于 Output 11.3 中所示。
Output 11.3
error CS0309: The type 'System.Text.StringBuilder>' must be convertible
to 'System.IComparable' in order to use it
as parameter 'T' in the generic type or method 'BinaryTree<T>'
要指定约束的接口,您声明一个 **接口约束**。此约束甚至可以绕过调用显式接口成员实现时需要强制转换的需要。
基类约束
有时您可能希望将构造类型限制为特定的类派生。您可以通过 **基类约束** 来实现此目的,如 Listing 11.22 所示。
Listing 11.22:声明基类约束
public class EntityDictionary<TKey, TValue>
: System.Collections.Generic.Dictionary<TKey, TValue>
where TValue : EntityBase
{
...
}
与单独的 System.Collections.Generic.Dictionary<TKey, TValue>
相比,EntityDictionary<TKey, TValue>
要求所有 TValue
类型都派生自 EntityBase
类。通过要求派生,可以在泛型实现中始终执行强制转换操作,因为约束将确保所有类型参数都派生自基类,因此,所有与 EntityDictionary
一起使用的 TValue
类型参数都可以隐式转换为基类。
基类约束的语法与接口约束的语法相同,只是当指定多个约束时,基类约束必须先出现。但是,与接口约束不同,不允许使用多个基类约束,因为不可能从多个类派生。同样,基类约束不能为密封类或特定结构指定。例如,C# 不允许类型参数的约束派生自 string
或 System.Nullable<T>
。
struct/class
约束
另一个有价值的泛型约束是能够将类型参数限制为值类型或引用类型。编译器不允许将 System.ValueType
指定为约束中的基类。相反,C# 提供了适用于引用类型的特殊语法。您不必指定 T
必须从中派生的类,而只需使用关键字 struct
或 class
,如 Listing 11.23 所示。
Listing 11.23:将类型参数指定为值类型
public struct Nullable<T> :
IFormattable, IComparable,
IComparable<Nullable<T>>, INullable
where T : struct
{
// ...
}
由于基类约束需要特定的基类,因此使用 struct
或 class
与基类约束将毫无意义,事实上可能会导致冲突的约束。因此,您不能将 struct
和 class
约束与基类约束一起使用。
struct
约束有一个特殊的特性。它将可能的类型参数限制为仅值类型,同时阻止 System.Nullable<T>
类型参数。为什么?没有这个最后的限制,就有可能定义无意义的类型 Nullable<Nullable<T>>
,这是无意义的,因为 Nullable<T>
本身允许支持 null 的值类型变量,所以可空-可空类型变得毫无意义。由于可空运算符(?
)是声明可空值类型的 C# 快捷方式,因此 struct
约束提供的 Nullable<T>
限制也阻止了以下代码。
int?? number // Equivalent to Nullable<Nullable<int> if allowed
多个约束
对于任何给定的类型参数,您可以指定任意数量的接口作为约束,但不能超过一个类,就像一个类可以实现任意数量的接口但只能继承自另一个类一样。每个新约束都在泛型类型和冒号后面的逗号分隔列表中声明。如果存在多个类型参数,则每个类型参数都必须以 where
关键字开头。在 Listing 11.24 中,EntityDictionary
类包含两个类型参数:TKey
和 TValue
。TKey
类型参数有两个接口约束,TValue
类型参数有一个基类约束。
Listing 11.24:指定多个约束
public class EntityDictionary<TKey, TValue>
: Dictionary<TKey, TValue>
where TKey : IComparable, IFormattable
where TValue : EntityBase
{
...
}
在这种情况下,对 TKey
本身有多个约束,并且对 TValue
还有一个附加约束。在为一种类型参数指定多个约束时,假定存在 AND 关系。例如,TKey
必须实现 IComparable
和 IFormattable
。请注意,每个 where
子句之间没有逗号。
构造函数约束
在某些情况下,在泛型类内部创建类型参数的实例是有益的。在 Listing 11.25 中,EntityDictionary<TKey, TValue>
类的 New()
方法必须创建类型参数 TValue
的实例。
Listing 11.25:要求默认构造函数约束
public class EntityBase<TKey>
{
public TKey Key
{
get{ return _Key; }
set{ _Key = value; }
}
private TKey _Key;
}
public class EntityDictionary<TKey, TValue> :
Dictionary<TKey, TValue>
where TKey: IComparable, IFormattable
where TValue : EntityBase<TKey>, new()
{
// ...
public TValue New(TKey key)
{
TValue newEntity = new TValue();
newEntity.Key = key;
Add(newEntity.Key, newEntity);
return newEntity;
}
// ...
}
由于并非所有对象都保证具有公共默认构造函数,因此编译器不允许您调用类型参数的默认构造函数。要覆盖此编译器限制,请在指定所有其他约束后添加文本 new()
。此文本是一个 **构造函数约束**,它强制带有构造函数约束的类型参数具有默认构造函数。只有默认构造函数约束可用。您不能为带参数的构造函数指定约束。
约束继承
约束由派生类继承,但必须在派生类上显式指定。考虑 Listing 11.26。
Listing 11.26:显式指定的继承约束
class EntityBase<T> where T : IComparable
{
}
// ERROR:
// The type 'T' must be convertible to 'System.IComparable'
// in order to use it as parameter 'T' in the generic type or
// method.
// class Entity<T> : EntityBase<T>
// {
// }
由于 EntityBase
要求 T
实现 IComparable
,因此 Entity
类需要显式包含相同的约束。否则将导致编译错误。这增加了程序员对派生类中约束的认识,避免了在使用派生类时发现约束但又不理解其来源的困惑。
高级主题
约束限制
约束受到适当限制,以避免无意义的代码。例如,您不能将基类约束与
struct
或class
约束结合使用,也不能将Nullable<T>
用于结构约束类型参数。此外,您不能指定约束来限制继承到特殊类型,如object
、数组、System.ValueType
、System.Enum
(enum
)、System.Delegate
和System.MulticastDelegate
。在某些情况下,约束限制可能更可取,但它们仍然不受支持。以下子节提供了一些不允许的约束示例。
不允许运算符约束
约束的另一个限制是,您不能指定类支持某个方法或运算符的约束,除非该方法或运算符在接口上。因此,Listing 11.27 中的泛型
Add()
无效。Listing 11.27:约束表达式不能要求运算符
public abstract class MathEx<T> { public static T Add(T first, T second) { // Error: Operator '+' cannot be applied to // operands of type 'T' and 'T'.< return first + second; } }在这种情况下,该方法假定所有类型都可用
+
运算符。但是,由于所有类型仅支持object
的方法(不包括+
运算符),因此会发生错误。不幸的是,没有办法在约束中指定+
运算符;因此,创建像这样的 add 方法要麻烦得多。此限制的一个原因是无法将类型约束为具有静态方法。例如,您不能在接口上指定静态方法。不支持 OR 条件
如果您为类型参数提供了多个接口或类约束,编译器始终假定约束之间存在 AND 关系。例如,
where TKey : IComparable, IFormattable
要求同时支持IComparable
和IFormattable
。没有办法在约束之间指定 OR 关系。因此,Listing 11.28 的等效项不受支持。Listing 11.28:不允许使用 OR 关系组合约束
public class BinaryTree<T> // Error: OR is not supported. where T: System.IComparable || System.IFormattable<T> { ... }支持此功能将阻止编译器在编译时解析要调用哪个方法。
类型为 Delegate 和 Enum 的约束无效
熟悉 C# 1.0 并阅读本章以了解 2.0 特性的读者将熟悉委托的概念,该概念在第 13 章中进行了介绍。另一个不允许的约束是使用任何委托类型作为类约束。例如,编译器将为 Listing 11.29 中的类声明输出错误。
Listing 11.29:继承约束不能是类型
System.Delegate
// Error: Constraint cannot be special class 'System.Delegate' public class Publisher<T> where T : System.Delegate { public event T Event; public void Publish() { if (Event != null) { Event(this, new EventArgs()); } } }所有委托类型都被视为特殊类,不能指定为类型参数。这样做将阻止对
Event()
调用的编译时验证,因为使用数据类型System.Delegate
和System.MulticastDelegate
时,事件触发的签名是未知的。对于任何枚举类型,都存在相同的限制。仅允许默认构造函数的构造函数约束
Listing 11.25 包含一个构造函数约束,它强制
TValue
支持默认构造函数。没有约束可以强制TValue
支持默认构造函数以外的构造函数。例如,不能使EntityBase.Key
受保护,只能在接受TKey
参数的TValue
构造函数中设置它,而仅使用约束。Listing 11.30 演示了无效代码。Listing 11.30:构造函数约束只能为默认构造函数指定
public TValue New(TKey key) { // Error: 'TValue': Cannot provide arguments // when creating an instance of a variable type. TValue newEntity = null; // newEntity = new TValue(key); Add(newEntity.Key, newEntity); return newEntity; }规避此限制的一种方法是提供一个包含实例化类型的成员的工厂接口。工厂实现接口,负责实例化实体,而不是
EntityDictionary
本身(参见 Listing 11.31)。Listing 11.31:使用工厂接口代替构造函数约束
public class EntityBase<TKey> { public EntityBase(TKey key) { Key = key; } public TKey Key { get { return _key; } set { _key = value; } } private TKey _key; } public class EntityDictionary<TKey, TValue, TFactory> : Dictionary<TKey, TValue> where TKey : IComparable, IFormattable where TValue : EntityBase<TKey> where TFactory : IEntityFactory<TKey, TValue>, new() { ... public TValue New(TKey key) { TValue newEntity = new TFactory().CreateNew(key); Add(newEntity.Key, newEntity); return newEntity; } ... } public interface IEntityFactory<TKey, TValue> { TValue CreateNew(TKey key); } ...这样的声明允许您将新的
key
传递给接受参数而不是默认构造函数的TValue
构造函数。它不再使用TValue
上的构造函数约束,因为TFactory
负责实例化订单而不是EntityDictionary<...>
。(Listing 11.31 中的代码的一个修改是保存工厂的副本。这将使您能够重用工厂,而不是每次都重新实例化它。)类型为
EntityDictionary<TKey, TValue, TFactory>
的变量的声明将导致类似 Listing 11.32 中Order
实体声明的实体声明。Listing 11.32:声明要在
EntityDictionary<...>
中使用的实体public class Order : EntityBase<Guid> { public Order(Guid key) : base(key) { // ... } } public class OrderFactory : IEntityFactory<Guid, Order> { public Order CreateNew(Guid key) { return new Order(key); } }
泛型方法
您已经了解到,当类是泛型时,向类添加泛型方法相对简单。您已经在前面的泛型类示例中做到了这一点,对于静态方法也同样适用。此外,您可以在泛型类中使用泛型类,正如您在前面的 BinaryTree
列表中通过以下代码行所做的那样。
public Pair< BinaryTree<T> > SubItems;
泛型方法是使用泛型的方法,即使包含类不是泛型类,或者方法包含的类型参数不包含在泛型类的类型参数列表中。要定义泛型方法,请在方法名后立即添加类型参数语法,如 Listing 11.33 中的 MathEx.Max<T>
和 MathEx.Min<T>
示例所示。
Listing 11.33:定义泛型方法
public static class MathEx
{
public static T Max<T>(T first, params T[] values)
where T : IComparable
{
T maximum = first;
foreach (T item in values)
{
if (item.CompareTo(maximum) > 0)
{
maximum = item;
}
}
return maximum;
}
public static T Min<T>(T first, params T[] values)
where T : IComparable
{
T minimum = first;
foreach (T item in values)
{
if (item.CompareTo(minimum) < 0)
{
minimum = item;
}
}
return minimum;
}
}
当方法需要一个不包含在类类型参数列表中的附加类型参数时,您在泛型类上使用相同的语法。在此示例中,该方法是静态的,但 C# 不要求这样。
请注意,泛型方法与类一样,可以包含多个类型参数。元数(类型参数的数量)是方法签名的附加区分特征。
类型推断
调用 Min<T>
和 Max<T>
方法的代码如 Listing 11.34 所示。
Listing 11.34:显式指定类型参数
Console.WriteLine( MathEx.Max<int>(7, 490));
Console.WriteLine( MathEx.Min<string>("R.O.U.S.", "Fireswamp"));
Listing 11.34 的输出出现在 Output 11.4 中。
Output 11.4
490 Fireswamp
毫不奇怪,类型参数 int
和 string
分别对应于泛型方法调用中使用的实际类型。但是,指定类型是多余的,因为编译器可以从传递给方法的参数中推断出类型。为了避免冗余,您可以从调用中省略类型参数。这称为 **类型推断**,Listing 11.35 提供了一个示例。此列表的输出出现在 Output 11.5 中。
Listing 11.35:推断类型参数
Console.WriteLine(MathEx.Max(7, 490));
Console.WriteLine(MathEx.Min("R.O.U.S'", "Fireswamp"));
Output 11.5
490Fireswamp
要使类型推断成功,类型必须与方法签名匹配。例如,使用 MathEx.Max(7.0, 490)
调用 Max<T>
方法会导致编译错误。您可以通过显式强制转换或包含类型参数来解决该错误。另请注意,您不能仅基于返回类型执行类型推断。允许类型推断需要参数。
指定约束
泛型方法还允许指定约束。例如,您可以将类型参数限制为实现 IComparable
。约束立即跟在方法头之后,在方法块的大括号之前指定,如 Listing 11.36 所示。
Listing 11.36:指定泛型方法上的约束
public class ConsoleTreeControl
{
// Generic method Show<T>
public static void Show<T>(BinaryTree<T> tree, int indent)
where T : IComparable
{
Console.WriteLine("\n{0}{1}",
"+ --".PadLeft(5*indent, ' '),
tree.Item.ToString());
if (tree.SubItems.First != null)
Show(tree.SubItems.First, indent+1);
if (tree.SubItems.Second != null)
Show(tree.SubItems.Second, indent+1);
}
}
请注意,Show<T>
实现本身不使用 IComparable
接口。但是,请回想一下 BinaryTree<T>
类确实需要这个(参见 Listing 11.37)。
Listing 11.37:BinaryTree<T>
要求 IComparable
类型参数
public class BinaryTree<T>
where T: System.IComparable
{
...
}
由于 BinaryTree<T>
类对此 T
约束有要求,并且 Show<T>
使用 BinaryTree<T>
,因此 Show<T>
也需要提供约束。
高级主题
在泛型方法中强制转换
有时您应该警惕使用泛型——例如,当专门用它来隐藏强制转换操作时。考虑以下将流转换为对象的方法。
public static T Deserialize<T>( Stream stream, IFormatter formatter) { return (T)formatter.Deserialize(stream); }
formatter
负责从流中删除数据并将其转换为对象。formatter 上的Deserialize()
调用返回类型为object
的数据。调用使用泛型版本的Deserialize()
看起来如下:string greeting = Deserialization.Deserialize<string>(stream, formatter);此代码的问题在于,对于方法的使用者而言,
Deserialize<T>()
看起来是强类型的。但是,仍然会隐式执行强制转换操作,而不是显式执行,如非泛型等效代码所示:string greeting = (string)Deserialization.Deserialize(stream, formatter);使用显式强制转换的方法比使用隐藏强制转换的泛型版本更明确地说明了正在发生的事情。开发人员在使用泛型方法强制转换时应小心,如果没有约束来验证强制转换的有效性。
泛型内部
鉴于之前章节中关于 CLI 类型系统中对象普遍性的讨论,泛型也是对象也就不足为奇了。事实上,泛型类上的类型参数成为运行时用于在需要时构建适当类的元数据。因此,泛型支持继承、多态和封装。使用泛型,您可以定义方法、属性、字段、类、接口和委托。
为了实现这一点,泛型需要底层运行时的支持。因此,将泛型添加到 C# 语言是编译器和平台的一个特性。例如,为了避免装箱,泛型实现对于值类型参数与引用类型参数的泛型不同。
高级主题
泛型的 CIL 表示
编译泛型类时,它与常规类没有区别。编译结果只是元数据和 CIL。CIL 被参数化,以便在代码中的某个位置接受用户提供的类型。假设您有一个简单的
Stack
类,声明如 Listing 11.38 所示。Listing 11.38:
Stack<T>
声明public class Stack<T> where T : IComparable { T[] items; // rest of the class here }编译类时,生成的 CIL 被参数化,看起来像 Listing 11.39。
Listing 11.39:
Stack<T>
的 CIL 代码.class private auto ansi beforefieldinit Stack'1<([mscorlib]System.IComparable)T> extends [mscorlib]System.Object { ... }第一个值得注意的项目是第二行
Stack
后面的'1
。该数字是元数。它声明了泛型类将包含的参数类型的数量。像EntityDictionary<TKey, TValue>
这样的声明将具有 2 的元数。此外,生成的 CIL 的第二行显示了施加在类上的约束。
T
类型参数用IComparable
接口声明进行装饰。如果继续查看 CIL,您会发现类型为
T
的项数组声明已更改为包含使用“感叹号表示法”的类型参数,这是 CIL 的泛型版本新加入的。感叹号表示类指定的第一个类型参数的存在,如 Listing 11.40 所示。Listing 11.40:CIL 使用“感叹号表示法”支持泛型
.class public auto ansi beforefieldinit 'Stack'1'<([mscorlib]System.IComparable) T> extends [mscorlib]System.Object { .field private !0[ ] items ... }除了在类头中包含元数和类型参数,以及在代码中使用感叹号表示的类型参数外,为泛型类生成的 CIL 与为非泛型类生成的 CIL 之间几乎没有区别。
基于值类型的泛型实例化
当泛型类型第一次使用值类型作为类型参数构造时,运行时会创建一个特化的泛型类型,并将提供的类型参数适当地放置在 CIL 中。因此,运行时为每个新的参数值类型创建新的特化泛型类型。
例如,假设某些代码声明了一个整数堆栈,如 Listing 11.41 所示。
Listing 11.41:Stack<int>
定义
Stack<int> stack;
当首次使用此类型 Stack<int>
时,运行时会生成 Stack
类的特化版本,并将 int
替换为其类型参数。此后,每当代码使用 Stack<int>
时,运行时都会重用已生成的特化 Stack<int>
类。在 Listing 11.42 中,您声明了两个 Stack<int>
类型的实例,它们都使用了运行时为 Stack<int>
已经生成的代码。
Listing 11.42:声明 Stack<T>
类型的变量
Stack<int> stackOne = new Stack<int>();
Stack<int> stackTwo = new Stack<int>();
如果稍后在代码中,您创建了另一个使用不同值类型作为其类型参数的堆栈(例如,long
或用户定义的 struct
),运行时将生成泛型类型的另一个版本。特化值类型类的优点是性能更好。此外,代码能够避免转换和装箱,因为每个特化泛型类都“原生”包含值类型。
基于引用类型的泛型实例化
对于引用类型,泛型的工作方式略有不同。当泛型类型第一次使用引用类型进行构造时,运行时会创建一个特化的泛型类型,并将 object
引用替换为 CIL 中的类型参数,而不是基于类型参数的特化泛型类型。之后每次使用构造类型实例化引用类型参数时,运行时都会重用之前生成的泛型类型的版本,即使引用类型与第一个引用类型不同。
例如,假设您有两种引用类型,一个 Customer
类和一个 Order
类,并且您创建了一个 Customer
类型的 EntityDictionary
,如下所示:
EntityDictionary<Guid, Customer> customers;
在访问此类之前,运行时会生成 EntityDictionary
类的特化版本,该版本不存储 Customer
作为指定的数据类型,而是存储 object
引用。假设下一行代码创建了另一个称为 Order
的引用类型的 EntityDictionary
:
EntityDictionary<Guid, Order> orders =
new EntityDictionary<Guid, Order>();
与值类型不同,对于使用 Order
类型的 EntityDictionary
来说,不会创建新的特化版本的 EntityDictionary
类。相反,将实例化一个使用 object
引用的 EntityDictionary
版本的实例,并将 orders
变量设置为引用它。
语言对比:Java—泛型
Sun 为 Java 实现的泛型完全在编译器内部实现,而不是在 Java 虚拟机 (JVM) 中。Sun 这样做是为了确保不需要分发更新的 Java 虚拟机,因为使用了泛型。
Java 实现使用与 C++ 中的模板和 C# 中的泛型类似的语法,包括类型参数和约束。但是,由于它不区分值类型和引用类型,未修改的 Java 虚拟机无法支持值类型的泛型。因此,Java 中的泛型无法获得 C# 那样的执行效率。事实上,每当 Java 编译器需要返回数据时,它就会注入从指定的约束(如果已声明)或基类型
Object
(如果未声明)自动向下转换。此外,Java 编译器在编译时生成一个特化的类型,然后使用它来实例化任何构造类型。最后,由于 Java 虚拟机本身不支持泛型,因此在运行时无法确定泛型类型的实例的类型参数,并且其他反射的使用受到严重限制。
为了仍然获得类型安全的优势,对于替换类型参数的每个对象引用,都会为 Order
类型专门分配内存区域,并将指针设置为该内存引用。假设您接着遇到了一个实例化 Customer
类型 EntityDictionary
的代码行,如下所示:
customers = new EntityDictionary<Guid, Customer>();
与之前使用 Order
类型创建的 EntityDictionary
类一样,将实例化另一个特化的 EntityDictionary
类(基于 object
引用的那个)的实例,并将其中包含的指针设置为引用 Customer
类型。这种泛型实现通过将引用类型泛型类的特化类减少到一个,大大减少了代码膨胀。
即使运行时使用相同的内部泛型类型定义,当泛型引用类型的类型参数发生变化时,如果类型参数是值类型,则此行为将被覆盖。例如,Dictionary<int, Customer>
、Dictionary<Guid, Order>
和 Dictionary<long, Order>
将需要新的内部类型定义。
摘要
泛型将极大地改变 C# 1.0 的编码风格。在几乎所有程序员在 C# 1.0 代码中使用 object
的情况中,泛型在 C# 2.0 中都是更好的选择,以至于 object
应该作为可能使用泛型实现的标志。提高的类型安全性、避免强制转换和减少代码膨胀提供了显着改进。类似地,在代码传统上使用 System.Collections
命名空间的情况下,应选择 System.Collections.Generics
。
下一章将介绍最普遍的泛型命名空间之一,即 System.Collections.Generic
。该命名空间几乎完全由泛型类型组成。它提供了清晰的示例,说明了一些最初使用对象的类型后来如何转换为使用泛型。
© Pearson Education 版权所有。保留所有权利。