购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.9 泛型

C#有如下两种不同的机制来编写跨类型可复用的代码:继承和泛型。但继承的复用性来自基类,而泛型的复用性是通过带有占位符的模板类型实现的。和继承相比,泛型能够提高类型的安全性,并减少类型转换和装箱。

C#的泛型和C++的模板是相似的概念,但它们的工作方法不同。我们将在3.9.14节讲解。

3.9.1 泛型类型

泛型类型中声明的类型参数(占位符类型)需要由泛型类型的消费者(即提供类型参数的一方)来填充。下面是一个存放类型 T 实例的泛型栈类型 Stack<T> Stack<T> 声明了单个类型参数 T

Stack<T> 的使用方式如下:

Stack<int> 用类型参数 int 填充 T ,这会在运行时隐式创建一个类型 Stack<int> 。若试图将一个字符串加入 Stack<int> 中则会产生一个编译时错误。 Stack<int> 具有如下的定义(为了防止混淆,类的名字将以 # 代替,替换的部分将用粗体展示):

技术上,我们称 Stack<T> 是开放类型,称 Stack<int> 是封闭类型。在运行时,所有的泛型实例都是封闭的,占位符已经被类型填充。这意味着以下语句是非法的:

但是,在类或者方法的内将 T 定义为类型参数是合法的:

3.9.2 为什么需要泛型

泛型是为了代码能够跨类型复用而设计的。假定我们需要一个整数栈,但是没有泛型的支持。那么一种解决方法是为每一个需要的元素类型硬编码不同版本的类(例如 IntStack StringStack 等)。显然,这将导致大量的重复代码。另一个解决方法是写一个用 object 作为元素类型的栈:

但是 ObjectStack 类不会像硬编码的 IntStack 类一样只处理整数元素。而且 ObjectStack 需要用到装箱和向下类型转换,而这些都不能够在编译时进行检查:

我们的栈既需要支持各种不同类型的元素,又需要一种简便的方法将栈的元素类型限定为特定类型,以提高类型安全性,减少类型转换和装箱。泛型恰好将元素类型参数化从而提供了这些功能。 Stack<T> 同时具有 ObjectStack IntStack 的全部优点。它与 ObjectStack 的共同点是 Stack<T> 只需要书写一次就可以支持各种类型,而与 IntStack 的共同点是 Stack<T> 的元素是特定的某个类型。 Stack<T> 的独特之处在于操作的类型是 T ,并且可以在编程时将 T 替换为其他类型。

ObjectStack 在功能上等价于 Stack<object>

3.9.3 泛型方法

泛型方法在方法的签名中声明类型参数。

使用泛型方法,许多基本算法就可以用通用方式实现了。以下是交换两个任意类型 T 的变量值的泛型方法:

Swap<T> 的使用方式如下:

通常调用泛型方法不需要提供类型参数,因为编译器可以隐式推断得到类型信息。如果有二义性,则可以用以下方式调用泛型方法:

在泛型中,只有引入类型参数(用尖括号标出)的方法才可归为泛型方法。泛型 Stack 类中的 Pop 方法仅仅使用了类型中已有的类型参数 T ,因此不属于泛型方法。

只有方法和类可以引入类型参数。属性、索引器、事件、字段、构造器、运算符等都不能声明类型参数,虽然它们可以参与使用所在类型中已经声明的类型参数。例如,在泛型的栈中,我们可以写一个索引器返回一个泛型项:

类似地,构造器可以参与使用已经存在的类型参数,但是不能引入新的类型参数:

3.9.4 声明类型参数

可以在声明类、结构体、接口、委托(参见第4章)和方法时引入类型参数。其他的结构(如属性)虽不能引入类型参数,但可以使用类型参数。例如,以下代码中的属性 Value 使用了类型参数 T

泛型或方法可以有多个参数:

可以用以下方式实例化:

或者:

只要类型参数的数量不同,泛型类型名和泛型方法的名称就可以进行重载。例如,下面的三个类型名称不会冲突:

习惯上,如果泛型类型和泛型方法只有一个类型参数,且参数的含义明确,那么一般将其命名为 T 。当使用多个类型参数时,每一个类型参数都使用 T 作为前缀,后面跟一个更具描述性的名称。

3.9.5 typeof和未绑定泛型类型

在运行时不存在开放的泛型类型,开放泛型类型将在编译过程中封闭。但运行时可能存在未绑定(unbound)的泛型类型,这种泛型类型只作为 Type 对象存在。C#中唯一指定未绑定泛型类型的方式是使用 typeof 运算符:

开放泛型类型一般与反射API(参见第18章)一起使用。

typeof 运算符也可以用于指定封闭的类型:

或一个开放类型(当然,它会在运行时封闭):

3.9.6 泛型的默认值

default 关键字可用于获取泛型类型参数的默认值。引用类型的默认值为 null ,而值类型的默认值是将值类型的所有字段按位设置为 0 的值。

从C# 7.1开始,我们可以在编译器能够进行类型推断的情况下忽略类型参数。因此以上程序最后一行可以写为:

3.9.7 泛型的约束

默认情况下,类型参数可以由任何类型来替换。在类型参数上应用约束,可以将类型参数定义为指定的类型参数。以下列出了可用的约束:

在下面的例子中, GenericClass<T,U> T 要求派生自(或者本身就是) SomeClass 并且实现 Interface1 ,要求 U 提供无参数构造器。

约束可以应用在方法定义或者类型定义——这些可以定义类型参数的地方。

基类约束要求类型参数必须是子类(或者匹配特定的类),接口约束要求类型参数必须实现特定的接口。这些约束要求类型参数的实例可以隐式转换为相应的类和接口。例如,我们可以使用 System 命名空间中的 IComparable<T> 泛型接口实现泛型的 Max 方法,该方法会返回两个值中更大的一个:

CompareTo 方法在 this 大于 other 时返回正值。以此接口为约束,我们可以将 Max 方法写为(为了避免分散注意力,省略了 null 检查):

Max 方法可以接受任何实现了 IComparable<T> 接口的类型参数(大部分内置类型都实现了该接口,例如 int string ):

类约束和结构体约束规定 T 必须是引用类型或值类型(不能为空)。结构体约束的一个很好的例子是 System.Nullable<T> 结构体(请参见4.7节):

非托管类型约束(C# 7.3引入)是一个增强型的结构体约束。其中 T 必须是一个简单的值类型或该值类型中(递归的)不包含任何引用类型字段 [1]

无参数构造器约束要求 T 有一个public无参数构造器。如果定义了这个约束,就可以对类型 T 使用 new() 了:

裸类型约束要求一个类型参数必须从另一个类型参数中派生(或匹配)。在本例中, FilteredStack 方法返回了另一个 Stack ,返回的 Stack 仅包含原来类中的一部分元素,并且类型参数 U 是类型参数 T 的子类:

3.9.8 继承泛型类型

泛型类和非泛型类一样都可以派生子类,并且在泛型类型子类中仍可以令基类中类型参数保持开放,如下所示:

子类也可以用具体的类型来封闭泛型参数:

子类型还可以引入新的类型参数:

技术上,子类型中所有的类型参数都是新的,可以说子类型封闭后又重新开放了基类的类型参数。因此子类可以在重新打开的类型参数上使用更有意义的新名称:

3.9.9 自引用泛型声明

一个类型可以使用自身类型作为具体类型来封闭类型参数:

以下的写法也是合法的:

3.9.10 静态数据

静态数据对于每一个封闭的类型来说都是唯一的:

3.9.11 类型参数和转换

C#的类型转换运算符可以进行多种的类型转换,包括:

· 数值转换。

· 引用转换。

· 装箱/拆箱转换。

· 自定义转换(通过运算符重载,请参见第4章)。

根据已知操作数的类型,在编译时就已经决定了类型转换的方式。但对于泛型类型参数来说,由于编译时操作数的类型还并未确定,上述规则就会出现特殊的情形。如果导致了二义性,那么编译器会产生一个错误。

最常见的场景是在执行引用转换时:

由于不知道 T 的确切类型,编译器会疑惑你是否希望执行自定义转换。上述问题最简单的解决方案就是改用 as 运算符,因为它不能进行自定义类型转换,因此是没有二义性的:

而更一般的做法是先将其转换为 object 类型。这种方法行得通,因为从 object 转换,或将对象转换为 object 都不是自定义转换,而是引用或者装箱/拆箱转换。在下例中, StringBuilder 是引用类型,所以一定是引用转换:

拆箱转换也可能导致二义性。例如,下面的代码可能是拆箱转换、数值转换或者自定义转换:

而解决方案也是先将其转换为 object ,然后再将其转换为 int (很明显,这是一个非二义性的拆箱转换):

3.9.12 协变

假定 A 可以转换为 B ,如果 X<A> 可以转换为 X<B> ,那么称 X 有一个协变类型参数。

由于C#有协变(covariance)和逆变(contravariance)的概念,所以“可转换”意味着可以通过隐式引用转换进行类型转换,例如, A B 的子类或者 A 实现 B 。而数值转换、装箱转换和自定义转换是不包含在内的。

例如, IFoo<T> 类型如果能够满足以下条件,则 IFoo<T> 拥有协变参数 T

接口支持协变类型参数(委托也支持协变类型参数,请参见第4章),但是类是不支持协变类型参数的。数组也支持协变(如果 A 可以隐式引用转换为 B ,则 A[] 也可以隐式引用转换为 B[] )。接下来将对此进行一些讨论和比较。

协变和逆变(或简称可变性)都是高级概念。在C#中引入和强化协变的动机在于允许泛型接口和泛型类型(尤其是.NET中定义的那些类型,例如, IEnumerable<T> )像人们期待的那样工作。即使你不了解它们背后的细节,也可以从中获益。

3.9.12.1 可变性不是自动的

为了保证静态类的安全性,泛型类型参数不是自动可变的。请看下面的例子:

接下来的语句是不能通过编译的:

这种约束避免了以下代码可能产生的运行时错误:

但是协变的缺失可能妨碍复用性。例如在下例中,我们希望写一个 Wash 方法“清洗”整个Animal栈:

将Bear栈传入 Wash 方法会产生编译时错误。一种解决方法是重新定义一个带有约束的 Wash 方法:

这样我们就可以使用如下方式调用 Wash 了:

另一种解决方案是让 Stack<T> 实现一个拥有协变类型参数的泛型接口,后面会举例讲解。

3.9.12.2 数组

出于历史原因,数组类型支持协变。这说明如果 B A 的子类,则 B[] 可以转换为 A[] A B 都是引用类型)。例如:

然而这种复用性也有缺点,数组元素的赋值可能在运行时发生错误:

3.9.12.3 声明协变类型参数

在接口和委托的类型参数上指定 out 修饰符可将其声明为协变参数。和数组不同,这个修饰符保证了协变类型参数是完全类型安全的。

为了阐述这一点,我们假定 Stack<T> 类实现了如下的接口:

T 上的 out 修饰符表明了 T 只用于输出的位置(例如,方法的返回值)。 out 修饰符将类型参数标记为协变参数,并且可以进行如下操作:

bears到animals的转换是由编译器保证的,因为类型参数具有协变性。在这种情况下,若试图将 Camel 实例入栈,则编译器会阻止这种行为。因为 T 只能在输出位置出现,所以不可能将 Camel 类输入接口中。

接口中的协变或逆变都是常见的,在接口中同时支持协变和逆变是很少见的。

特别注意,方法中的 out 参数是不支持协变的,这是CLR的限制。

如前所述,我们可以利用类型转换的协变性解决复用性问题:

第7章讲述的 IEnumerator<T> IEnumerable<T> 接口的 T 都是协变的。这意味着需要时可以将 IEnumerable<string> 转换为 IEnumerable<object>

如果在输入位置(例如,方法的参数或可写属性)使用协变参数,则会发生编译时错误。

不管是类型参数还是数组,协变(和逆变)仅仅对于引用转换有效,而对装箱转换无效。因此,如果编写了一个接受 IPoppable<object> 类型参数的方法,则可以使用 IPoppable<string> 调用它,但不能使用 IPoppable<int>

3.9.13 逆变

通过前面的介绍我们已经知道,假设 A 可以隐式引用转换为 B ,如果 X<A> 允许引用类型转换为 X<B> ,则类型 X 具有协变类型参数。而逆变的转换方向正好相反,即从 X<B> 转换到 X<A> 。它仅在类型参数出现在输入位置上,并用 in 修饰符标记才行得通。以下扩展了之前的例子,假设 Stack<T> 实现了如下的接口:

则以下的语句是合法的:

IPushable 中没有任何成员输出 T ,所以将 animals 转换为 bears 时不会出现问题(但是通过这个接口无法实现 Pop 方法)。

即使 T 含有相反的可变性标记, Stack<T> 类可以同时实现 IPushable<T> IPoppable<T> 。由于只能通过接口而不是类实现可变性,因此在进行可变性转换之前,必须首先选定 IPoppable 或者 IPushable 接口。而选定的接口会限制操作在合适的可变性规则下执行。

这也说明了为什么类不允许接受可变性类型参数:具体实现通常都需要数据进行双向流动。

再看一个例子,以下是 System 命名空间中的一个接口定义:

该接口含有逆变参数 T ,因此我们可以使用 IComparer<object> 来比较两个字符串:

与协变正好相反,如果将逆变的类型参数用在输出位置(例如,返回值或者可读属性)上,则编译器将报告错误。

3.9.14 C#泛型和C++模板对比

C#的泛型和C++的模板在应用上很相似,但是它们的工作原理却大不相同。两者都发生了生产者和消费者的关联,且生产者的占位符将被消费者填充。但是在C#泛型中,生产者的类型(开放类型,如 List<T> )可以编译到程序库中(如 mscorlib.dll )。这是因为生产者和消费者进行关联生成封闭类型是在运行时发生的。而在C++模板中,这一关联是在编译时进行的。这意味着C++不能将模板库部署为 .dll ,它们只存在于源代码中。这令动态语法检查难以实现,更不用说即时创建或参数化类型了。

为了深究这一情形形成的原因,我们重新观察C#的 Max 方法:

为什么我们不能按以下方式实现呢?

原因是, Max 需要在编译时支持所有可能的 T 类型值。由于对于任意类型 T ,运算符 > 没有统一的含义,因此上述程序无法通过编译。实际上,并不是所有的类型都支持 > 运算符。相对地,下面的代码是用C++的模板编写的 Max 方法。该代码会为每一个 T 值分别编译,对特定的 T 呈现不同的 > 语义,而当 T 不支持 > 运算符时,编译失败:


[1] 引用类型也可以是 System.ValueType System.Enum ,参见第6章。

[1] 更加严格的说法是,非托管类型约束指类型参数必须是一个非可空的非托管类型,其中非托管类型指 sbyte byte short ushort int uint long ulong char float double decimal bool ,以及枚举类型、指针类型和任何自定义的结构体(该结构体不包含任何类型参数,仅仅包含非托管类型的字段)。 pDMN50PpVCW56ZtxH8R0H8GKnmgFr+EUzkvrv3EdADWrCXQzmIqM/sdqBG4xd2RS

点击中间区域
呼出菜单
上一章
目录
下一章
×