思考并回答以下问题:
- C#提供了5种泛型:类、结构、接口、委托和方法。怎么理解?
- 泛型让数据类型不用硬编码怎么理解?
- 什么是构造类型?
- 什么是约束?
本章涵盖:
- 什么是泛型
- C#中的泛型
- 泛型类
- 声明泛型类
- 创建构造类型
- 创建变量和实例
- 类型参数的约束
- 泛型方法
- 扩展方法和泛型类
- 泛型结构
- 泛型委托
- 泛型接口
什么是泛型
使用已经学习的语言结构,我们已经可以建立多种不同类型的强大对象。大部分情况下是声明类,然后封装需要的行为,最后创建这些类的实例。
到现在为止,所有在类声明中用到的类型都是特定的类型——或许是程序员定义的,或许是语言或BCL定义的。然而,很多时候,如果我们可以把类的行为提取或重构出来,使之不仅能应用到它们编码的数据类型上,而且还能应用到其他类型上的话,类就会更有用。
有了泛型就可以做到这一点了。我们可以重构代码并且额外增加一个抽象层,对于这样的代码来说,数据类型就不用硬编码了。这是专门为多段代码在不同的数据类型上执行相同指令的情况专门设计的。
**一个栈的示例**假设我们首先创建了如下的代码,它声明了一个叫做MyIntStack的类,该类实现了一个int类型的栈。它允许我们把int压入栈中,以及把它们弹出。顺便说一下,这不是系统定义的栈。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class MyIntStack // int类型的栈
{
int StackPointer = 0;
int[] StackArray; // int类型的数组
public void Push(int x) // 输入类型:int
{
...
}
public int Pop() // 返回类型:int
{
...
}
}
假设现在希望将相同的功能应用于float类型的值,可以有几种方式来实现,一种方式是按照下面的步骤产生后续的代码。
- 剪切并粘贴MyIntStack类的代码。
- 把类名改为MyFloatStack。
- 把整个类声明中相应的int声明改为float声明。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class MyFloatStack // float类型的栈
{
int StackPointer = 0;
float[] StackArray; // float类型的数组
public void Push(float x) // 输入类型:float
{
...
}
public float Pop() // 返回类型:float
{
...
}
}
这个方法当然可行,但是很容易出错而且有如下缺点。
- 我们需要仔细检查类的每一个部分来看哪些类型的声明需要修改,哪些类型的声明需要保留。
- 每次需要新类型(long、double、string等)的栈类时,我们都需要重复这个过程。
- 在这些过程后,我们有了很多几乎具有相同代码的副本,占据了额外的空间。
- 调试和维护这些相似的实现不但复杂而且容易出错。
C#中的泛型
泛型(generic)特性提供了一种更优雅的方式,可以让多个类型共享一组代码。泛型允许我们声明类型参数化(type parameterized)的代码,可以用不同的类型进行实例化。也就是说,我们可以用“类型占位符”来写代码,然后在创建类的实例时指明真实的类型。
我们应该很清楚类型不是对象而是对象的模板这个概念了。同样地,泛型类型也不是类型,而是类型的模板。图1演示了这点。
图1 泛型类型是类型的模板
C#提供了5种泛型:类、结构、接口、委托和方法。注意,前面4个是类型,而方法是成员。图2演示了泛型类型如何用于其他类型。
**继续栈的示例**图2 泛型和用户自定义类型
在栈的示例中,MyIntStack和MyFloatStack两个类的主体声明都差不多,只不过在处理由栈保存的类型时有点不同。
- 在MyIntStack中,这些位置使用int类型占据。
- 在MyFloatStack中,这些位置被float占据。
通过如下步骤我们可以从MyIntStack创建一个泛型类。
- (1)在MyIntStack类定义中,使用类型占位符T而不是float来替换int。
- (2)修改类名称为MyStack。
- (3)在类名后放置\
。
结果就是如下的泛型类声明。由尖括号和T构成的字符串表明T是类型的占位符。(也不一定是字母T,它可以是任何标识符。)在类声明的主体中,每一个T都会被编译器替换为实际类型。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class MyStack<T>
{
int StackPointer = 0;
T[] StackArray;
public void Push(T x)
{
...
}
public T Pop()
{
...
}
}
泛型类
既然已经见过了泛型类,让我们再来详细了解一下它,并看看如何创建和使用它。
创建和使用常规的、非泛型的类有两个步骤:声明类和创建类的实例。但是泛型类不是实际的类,而是类的模板,所以我们必须先从它们构建实际的类类型,然后创建这个构建后的类类型的实例。
图3从一个较高的层面上演示了这个过程。
图3 从泛型类创建实例
- 在某些类型上使用占位符来声明一个类。
- 为占位符提供真实类型。这样就有了真实类的定义,填补了所有的“空缺”。该类型称为构造类型(constructed type)。
- 创建构造类型的实例。
声明泛型类
声明一个简单的泛型类和声明普通类差不多,区别如下。
- 在类名之后放置一组尖括号。
- 在尖括号中用逗号分隔的占位符字符串来表示希望提供的类型。这叫做类型参数(type parameter)。
- 在泛型类声明的主体中使用类型参数来表示应该替代的类型。
例如,如下代码声明了一个叫做SomeClass的泛型类。类型参数列在尖括号中,然后当作真实类型在声明的主体中使用。1
2
3
4
5
6
7
8// T1和T2是类型参数
class SomeClass<T1, T2>
{
// 通常在这些位置使用类型
// ↓ ↓
public T1 SomeVar = new T1();
public T2 OtherVar = new T2();
}
在泛型类型声明中并没有特殊的关键字。取而代之的是尖括号中的类型参数列表,它可以区分泛型类与普通类的声明。
创建构造类型
一旦创建了泛型类型,我们就需要告诉编译器能使用哪些真实类型来替代占位符(类型参数)。编译器获取这些真实类型并创建构造类型(用来创建真实类对象的模板)。
创建构造类型的语法如下,包括列出类名并在尖括号中提供真实类型来替代类型参数。要替代类型参数的真实类型叫做类型实参(type argument )。1
2// 类型实参
SomeClass<short, int>
编译器接受了类型实参并且替换泛型类主体中的相应类型参数,产生了构造类型——从它创建真实类型的实例。
图4左边演示了SomeClass泛型类型的声明。右边演示了使用类型实参short和int来创建构造类。
图4 为泛型类的所有类型参数提供类型实参,允许编译器产生一个可以用来创建真实类对象的构造类
图5演示了类型参数和类型实参的区别。
- 泛型类声明上的类型参数用做类型的占位符。
- 在创建构造类型时提供的真实类型是类型实参。
图5 类型参数与类型实参
创建变量和实例
在创建引用和实例方面,构造类类型的使用和常规类型差不多。例如,如下代码演示了两个类对象的创建。1
2
3
4
5
6MyNonGenClass myNGC = new MyNonGenClass();
// 构造类
// ↓
SomeClass<short, int> mySc1 = new SomeClass<short, int>();
var mysc2 = new SomeClass<short, int>();
- 第一行显示了普通非泛型类型对象的创建。这应该是我们目前非常熟悉的形式。
- 第二行代码显示了SomeClass泛型类型对象的创建,使用short和int类型进行实例化。这种形式和上面一行差不多,只不过把普通类型名改为构造类形式。
- 第三行和第二行的语法一样,没有在等号两边都列出构造类型,而是使用var关键字让编译器使用类型引用。
和非泛型类一样,引用和实例可以分开创建,如图6所示。从图中还可以看出,内存中出现的情况与非泛型类是一样的。
图6 使用构造类型来创建引用和实例
- 泛型类声明下面的第一行在栈上为myInst分配了一个引用,值是null。
- 第二行在堆上分配实例,并且把引用赋值给变量。
可以从同一个泛型类型构建出很多不同的类类型。每一个都有独立的类类型,就好像它们都有独立的非泛型类声明一样。例如,下面的代码演示了从SomeClass泛型类创建两个类型。图7演示了代码。
- 一个类型使用short和int构造。
- 另一个类型使用int和long构造。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 泛型类
class SomeClass< T1, T2 >
{
...
}
class Program
{
static void Main()
{
var first = new SomeClass<short, int >(); // 构造的类型
var second = new SomeClass<int, long>(); // 构造的类型
...
}
}
图7 从泛型类构建的两个构造类
使用泛型的栈的示例
如下代码给出了一个使用泛型来实现栈的示例。Main方法定义了两个变量,stackInt和stackString。使用int和string作为类型实参来创建这两个构造类型。1
2
这段代码产生了如下的输出:1
2
比较泛型和非泛型栈
表1总结了原始非泛型版本的栈与最终泛型版本的栈之间的区别。图8演示了其中的一些区别。
表1 非泛型栈和泛型栈之间的区别
源代码大小 | 更大:我们需要为每一种类型编写-个新的实现 | 更小:不管构造类型的数量有多少,我们只需要一个实现 |
可执行大小 | 无论每一个版本的栈是否会被使用,都会在编译的版本中出现 | 可执行文件中只会出现有构造类型的类型 |
写的难易度 | 易于书写,因为它更具体 | 比较难写,因为它更抽象 |
维护的难易度 | 更容易出问题,因为所有修改需要应用到每一个可用的类型上 | 易于维护,因为只需要修改一个地方 |
图8 非泛型栈和泛型栈
类型参数的约束
在泛型栈的示例中,栈除了保存和弹出它包含的一些项之外没有做任何事情。它不会尝试添加、比较或做其他任何需要用到项本身的运算符的事情。理由还是很恰当的,由于泛型栈不会知道它们保存的项的类型是什么,它不会知道这些类型实现的成员。
然而,所有的C#对象最终都从object类继承,因此,栈可以确认的是,这些保存的项都实现了object类的成员。它们包括ToString、Equals以及GetType。除了这些,它不知道还有哪些成员可用。
只要我们的代码不访问它处理的一些类型的对象(或者只要它始终是object类型的成员),泛型类就可以处理任何类型。符合约束的类型参数叫做未绑定的类型参数(unbounded type parameter),然而,如果代码尝试使用其他成员,编译器会产生一个错误消息。
例如,如下代码声明了一个叫做Simple的类,它有一个叫做LessThan的方法,接受了两个泛型类型的变量。LessThan尝试用小于运算符返回结果。但是由于不是所有的类都实现了小于运算符,也就不能用任何类来代替T,所以编译器会产生一个错误消息。1
2
3
4
5
6
7
8class Simple<T>
{
static public bool LessThan(T i1, T i2)
{
return i1 < i2; // 错误
}
...
}
要让泛型变得更有用,我们需要提供额外的信息让编译器知道参数可以接受哪些类型。这些额外的信息叫做约束(constrain),只有符合约束的类型才能替代给定的类型参数,来产生构造类型。
Where子句
约束使用where子句列出。
- 每一个有约束的类型参数有自己的where子句。
- 如果形参有多个约束,它们在where子句中使用逗号分隔。
where子句的语法如下:1
2
3
4
5// 类型参数 约束列表
// ↓ ↓
where TypeParam : constraint, constraint, ....
// ↑
// 关键字
有关where子句的要点如下。
- 它们在类型参数列表的关闭尖括号之后列出。
- 它们不使用逗号或其他符号分隔。
- 它们可以以任何次序列出。
- where是上下文关键字,所以可以在其他上下文中使用。
例如,如下泛型类有3个类型参数。T1是未绑定的,对于T2,只有Customer类型或从Customer继承的类型的类才能用作类型实参,而对于T3,只有实现IComparable接口的类才能用于类型实参。1
2
3
4
5
6
7// 未绑定 具有约束 没有分隔符
// ↓ ↓ ↓ ↓
class MyClass <T1, T2, T3> where T2 : Customer // T2的约束
where T3 : IComparable // T3的约束
{
...
}
约束类型和次序
共有5种类型的约束,如表2所示。
表2 约束类型
类名 | 只有这个类型的类或从它继承的类才能用作类型实参 |
class | 任何引用类型,包括类、数组、委托和接口都可以用作类型实参 |
struct | 任何值类型都可以用作类型实参 |
接口名 | 只有这个接口或实现这个接口的类型才能用作类型实参 |
new() | 任何带有无参公共构造函数的类型都可以用作类型实参。这叫做构造函数约束 |
where子句可以以任何次序列出。然而,where子句中的约束必须有特定的顺序,如图9所示。
- 最多只能有一个主约束,如果有则必须放在第一位。
- 可以有任意多的接口名约束。
- 如果存在构造函数约束,则必须放在最后。
图9 如果类型参数有多个约束,它们必须遵照这个顺序
如下声明给出了一个where子句的示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14class SortedList<S> where S : IComparable<S>
{
...
}
class LinkedList<M, N> where M : IComparable<M> where N : ICloneable
{
...
}
class MyDictionary<KeyType, ValueType> where KeyType : IEnumerable, new()
{
...
}
泛型方法
与其他泛型不一样,方法是成员,不是类型。泛型方法可以在泛型和非泛型类以及结构和接口中声明,如图10所示。
图10 泛型方法可以声明在泛型类型和非泛型类型中
声明泛型方法
泛型方法具有类型参数列表和可选的约束。
- 泛型方法有两个参数列表。
- 封闭在圆括号内的方法参数列表。
- 封闭在尖括号内的类型参数列表。
- 要声明泛型方法,需要:
- 在方法名称之后和方法参数列表之前放置类型参数列表;
- 在方法参数列表后放置可选的约束子句。
1 | // 类型参数列表 约束子句 |
说明:记住,类型参数列表在方法名称后,在方法参数列表之前。
调用泛型方法
要调用泛型方法,应该在方法调用时提供类型实参,如下所示:1
2
3
4// 类型参数
// ↓
MyMethod<short, int>();
MyMethod<int, iong >();
图11演示了一个叫做DoStuff的泛型方法的声明,它接受两个类型参数。下面是使用不同参数类型的两次方法调用。每个都有不同的类型参数。编译器使用每个构造实例产生方法的不同版本。如下图中右边所示。
**推断类型**图11 有两个实例的泛型方法
如果我们为方法传入参数,编译器有时可以从方法参数中推断出泛型方法的类型形参中用到的那些类型。这样就可以使方法调用更简单,可读性更强。
例如,下面的代码声明了MyMethod,它接受了一个与类型参数同类型的方法参数。1
2
3
4
5
6// 两个都是T类型
// ↓ ↓
public void MyMethod<T> (T myVal)
{
...
}
如下代码所示,如果我们使用int类型的变量调用MyMethod,方法调用中的类型参数的信息就多余了,因为编译器可以从方法参数中得知它是int。1
2
3
4int myInt = 5;
MyMethod <int> (myInt);
// ↑ ↑
// 两个都是int
由于编译器可以从方法参数中推断类型参数,我们可以省略类型参数和调用中的尖括号,如下所示。1
MyMethod(myInt);
泛型方法的示例
如下的代码在一个叫做Simple的非泛型类中声明了一个叫做ReverseAndPrint的泛型方法。这个方法把任意类型的数组作为其参数。Main声明了3个不同的数组类型,然后使用每一个数组调用方法两次。第一次使用特定数组调用了方法,并显式使用类型参数,而第二次让编译器推断类型。1
2
扩展方法和泛型类
在第7章中,我们详细介绍了扩展方法,它也可以和泛型类结合使用。它允许我们将类中的静态方法关联到不同的泛型类上,还允许我们像调用类构造实例的实例方法一样来调用方法。
和非泛型类一样,泛型类的扩展方法:
- 必须声明为static;
- 必须是静态类的成员;
- 第一个参数类型中必须有关键字this,后面是扩展的泛型类的名字。
如下代码给出了一个叫做Print的扩展方法,扩展了叫做Holder1
2
泛型结构
与泛型类相似,泛型结构可以有类型参数和约束。泛型结构的规则和条件与泛型类是一样的。
例如,下面代码声明了一个叫做PieceofData的泛型结构,它保存和获取一块数据,其中的类型在构建类型时定义。Main创建了两个构造类型的对象——一个使用int,而另外一个使用string。1
2
泛型委托
泛型委托和非泛型委托非常相似,不过类型参数决定了能接受什么样的方法。
要声明泛型委托,在委托名称后、委托参数列表之前的尖括号中放置类型参数列表。
1
2
3类型参数
delegate R MyDelegatecT, R>( I value );
返回类型 委托形参注意,在这里有两个参数列表:委托形参列表和类型参数列表。
- 类型参数的范围包括:
- 返回值;
- 形参列表;
- 约束子句。
如下代码给出了一个泛型委托的示例。在Main中,泛型委托MyDelegate使用string类型的实参实例化,并且使用Printstring方法初始化。1
2
CH的LINQ特性在很多地方使用了泛型委托,但在我介绍LINQ之前,有必要给出另外一个示例。第19章会介绍LINQ以及更多有关其泛型委托的内容。
如下代码声明了一个叫做Func的委托,它接受带有两个形参和一个返回值的方法。方法返回的类型被标识为TR,方法参数类型被标识为T1和T2。1
2
泛型接口
泛型接口允许我们编写参数和接口成员返回类型是泛型类型参数的接口。泛型接口的声明和非泛型接口的声明差不多,但是需要在接口名称之后的尖括号中放置类型参数。
例如,如下代码声明了叫做IMyIfc的泛型接口。
- 泛型类Simple实现了泛型接口。
- Main实例化了泛型类的两个对象,一个是int类型,另外一个是string类型。
1 |
使用泛型接口的示例
如下示例演示了泛型接口的两个额外的能力:
- 与其他泛型相似,实现不同类型参数的泛型接口是不同的接口;
- 我们可以在非泛型类型中实现泛型接口。
例如,下面的代码与前面的示例相似,但在这里, Simple是实现泛型接口的非泛型类。其实,它实现了两个IMyIfc的实例。一个实例使用int类型实例化,而另一个使用string类型实例化。1
2
泛型接口的实现必须唯一
实现泛型类型接口时,必须保证类型实参组合不会在类型中产生两个重复的接口。
例如,在下面的代码中, Simple类使用了两个IMyIfc接口的实例化。
- 第一个是构造类型,使用类型int进行实例化。
- 第二个有一个类型参数但不是实参。
对于泛型接口,使用两个相同接口本身并没有错,问题在于这么做会产生一个潜在的冲突,因为如果把int作为类型参数来替代第二个接口中的S的话,Simple可能会有两个相同类型的接口,这是不允许的。1
2
说明 泛型接口的名字不会和非泛型冲突。例如,在前面的代码中我们还可以声明一个名称为ImyIfc的非泛型接口。