泛型
什么是泛型
到现在为止,所有在类声明中用到的类型都是特定的类型–或是程序员定义的,或是语言或BCL定义的。然而,很多时候,我们需要把类的行为提取或重构出来,使之不仅能用到它们编码的数据类型上,还能应用到其他类型上。
泛型可以做到这一点。我们重构代码并额外增加一个抽象层,对于这样的代码来说,数据类型就不用硬编码了。这是专门为多段代码在不同的数据类型上执行相同指令的情况专门设计的。
听起来比较抽象,下面看一个示例
一个栈的示例
假设我们声明一个MyIntStack类,该类实现一个int类型的栈。它允许int值的压入弹出。
class MyIntStack { int StackPointer=0; int[] StackArray; public void Push(int x) { ... } public int Pop() { ... } }
假设现在希望将相同的功能应用与float类型的值,可以有几种方式来实现。不用泛型,按照我们以前的思路产生的代码如下。
class MyFloatStack { int StackPointer=0; float[] StackArray; public void Push(float x) { ... } public float Pop() { ... } }
这个方法当然可行,但容易出错且有如下缺点:
- 我们需要仔细检查类的每部分来看哪些类型的声明需要修改,哪些需要保留
- 每次需要新类型的栈类时,我们需要重复该过程
- 代码冗余
- 不宜调试和维护
C#中的泛型
泛型(generic)特性提供了一种更优雅的方式,可以让多个类型共享一组代码。泛型允许我们声明类型参数化(type-parameterized)的代码,可以用不同的类型进行实例化。即我们可以用“类型占位符”来写代码,然后在创建类的实例时指明真实的类型。
本书读到这里,我们应该很清楚类型不是对象而是对象的模板这个概念了。同样地,泛型类型也不是类型,而是类型的模板。
C#提供了5种泛型:类、结构、接口、委托和方法。
注意,前4个是类型,而方法是成员。
继续栈示例
将MyIntStack和MyFloatStack两个类改为MyStack泛型类。
class MyStack<T> { int StackPointer=0; T[] StackArray; public void Push(T x){...} public T Pop(){...} }
泛型类
创建和使用常规的、非泛型的类有两个步骤:声明和创建类的实例。但是泛型类不是实际的类,而是类的模板,所以我们必须从它们构建实际的类类型,然后创建实例。
下图从一个较高的层面上演示了该过程。
- 在某些类型上使用占位符来声明一个类
- 为占位符提供真实类型。这样就有了真实类的定义,填补了所有的“空缺”。该类型称为构造类型(constructed type)
- 创建构造类型的实例
声明泛型类
声明一个简单的泛型类和声明普通类差不多,区别如下。
- 在类名后放置一组尖括号
- 在尖括号中用逗号分隔的占位符字符串来表示希望提供的类型。这叫做类型参数(type parameter)
- 在泛型类声明的主体中使用类型参数来表示应该替代的类型
class SomeClass<T1,T2> { public T1 SomeVar=new T1(); public T2 OtherVar=new T2(); }
泛型类型声明中没有特殊的关键字,取而代之的是尖括号中的类型参数列表。
创建构造类型
一旦创建了泛型类型,我们就需要告诉编译器能使用哪些真实类型来替代占位符(类型参数)。
创建构造类型的语法如下,包括列出类名并在尖括号中提供真实类型来替代类型参数。要替代类型参数的真实类型叫做类型实参(type argument)。
SomeClass<short,int>
编译器接受类型实参并且替换泛型类主体中的相应类型参数,产生构造类型–从它创建真实类型的实例。
下图演示了类型参数和类型实参的区别。
- 泛型类声明上的类型参数用做类型的占位符
- 在创建构造类型时提供的真实类型是类型实参
创建变量和实例
在创建引用和实例方面,构造类类型的使用和常规类型相似。
MyNonGenClass myNGC=new MyNonGenClass();
SomeClass<short,int> mySc1=new SomeClass<short,int>(); var mySc2=new SomeClass<short,int>();
和非泛型一样,引用和实例可以分开创建。
SomeClass<short,int> myInst; myInst=new SomeClass<short,int>();
可以从同一泛型类型构建不同类类型。每个独立的类类型,就好像它们都有独立的非泛型类声明一样。
class SomeClass<T1,T2> { ... } class Program { static void Main() { var first=new SomeClass<short,int>(); var second=new SomeClass<int,long>(); } }
使用泛型的栈的示例
class MyStack<T> { T[] StackArray; int StackPointer=0; public void Push<T x> { if(!IsStackFull) { StackArray[StackPointer++]=x; } } public T Pop() { return (!IsStackEmpty) ?StackArray[--StackPointer] :StackArray[0]; } const int MaxStack=10; bool IsStackFull{get{return StackPointer>=MaxStack;}} bool IsStackEmpty{get{return StackPointer<=0;}} public MyStack() { StackArray=new T[MaxStack]; } public void Print() { for(int i=StackPointer-1;i>=0;i--) { Console.WriteLine(" Value:{0}",StackArray[i]); } } } class Program { static void Main() { var StackInt=new MyStack<int>(); var StackString=new MyStack<string>(); StackInt.Push(3); StackInt.Push(5); StackInt.Push(7); StackInt.Push(9); StackInt.Print(); StackString.Push("This is fun"); StackString.Push("Hi there! "); StackString.Print(); } }
比较泛型和非泛型栈
类型参数的约束
在泛型栈的示例中,栈除了保存和弹出它包含的一些项之外没做任何事情。它不会尝试添加、比较或做其他任何需要用到项本身的运算符的事情。理由是,泛型栈不知道它保存的项的类型是什么,也不知道这些类型实现的成员。
然而,C#对象都从object类继承,因此,栈可以确认,这些保存的项都实现了object类的成员。它们包括ToString、Equals以及GetType。
如果代码尝试使用除object类的其他成员,编译器会产生错误。
例:
class Simple<T> { static public bool LessThan(T i1,T i2) { return i1<i2; //错误 } ... }
要让泛型变得更有用,我们需要提供额外的信息让编译器知道参数可以接受哪些类型。这些信息叫做约束(constrain)。只有符合约束的类型才能替代类型参数。
Where子句
约束使用Where子句列出。
- 每个约束的类型参数有自己的where子句
- 如果形参有多个约束,它们在where子句中使用逗号分隔
where子句语法如下:
类型参数 约束列表 ↓ ↓ where TypeParam:constraint,constraint,... ↑ ↑ 关键字 冒号
有关where子句的要点:
- 它们在类型参数列表的关闭尖括号之后列出
- 它们不是用逗号或其他符号分隔
- 它们次序任意
- where是上下文关键字,可以在其他上下文中使用
例:where子句示例
class MyClass<T1,T2,T3> where T2:Customer where T3:IComparable { ... }
约束类型和次序
where子句可以以任何次序列出。然而where子句中的约束必须有特定顺序。
- 最多只能有一个主约束,若有则必须放第一位
- 可以有任意多的接口名约束
- 若有构造函数约束,必须放最后
例:约束示例
class 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() {...}
泛型方法
与其他泛型不一样,方法是成员,不是类型。泛型方法可以在泛型和非泛型类以及结构和接口中声明。
声明泛型方法
泛型方法具有类型参数列表和可选的约束
- 泛型方法有两个参数列表
- 封闭在圆括号内的方法参数列表
- 封闭在尖括号内的类型参数列表
- 要声明泛型方法,需要:
- 在方法名称后和方法参数列表前放置类型参数列表
- 在方法参数列表后放置可选的约束子句
类型参数列表 约束子句 ↓ ↓ public void PrintData<S,T>(S p,T t)where S:Person { ↑ ... 方法参数列表 }
记住,类型参数列表在方法名称后,在方法参数列表前。
调用泛型方法
调用方法,需在调用时提供类型实参,如下:
MyMethod<short,int>(); MyMethod<int,long>();
例:调用泛型方法示例
推断类型
如果我们为方法传入参数,编译器有时可以从方法参数中推断出泛型方法的类型形参用到的那些类型。这样就可以使方法调用更简单,可读性更强。
如下代码,若我们使用int类型变量调用MyMethod,方法调用中的类型参数信息就多余了,因为编译器可以从方法参数得知它是int。
int myInt=5; MyMethod<int>(myInt);
由于编译器可以从方法参数中推断类型参数,我们可以省略类型参数和调用中的尖括号,如下:
MyMethod(myInt);
泛型方法示例
class Simple { static public void ReverseAndPrint<T>(T[] arr) { Array.Reverse(arr); foreach(T item in arr) { Console.WriteLine("{0},",item.ToString()); } Console.WriteLine(""); } } class Program { static void Main() { var intArray=new int[]{3,5,7,9,11}; var stringArray=new string[]{"first","second","third"}; var doubleArray=new double[]{3.567,7,891,2,345}; Simple.ReverseAndPrint<int>(intArray); Simple.ReverseAndPrint(intArray); Simple.ReverseAndPrint<string>(stringArray); Simple.ReverseAndPrint(stringArray); Simple.ReverseAndPrint<double>(doubleArray); Simple.ReverseAndPrint(doubleArray); } }
扩展方法和泛型类
在第7章中,我们详细介绍了扩展方法,它也可以和泛型类结合使用。它允许我们将类中的静态方法关联到不同的泛型类上,还允许我们像调用类结构实例的实例方法一样来调用方法。
和非泛型类一样,泛型类的扩展方法:
- 必须声明为static
- 必须是静态类的成员
- 第一个参数类型中必须有关键字this,后面是扩展的泛型类的名字
static class ExtendHolder { public static void Print<T>(this Holder<T>h) { T[] vals=h.GetValue(); Console.WriteLine("{0}, {1}, {2}",vals[0],vals[1],vals[2]); } } class Holder<T> { T[] Vals=new T[3]; public Holder(T v0,T v1,T v2) { Vals[0]=v0;Vals[1]=v1;Vals[2]=v2; public T[] GetValues(){return Vals;} } } class Program { static void Main() { var intHolder=new Holder<int>(3,5,7); var stringHolder=new Holder<string>("a1","b2","c3"); intHolder.Print(); stringHolder.Print(); } }
泛型结构
与泛型类相似,泛型结构可以有类型参数和约束。泛型结构的规则和条件与泛型类一样。
struct PieceOfData<T> { public PieceOfData(T value){_data=value;} private T _data; public T Data { get{return _data;} set{_data=value;} } } class Program { static void Main() { var intData=new PieceOfData<int>(10); var stringData=new PieceOfData<string>("Hi there."); Console.WriteLine("intData ={0}",intData.Data); Console.WriteLine("stringData ={0}",stringData.Data); } }
泛型委托
泛型委托与非泛型委托非常相似,不过类型参数决定能接受什么样的方法。
- 要声明泛型委托,在委托名称后、委托参数列表前的尖括号中放置类型参数列表
`delegate R MyDelegate<T,R>(T Value);`
- 注意,有两个参数列表:委托形参列表和类型参数列表
- 类型参数的范围包括:
- 返回值
- 形参列表
- 约束子句
例:泛型委托示例
delegate void MyDelegate<T>(T value); class Simple { static public void PrintString(string s) { Console.WriteLine(s); } static public void PrintUpperString(string s) { Console.WriteLine("{0}",s.ToUpper()); } } class Program { static void Main() { var myDel=new MyDelegate<string>(Simple.PrintString); myDel+=Simple.PrintUpperString; myDel("Hi There."); } }
另一个 泛型委托示例
C#的LINQ(第19章)特性在很多地方使用了泛型委托,但在介绍LINQ前,有必要给出另外一个示例。
public delegate TR Func<T1,T2,TR>(T1 p1,T2 p2);//泛型委托 class Simple { static public string PrintString(int p1,int p2) { int total=p1+p2; return total.ToString(); } } class Program { static void Main() { var myDel=new Fun<int,int,string>(Simple.PrintString); Console.WriteLine("Total:{0}",myDel(15,13)); } }
泛型接口
泛型接口允许我们编写参数和返回类型是泛型类型参数的接口。
例:IMyIfc泛型接口
interface IMyIfc<T> { T ReturnIt(T inValue); } class Simple<S>:IMyIfc<S> { public S ReturnIt(S inValue) { return inValue; } } class Program { static void Main() { var trivInt=new Simple<int>(); var trivString=new Simple<string>(); Console.WriteLine("{0}",trivInt.ReturnIt(5)); Console.WriteLine("{0}",trivString.ReturnIt("Hi there.")); } }
使用泛型接口的示例
如下示例演示了泛型接口的两个额外能力:
- 实现不同类型参数的泛型接口是不同的接口
- 可以在非泛型类型中实现泛型接口
例:Simple是实现泛型接口的非泛型类。
interface IMyIfc<T> { T ReturnIt(T inValue); } class Simple:IMyIfc<int>,IMyIfc<string> //非泛型类 { public int ReturnIt(int inValue) //实现int类型接口 {return inValue;} public string ReturnIt(string inValue) //实现string类型接口 {return inValue;} } class Program { static void Main() { var trivial=new Simple(); Console.WriteLine("{0}",trivial.ReturnIt(5)); Console.WriteLine("{0}",trivial.ReturnIt("Hi there.")); } }
泛型接口的实现必须唯一
实现泛型类接口时,必须保证类型实参组合不会在类型中产生两个重复的接口。
例:Simple类使用了两个IMyIfc接口的实例化。
对于泛型接口,使用两个相同接口本身没有错,但这样会产生一个潜在冲突,因为如果把int作为类型参数来替代第二个接口中的S的话,Simple可能会有两个相同类型的接口,这是不允许的。
interface IMyIfc<T> { T ReturnIt(T inValue); } class Simple<S>:IMyIfc<int>,IMyIfc<S> //错误 { public int ReturnIt(int inValue) {return inValue;} public S ReturnIt(S inValue) //如果它不是int类型的 {return inValue;} //将和上个示例的接口一样 }
说明:泛型接口的名字不会和非泛型冲突。例如,在前面的代码中我们还可以声明一个名为IMyIfc的非泛型接口。
协变
纵观本章,大家已经看到,如果你创建泛型类型的实例,编译器会接受泛型类型声明以及类型参数来构造类型。但是,大家通常会错误的将派生类型分配给基类型的变量。下面我们来看一下这个主题,这叫做可变性(variance)。它分为三种–协变(convariance)、逆变(contravariance)和不变(invariance)。
首先回顾已学内容,每个变量都有一种类型,可以将派生类对象的实例赋值给基类变量,这叫赋值兼容性。
例:赋值兼容性
class Animal { public int NumberOfLegs=4; } class Dog:Animal { } class Program { static void Main() { var a1=new Animal(); var a2=new Dog(); Console.WriteLine("Number of dog legs:{0}",a2.NumberOfLegs); } }
现在,我们来看一个更有趣的例子,用下面的方式对代码进行扩展。
- 增加一个叫做Factory的泛型委托,它接受类型参数T,不接受方法参数,然后返回一个类型为T的对象
- 添加一个叫MakeDog的方法,不接受参数但返回一个Dog对象。如果我们使用Dog作为类型参数的话,这个方法可以匹配Factory委托
class Animal{public int NumberOfLegs=4;} class Dog:Animal{} delegate T Factory<T>(); class Program { static Dog MakeDog() { return new Dog(); } static void Main() { Factory<Dog> dogMaker=MakeDog; Factory<Animal>animalMaker=dogMaker; Console.WriteLine(animalMaker().Legs.ToString()); } }
上面代码在Main的第二行会报错,编译器提示:不能隐式把右边的类型转换为左边的类型。
看上去由派生类型构造的委托应该可以赋值给由基类构造的委托,那编译器为何报错?难道赋值兼容性原则不成立了?
不是,原则依然成立,但是对于这种情况不适用!问题在于尽管Dog是Animal的派生类,但是委托Factory<Dog>
没有从委托Factory<Animal>
派生。相反,两个委托对象是同级的,它们都从delegate类型派生。
再仔细分析一下这种情况,我们可以看到,如果类型参数只用作输出值,则同样的情况也适用于任何泛型委托。对于所有这样的情况,我们应该可以使用由派生类创建的委托类型,这样应该能够正常工作,因为调用代码总是期望得到一个基类的引用,这也正是它会得到的。
如果派生类只是用于输出值,那么这种结构化的委托有效性之间的常数关系叫做协变。为了让编译器知道这是我们的期望,必须使用out关键字标记委托声明中的类型参数。
增加out关键字后,代码就可以通过编译并正常工作了。
delegate T Factory<out T>(); ↑ 关键字指定了类型参数的协变
- 图左边栈中的变量是
T Factory<out T>()
的委托类型,其中类型变量T是Animal类 - 图右边堆上实际构造的委托是使用Dog类类型变量进行声明的,Dog从Animal派生
- 这是可行的,尽管调用委托时,调用代码接受Dog类型的对象,而不是期望的Animal类型对象,但是调用代码可以像之前期望的那样自由地操作对象的Animal部分
逆变
现在来看另一种情况。
class Animal{public int NumberOfLegs=4;} class Dog:Animal{} delegate T Factory<T>(); class Program { delegate void Action1<in T>(T a); static void ActOnAnimal(Animal a) { Console.WriteLine(a.NumberOfLegs); } static void Main() { Action1<Animal> act1=ActOnAnimal; Action1<Dog> dog1=act1; dog1(new Dog()); } }
和之前情况相似,默认情况下不可以赋值两种不兼容的类型。但在某些情况下可以让这种赋值生效。
其实,如果类型参数只用作委托中方法的输入参数的话就可以了。因为即使调用代码传入了一个程度更高的派生类的引用,委托中的方法也只期望一个程度低一些的派生类的引用,当然,它也仍然接受并知道如何操作。
这种期望传入基类时允许传入派生对象的特性叫做逆变。可以在类型参数中显式使用in关键字来使用。
- 图左边栈上的变量是
void Action1<in T>(T p)
类型的委托,其类型变量是Dog类 - 图右边实际构建的委托使用Animal类的类型变量来声明,它是Dog类的基类
- 这样可以工作,因为在调用委托时,调用代码为方法ActOnAnimal传入Dog类型的变量,而它期望的是Animal类型的对象。方法当然可以像期望的那样自由操作对象的Animal部分
下图总结了泛型委托中协变和逆变的不同
- 上面的图演示了协变:
- 左边栈上的变量是
F<out T>()
类型的委托,类型变量是叫做Base的类 - 在右边实际构建的委托,使用Derived类的类型变量声明,这个类派生自Base
- 这样可以工作,因为在调用时,方法返回指向派生类型的对象的引用,派生类型同样指向其基类,调用代码可正常工作
- 左边栈上的变量是
- 下面的图演示了逆变:
- 左边栈上的变量是
F<in T>(T p)
类型的委托,类型参数是Derived类 - 在右边实际构建的委托,使用Base类的类型变量声明,这个类是Derived类的基类
- 这样可以工作,因为在调用时,调用代码传入了派生类型的变量,方法期望的只是其基类,方法完全可以像以前那样操作对象的基类部分
- 左边栈上的变量是
接口的协变和逆变
现在你应该已经理解了协变和逆变可以应用到委托上。其实相同的原则也可用到接口上,可以在声明接口的时候使用out和in关键字。
例:使用协变的接口
class Animal{public string Name;} class Dog:Animal{}; interface IMyIfc<out T> { T GetFirst(); } class SimpleReturn<T>:IMyIfc<T> { public T[] items=new T[2]; public T GetFirst() { return items[0]; } } class Program { static void DoSomething(IMyIfc<Animal>returner) { Console.WriteLine(returner.GetFirst().Name); } static void Main() { SimpleReturn<Dog> dogReturner=new SimpleReturn<Dog>(); dogReturner.items[0]=new Dog(){Name="Avonlea"}; IMyIfc<Animal> animalReturner=dogReturner; DoSomething(dogReturner); } }
有关可变性的更多内容
之前的两小节解释了显式的协变和逆变。还有一些情况编译器可以自动识别某个已构建的委托是协变或是逆变并自动进行类型强制转换。这通常发生在没有为对象的类型赋值的时候,如下代码演示了该例子。
class Animal{public int Legs=4;} class Dog:Animal{} class Program { delegate T Factory<out T>(); static Dog MakeDog() { return new Dog(); } static void Main() { Factory<Animal> animalMaker1=MakeDog;//隐式强制转换 Factory<Dog> dogMaker=MakeDog; Factory<Animal> animalMaker2=dogMaker;//需要out标识符 Factory<Animal> animalMaker3 =new Factory<Dog>(MakeDog);//需要out标识符 } }
有关可变性的其他一些重要事项如下:
- 变化处理的是使用派生类替换基类的安全情况,反之亦然。因此变化只适用于引用类型,因为不能从值类型派生其他类型
- 显式变化使用in和out关键字只适用于委托和接口,不适用于类、结构和方法
- 不包括in和out关键字的委托和接口类型参数叫做不变。这些类型参数不能用于协变或逆变
协变 ↓ delegate T Factory<out R,in S,T>(); ↑ ↑ 逆变 不变