泛型
什么是泛型
到現在為止,所有在類聲明中用到的類型都是特定的類型–或是程序員定義的,或是語言或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},\t{1},\t{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>(); ↑ ↑ 逆變 不變