本章的主要内容如下:
● 泛型概述
● 创建泛型类
● 泛型类的特性
● 泛型接口
● 泛型结构
● 泛型方法
目录
1.1 泛型概述
泛型是C#和.NET的一个重要概念。泛型不仅是C#编程语言的一部分,而且与程序集中的L(Intermediate Languag,中间语言)代码紧密地集成。有了泛型,就可以创建独立于被包含类型的类和方法。我们不必给不同的类型编写功能相同的许多方法或类,只创建一个方法或类即可。
另一个减少代码的选项是使用Object类,但使用派生自Object类的类型进行传递不是类型安全的。泛型类使用泛型类型,并可以根据需要用特定的类型替换泛型类型。这就保证了类型安全性:如果某个类型不支持泛类,编译器就会出现错误。
泛型不仅限于类,本章还将介绍用于接口和方法的泛型。用于委托的泛型参见第8章。
泛型不仅存在于C#中,其他语言中有类似的概念。例如,C+模板就与泛型相似。但是,C+模板和NET泛型之间有一个很大的区别。对于C+模板,在用特定的类型实例化模板时,需要模板的源代码。C十+编译器为每个属于特定模板实例的类型生成单独的二进制代码。相反,泛型不仅是C#语言的一种结构,而且是CLR(公共语言运行库定义的。所以,即使泛型类是在C#中定义的,也可以在Visual Basic中用一个特定的类型实例化该泛型。
下面介绍泛型的优点和缺点,尤其是:
● 性能
● 类型安全性
● 二进制代码重用
● 代码的扩展
● 命名约定
1.1.1 性能
泛型的一个主要优点是性能。第10章介绍了System.Collections和System.Collections. Generic命名空间的泛型和非泛型集合类。对值类型使用非泛型集合类,在把值类型转换为引用类型,和把引用类型转换为值类型时,需要进行装箱和拆箱操作。
下面的例子显示了System.Collections命名空间中的ArrayList类。ArrayList存储对象, Add()方法定义为需要把一个对象作为参数,所以要装箱一个整数类型。在读取ArrayList中的值时,要进行拆箱,把对象转换为整数类型。可以使用类型转换运算符把ArrayList集合的第一个元素赋予变量i1,在访问int类型的变量i2的foreach语句中,也要使用类型转换运算符:
装箱和拆箱操作很容易使用,但性能损失比较大,迭代许多项时尤其如此。
System.Collections.Generic命名空间中的List<T>类不使用对象,而是在使用时定义类型。在下面的例子中,List<T>类的泛型类型定义为int,所以int类型在JIT编译器动态生成的类中使用,不再进行装箱和拆箱操作。
1.1.2 类型安全
泛型的另一个特性是类型安全。与ArrayList类一样,如果使用对象,可以在这个集合中添加任意类型。下面的例子在ArrayList类型的集合中添加一个整数、一个字符串和一个MyClass类型的对象:
如果这个集合使用下面的foreach语句迭代,而该foreach语句使用整数元素来迭代,编译器就会编译这段代码。但并不是集合中的所有元素都可以转换为int,所以会出现一个运行异常:
错误应尽早发现。在泛型类List<T>中,泛型类型T定义了允许使用的类型。有了List<int>的定义,就只能把整数类型添加到集合中。编译器不会编译这段代码,因为Add()方法的参数无效:
1.1.3 二进制代码的重用
泛型允许更好地重用二进制代码。泛型类可以定义一次,用许多不同的类型实例化。不需要像C++模板那样访问源代码。
例如,System.Collections.Generic命名空间中的List<T>类用一个int、一个字符串和一个MyClass类型实例化:
泛型类型可以在一种语言中定义,在另一种.NET语言中使用。
1.1.4 代码的扩展
在用不同的类型实例化泛型时,会创建多少代码?
因为泛型类的定义会放在程序集中,所以用某个类型实例化泛型类不会在IL代码中复制这些类。但是,在JIT编译器把泛型类编译为内部码时,会给每个值类型创建一个新类。引用类型共享同一个内部类的所有实现代码。这是因为引用类型在实例化的泛型类中只需要4字节的内存单元(32位系统),就可以引用一个引用类型。值类型包含在实例化的泛型类的内存中。而每个值类型对内存的要求都不同,所以要为每个值类型实例化一个新类。
1.1.5 命名约定
如果在程序中使用泛型,区分泛型类型和非泛型类型会有一定的帮助。下面是泛型类型的命名规则:
● 泛型类型的名称用字母T作为前缀。
● 如果没有特殊的要求,泛型类型允许用任意类替代,且只使用了一个泛型类型,就可以用字符T作为泛型类型的名称。
● 如果泛型类型有特定的要求(例如必须实现一个接口或派生于基类),或者使用了两个或多个泛型类型,就应给泛型类型使用描述性的名称:、
1.2 创建泛型类
首先介绍一个一般的、非泛型的简化链表类,它可以包含任意类型的对象,以后再把这个类转化为泛型类。
在链表中,一个元素引用其后的下一个元素。所以必须创建一个类,将对象封装在链表中,引用下一个对象。类LinkedListNode包含一个对象value,它用构造函数初始化,还可以用Value属性读取。另外,LinkedListNode类包含对链表中下一个元素和上一个元素的引用,这些元素都可以从属性中访问。
LinkedList类包含LinkedListNode类型的first和last字段,它们分别标记了链表的头尾。AddLast()方法在链表尾添加一个新元素。首先创建一个LinkedListNode类型的对象。如果链表是空的,则first和last字段就设置为该新元素;否则,就把新元素添加为链表中的最后一个元素。执行GetEnumerator()方法时,可以用foreach语句迭代链表。GetEnumerator()方法使用yield语句创建一个枚举器类型。
现在可以给任意类型使用LinkedList类了。在下面的代码中,实例化了一个新LinkedList对象,添加了两个整数类型和一个字符串类型。整数类型要转换为一个对象,所以执行装箱操作,如前面所述。在foreach语句中执行拆箱操作。在foreach语句中,链表中的元素被强制转换为整数,所以对于链表中的第三个元素,会发生一个运行异常,因为它转换为int时会失败。
下面创建链表的泛型版本。泛型类的定义与一般类类似,只是要使用泛型类型声明。之后,泛型类型就可以在类中用作一个字段成员,或者方法的参数类型。LinkedListNode类用一个泛型类型T声明。字段value的类型是T,而不是object。构造函数和Value属性也变为接受和返回T类型的对象。也可以返回和设置泛型类型,所以属性Next和Prev的类型是LinkedListNode<T>。
1.3 泛型类的功能
在创建泛型类时,需要一些其他C#关键字。例如,不能把null赋予泛型类型。此时,可以使用default关键字。如果泛型类型不需要Object类的功能,但需要调用泛型类上的某些特定方法,就可以定义约束。
本节讨论如下主题:
● 默认值
● 约束
● 继承
● 静态成员
下面开始一个使用泛型文档管理器的示例。文档管理器用于从队列中读写文档。先创建一个新的控制台项目DocumentManager,添加类DocumentManager<T>。AddDocument()方法将一个文档添加到队列中。如果队列不为空,IsDocumentAvailable只读属性就返回true。
1.3.1 默认值
现在给DocumentManager<T>类添加一个GetDocument()方法。在这个方法中,给类型T指定null。但是,不能把null赋予泛型类型。原因是泛型类型也可以实例化为值类型,而null只能用于引用类型。为了解决这个问题,可以使用default关键字。通过default关键字,将null赋予引用类型,将0赋予值类型。
1.3.2 约束
如果泛型类需要调用泛型类型上的方法,就必须添加约束。对于DocumentManager<T>,文档的所有标题应在DisplayAllDocuments()方法中显示。Document类实现带有Title和Content只读属性的IDocument接口。
要使用DocumentManager<T>类显示文档,可以将类型T强制转换为IDocument接口,以显示标题:
问题是,如果类型T没有执行IDocument接口,这个类型转换就会生成一个运行异常。最好给DocumentManager<TDocument>类定义一个约束:TDocument类型必须执行IDocument接口。为了在泛型类型的名称中指定该要求,将T改为TDocument。where子句指定了执行IDocument接口的要求。
这样,就可以编写foreach语句,让类型T包含属性Title了。Visual Studio IntelliSense和编译器都会提供这个支持。
在Main()方法中,DocumentManager<T>类用Document类型实例化,而Document类型执行了需要的IDocument接口。接着添加和显示新文档,检索其中一个文档:
DocumentManager现在可以处理任何执行了IDocument接口的类。
在示例应用程序中,介绍了接口约束。泛型还有几种约束类型,如表9-1所示。
注意·:
在C#中,where子句的一个重要限制是,不能定义必须由泛型类型执行的运算符。运算符不能在接口中定义。在where子句中,只能定义基类、接口和默认构造函数。
1.3.3 继承
前面创建的LinkedList<T>类执行了IEnumerable<T>接口:
泛型类型可以执行泛型接口,也可以派生于一个类。泛型类可以派生于泛型基类:
其要求是必须重复接口的泛型类型,或者必须指定基类的类型,如下所示:
于是,派生类可以是泛型类或非泛型类。例如,可以定义一个抽象的泛型基类,它在派生类中用一个具体的类型实现。这允许对特定类型执行特殊的操作:
还可以创建一个部分的特殊操作,如从Quey中派生StringQuery类,只定义一个泛型参数,如字符事 TResult。要实例化StringQuery,只需要提供TRequest的类型:
1.3.4 静态成员
泛型类的静态成员需要特别关注。泛型类的静态成员只能在类的一个实例中共享。下面看一个例子,其中 StaticDemo<T>类包含静态字段x:
由于同时对一个string类型和一个int类型使用了StaticDemo<T>类,因此存在两组静态字段:
StaticDemo<string>.x = 4;
StaticDemo<int>.x = 5;
Console.WriteLine(StaticDemo<string>.x); // writes 4
1.4 泛型接口
使用泛型可以定义接口,接口中的方法可以带泛型参数。在链表示例中,就执行了IEnumerable<T>接口,它定义了GetEnumerator()方法,以返回IEnumerator<out T>。NET为不同的情况提供了许多泛型接口,例如,IComparable<T>、ICollection<T>和IExtensibleObject<T>。同一个接口常常存在比较老的非泛型版本,例如,.NET1.0有基于对象的IComparable接口。IComparable<inT>基于一个泛型类型:
比较老的非泛型接口IComparable需要一个带CompareTo0方法的对象。这需要强制转换为特定的类型,例如,Person类要使用LastName属性,就需要使用CompareToO方法:
执行泛型版本时,不再需要将object的类型强制转换为Person:
1.4.1 协变和抗变
在NET4之前,泛型接口是不变的。.NET4通过协变和抗变为泛型接口和泛型委托添加了一个重要的扩展。协变和抗变指对参数和返回值的类型进行转换.例如,可以给一个需要Shape参数的方法传送Rectangle参数吗?下面用示例说明这些扩展的优点。
在.NET中,参数类型是抗变的,假定有Shape和Rectangle类,Rectangle类派生自Shape基类。声明DisplayO方法是为了接受Shape类型的对象作为其参数:
现在可以传递派生自Shape基类的任意对象。因为Rectangle派生自Shape,所以Rectangle满足Shape的所有要求,编译器接受这个方法调用:
方法的返回类型是协变的。当方法返回一个Shape时,不能把它赋予Rectangle,因为Shape不一定总是 Rectangle。反过来是可行的:如果一个方法像GetRectangleO方法那样返回一个Rectangle,.
在NET Framework4版本之前,这种行为方式不适用于泛型。自C#4以后,扩展后的语言支持泛型接口和泛型委托的协变和抗变。下面开始定义Shape基类和Rectangle类.
1.4.2 泛型接口的协变
如果泛型类型用out关键字标注,泛型接口就是协变的。这也意味着返回类型只能是T。接口IIndex与类型T是协变的,并从一个只读索引器中返回这个类型.
IIndex<T>接口用RectangleCollection类来实现。RectangleCollection类为泛型类型T定义了Rectangle:
RectangleCollection.GetRectangleO方法返回一个实现IIndex<Rectangle>接口的RectangleCollection类,所以可以把返回值赋予IIndex<Rectangle>类型的变量rectangle。因为接口是协变的,所以也可以把返回值赋予 Index<Shape>类型的变量。Shape不需要Rectangle没有提供的内容。使用shapes变量,就可以在for循环中使用接口中的索引器和Cont属性。
1.4.3 泛型接口的抗变
如果泛型类型用关键字标注,泛型接口就是抗变的。这样,接口只能把泛型类型T用作其方法的输入。
ShapeDisplay类实现Display<Shape>,并使用Shape对象作为输入参数
创建ShapeDisplay的一个新实例,会返回Display<Shape>,并把它赋予shapeDisplay变量.因为Display<T>是抗变的,所以可以把结果赋予Display<Rectangle>,其中Rectangle派生自Shape。这次接口的方法只能把泛型类型定义为输入,而Rectangle满足Shape的所有要求。
1.5 泛型结构
与类相似,结构也可以是泛型的。它们非常类似于泛型类,只是没有继承特性。本节介绍泛型结构 Nullable<-T>,它由.NET Framework定义。
NET Framework中的一个泛型结构是Nullable<T>。数据库中的数字和编程语言中的数字有显著不同的特征,因为数据库中的数字可以为空,而C#中的数字不能为空。32是一个结构,而结构实现同值类型,所以结构不能为空。这种区别常常令人很头痛,映射数据也要多做许多辅助工作。这个问题不仅存在于数据库中,也存在于把XML数据映射到.NET类型。
一种解决方案是把数据库和L文件中的数字映射为引用类型,因为引用类型可以为空值。但这也会在运行期间带来额外的系统开销。
使用Nullable<T>结构很容易解决这个问题。下面的代码段说明了如何定义Nullable<T>的一个简化版本。结构Nullable<T>定义了一个约束:其中的泛型类型T必须是一个结构。把类定义为泛型类型后,就没有低系统开销这个优点了,而且因为类的对象可以为空,所以对类使用Nullable<T>类型是没有意义的。除了Nullable<T>定义的T类型之外,唯一的系统开销是has Value布尔字段,它确定是设置对应的值,还是使之为空。除此之外,泛型结构还定义了只读属性Has Value和Vaue,以及一些运算符重载。把Nullable<T>类型强制转换为T类型的运算符重载是显式定义的,因为当has Value为false时,它会抛出一个异常。强制转换为Nullable<T>类型的运算符重载定义为隐式的,因为它总是能成功地转换:
在这个例子中,Nullable<T>用Nullable<int>实例化。变量x现在可以用作一个int,进行赋值或使用运算符执行一些计算。这是因为强制转换了Nullable<T>类型的运算符。但是,x还可以为空。Nullable<T>的Has Value和Value属性可以检查是否有一个值,该值是否可以访问:
因为可空类型使用得非常频繁,所以C#有一种特殊的语法,它用于定义可空类型的变量。定义这类变量时不使用泛型结构的语法,而使用“?”运算符。在下面的例子中,变量x1和x2都是可空的t类型的实例:
可空类型可以与null和数字比较,如上所示。这里,x的值与null比较,如果x不是null,它就与小于0的值比较:
知道了Nullable<T>是如何定义的之后,下面就使用可空类型。可空类型还可以与算术运算符一起使用。变量x3是变量x1和x2的和。如果这两个可空变量中任何一个的值是null,它们的和就是null。
注意:这里调用的GetNullableType()方法只是一个占位符,它对于任何方法都返回一个可空的int。为了进行测试,简单起见,可以使实现的GetNullableTypeO返回null或返回任意整数。
非可空类型可以转换为可空类型。从非可空类型转换为可空类型时,在不需要强制类型转换的地方可以进行隐式转换。这种转换总是成功的:
但从可空类型转换为非可空类型可能会失败。如果可空类型的值是null,并且把null值赋予非可空类型就会抛出nvalidOperationException类型的异常。这就是需要类型强制转换运算符进行显式转换的原因:
如果不进行显式类型转换,还可以使用合并运算符从可空类型转换为非可空类型。合并运算符的语法是“?”,为转换定义了一个默认值,以防可空类型的值是null。这里,如果x1是null,y1的值就是0。
1.6 泛型方法
除了定义泛型类之外,还可以定义泛型方法。在泛型方法中,泛型类型用方法声明来定义。
Swap<T>方法把T定义为泛型类型,用于两个参数和一个变量temp:
把泛型类型赋予方法调用,就可以调用泛型方法:
但是,因为C#编译器会通过调用Swap方法来获取参数的类型,所以不需要把泛型类型赋予方法调用。泛型方法可以像非泛型方法那样调用:
1.6.1 带委托的泛型方法
这个Accumulate(0方法使用两个泛型参数T1和T2,第一个参数T1用于实现Enumerable<T1>参数的集合,第二个参数使用泛型委托FncT1,T2,TResult>。其中,第2个和第3个泛型参数都是T2类型。需要传递的方法有两个输入参数(T1和T2)和一个T2类型的返回值。
在调用这个方法时,需要指定泛型参数类型,因为编译器不能自动推断出该类型。对于方法的第1个参数,所赋予的accounts集合是Enumerable<Account>类型。对于第2个参数,使用一个lambda表达式来定义Account和decimal类型的两个参数,返回一个小数。对于每一项,通过Accumulate02方法调用这个lambda表达式。
不要为这种语法伤脑筋。该示例仅说明了扩展Accumulate()方法的可能方式。