0. 文章目的: 介紹變體的概念,並介紹其對C#的意義 1. 閱讀基礎 瞭解C#進階語言功能的使用(尤其是泛型、委托、介面) 2. 從示例入手,理解變體 變體這一概念用於描述存在繼承關係的類型間的轉化,這一概念並非只適用於C#,在許多其他的OOP語言中也都有變體概念。變體一共有三種:協變、逆變與不變 ...
0. 文章目的:
介紹變體的概念,並介紹其對C#的意義
1. 閱讀基礎
瞭解C#進階語言功能的使用(尤其是泛型、委托、介面)
2. 從示例入手,理解變體
變體這一概念用於描述存在繼承關係的類型間的轉化,這一概念並非只適用於C#,在許多其他的OOP語言中也都有變體概念。變體一共有三種:協變、逆變與不變。其中協變與逆變這兩個詞來自數學領域,但是其含義和數學中的含義幾乎沒有關係(就像編程語言的反射和光的反射之間的關係)。從字面上來看這三種變體的名字多少有點唬人,但其實際意思並不難理解。廣泛來說,三種變體的意思如下:
- 協變(Covariance):允許使用派生程度更大的類型
- 逆變(Contravariance):允許使用派生程度更小的類型
- 不變(Invariance):只允許目標類型
或者換一種更具體的說法:
- 協變(Covariance):若類型A為協變數,則需要使用類型A的地方可以使用A的某個子類類型。
- 逆變(Contravariance):若類型A為逆變數,則需要使用類型A的地方可以使用A的某個基類類型。
- 不變(Invariance):若類型A為不變數,則需要使用類型A的地方只能使用A類型。
(註意是‘協變/量’而不是‘協/變數’)
為了方便說明三者的含義,先定義兩個類:
class Cat { }
class SuperCat : Cat { }
上述代碼定義了一個Cat類,並從Cat類派生出一個SupreCat類,如無特殊說明,後文的所有代碼都會假設這兩個類存在。下麵利用這兩個類逐一說明三種變體的含義。
2.1 協變:在一個需要Cat的場合,可以使用SuperCat
例如,對於下列代碼:
Cat cat = new SuperCat();
cat是一個引用Cat對象的變數,從類型安全的角度來說,它應該只能引用Cat對象,但是由於通常子類總是可以安全地轉化為其某一基類,因此你也可以讓其引用一個SuperCat對象。要實現這種用子類代替基類的操作就需要支持協變,由於OOP語言基本都支持子類向基類安全轉化,所以協變在很多人看來是很十分自然的,也容易理解。
2.2 逆變:在一個需要SuperCat的場合,可以使用Cat
逆變有時也被稱為抗變,你可能會覺得逆變的含義非常讓人迷惑,因為通常來說基類是不能安全轉化為其子類的,從類型安全的角度來看,這一概念應該似乎沒有實際的應用場合,尤其是對於靜態類型的語言。然而,考慮以下代碼:
delegate void Action<T>();
void Feed(Cat cat)
{
...
}
Action<SuperCat> f = Feed;
Feed是一個‘參數為Cat對象的方法’,而f是一個引用‘參數為SuperCat對象的方法’的委托。從類型安全的角度來說,委托f應該只能引用參數為SuperCat對象的方法。然而如果你仔細思考上述代碼,就會意識到既然委托f在調用時需要傳入的是一個SuperCat對象,那麼可以處理Cat類型的Feed方法顯然也可以處理SuperCat(因為SuperCat可以安全轉化為Cat),因此上面的代碼從邏輯上來說是可以正常運行的。那麼也就是說,本來需要SuperCat類型的地方(這裡是委托的參數類型)現在實際給的卻是Cat類型,要實現這種用基類代替子類的操作就需要逆變。
不過,結合上述,你會發現所謂逆變實際還是依靠‘子類可以向基類安全轉化’這一原則,只是因為我們是從委托f的角度去考慮而已。
2.3 不變:在一個需要Cat的場合,只能使用Cat
相比逆變和協變,不變更容易理解:只接受指定類型,不接受其基類或者子類。比如如果Cat類型具有不變性,那麼下述代碼將無法通過編譯:
Cat cat = new SuperCat(); // 錯誤,cat只能引用Cat類型
顯然不變從表現上來說是理所當然與符合常識的,故本文主要闡述協變與抗變。
3. C#中的變體
3.1 C#中的變體
同大多數語言一樣,C#同樣遵循‘基類引用可以指向子類’這一基本原則,因此對C#來說協變是普遍存在的:
Feed(Cat cat)
{
...
}
Cat cat = new SuperCat(); // 本來需要指向Cat對象的變數cat被指向了SuperCat對象,利用了協變性
SuperCat superCat = new SuperCat();
Feed(superCat); // 同理,Feed方法需要Cat對象但是傳入的是SuperCat對象,利用了協變性
C#中的不變體現在值類型上,這是因為值類型都不允許繼承與被繼承,自然也不存在基類或子類的概念,也不存在類型間通過繼承轉化的情況。
C#中的逆變在一般情況下沒有體現,因為將基類轉化為派生類是不安全的,C#不支持這種操作。所以逆變對C#來說很多時候其實只是概念上的認識,真正讓逆變對C#有意義的情況是使用泛型的場合,這在接下來就會提到。
從學習語言語法的角度來說,瞭解變體對學習C#的幫助其實不大,但如果想更進一步理解C#中泛型的設計原理,就有必要理解變體了。
3.2 泛型與變體
理解變體對理解C#的泛型設計原理有重要意義,C#中泛型的類型參數預設為不變數,但可以是out
與in
關鍵字來指示類型為參數為協變數或者逆變數。簡單來說,in
關鍵字用於修飾輸入參數的相容性,out
關鍵字用於修飾輸出參數的相容性。這一節會通過具體的泛型使用示例來解釋變體概念對C#泛型的意義。
3.2.1 泛型委托
(1)輸入參數的相容性:逆變
考慮下麵的泛型委托聲明:
delegate void Action<T>(T arg);
上述委托可以接受一個參數類型為T,返回類型為TReturn的委托。下麵來定義一個方法:
void Feed(Cat cat)
{
}
Foo是一個接受一個Cat對象,並返回一個SuperCat對象的方法。因此,下麵的代碼是理所當然的:
Action<Cat> act = Feed;
然而,從邏輯上來講,下麵的代碼也應該是合法的:
Action<SuperCat> act = Feed;
委托act接受的參數類型為SuperCat,也就是說當調用委托act的時候傳入的將會是一個SuperCat對象,顯然SuperCat對象可以安全地轉換為Foo所需要的Cat對象,因此這一轉變是安全的。我們以委托act的視角來看:本來act應該引用的是一個‘參數類型為SuperCat’的方法,然而我們卻把一個‘參數類型為Cat的’Feed方法賦值給了它,但結合上面的分析我們知道這一賦值行為是安全的。也就是說,本來此時泛型委托Action<T>中泛型類型參數T需要的類型是SuperCat,但現在實際給的類型卻是Cat:
(紅色是方法參數類型)
Cat是SuperCat的基類,也就是說這時候泛型委托Action<T>的類型參數T這個位置上出現了逆變。儘管從邏輯上來說這是合理的,但是C#中泛型類型參數預設具有不變性,因此如果要使上述代碼通過編譯,還需要將泛型委托Func的類型參數T聲明為逆變數,在C#中,可以通過在泛型類型參數前添加in關鍵字將泛型參數聲明為逆變數:
delegate void Action<in T>(T arg);
(2):輸出參數的相容性:協變
另一方面,下麵的代碼從邏輯上說也應該是合法的:
delegate T Func<T>();
SuperCat GetSuperCat()
{
...
}
Func<Cat> func = GetSuperCat;
委托func被調用時需要返回一個Cat對象,而GetSuperCat返回的是一個SuperCat對象,這顯然是滿足func的要求的:
同樣以委托func的視角來看,本來需要類型Cat的地方現在實際給的類型是SuperCat,也就是說,此時出現了協變。同樣的,如果要使上述代碼通過編譯,應該需要將Func的類型參數T聲明為協變數,可以在泛型參數前添加out關鍵字將泛型類型參數聲明為協變數:
delegate T Func<out TReturn>();
3.2.2 泛型介面
(1)輸出參數的相容性:協變
假設現有以下用於表示集合的介面聲明與實現該介面的泛型類:
interface ICollection<T>
{
}
class Collection<T> : ICollection<T>
{
}
根據上述定義,理所當然的,下麵的語句是合法的:
ICollection<Cat> cats = new Collection<Cat>();
然而,從邏輯上講,下麵的語句也應該是合法的:
ICollection<Cat> cats = new Collection<SuperCat>();
原因如下:既然SuperCat是Cat的子類,那麼Collection中的任意一個SuperCat對象都應該可以安全轉化為Cat對象,那麼SuperCat的集合也應該視為Cat的集合。從事實上講,若對任何一個需要Cat對象集合的方法,即便傳入的是一個SuperCat對象的集合也應該可以正常工作。同樣以類型為ICollection<Cat>的介面變數cats的視角來看,ICollection<Cat>類型上本來應該為Cat類型的地方現在被SuperCat類型所替代:
SuperCat代替了Cat,也就是說出現了協變,那麼如果要使上述代碼通過編譯,則需要將類型參數T聲明為協變數:
interface ICollection<out T>
{
}
C#中的IEnumerable介面就將其類型參數T聲明為了協變數,因此下麵的代碼可以正常運行:
IEnumerable<Cat> cats = new List<SuperCat>();
(2)輸入參數的相容性:逆變
接著再來考慮一個介面與實現類:
interface IHand<T>
{
void Pet(T animal);
}
class Hand<T> : IHand<T>
{
void Pet(T animal) { ... }
}
下麵的代碼應該是合理的:
SuperCat cat = new SuperCat();
IHand<SuperCat> hand = new Hand<Cat>();
hand.Pet(cat);
原因如下:實現IHand<Cat>介面的Hand<Cat>的Pet方法可以處理Cat類型,顯然其應該也可以處理作為Cat子類的SuperCat。同樣的,以類型為IHand<SuperCat>的介面變數hand來看,本來應該需要類型為SuperCat的地方現在實際卻是Cat類型:
Cat替代了SuperCat,也就是說此時發生了逆變。同樣的,如果要讓上述代碼通過編譯,需要將IHand<>的類型參數T聲明為逆變數:
interface IHand<in T>
{
void Pet(T animal);
}
這樣下述代碼就可以通過編譯:
IHand<SuperCat> hand = new Hand<Cat>();
3.2.3 泛型方法
與泛型委托和泛型介面不同的是,泛型方法不允許修改類型參數的變體類型,泛型方法的類型參數只能是不變數,因為讓泛型方法的類型參數為變體沒有意義。一方面,泛型方法的類型參數會在方法被調用時直接使用目標類型,因此不存在需要變體的情況:
void Pet<T>(T cat)
{
...
}
Pet(new Cat()); // 此時T為Cat
Pet(new SuperCat()); // 此時T為SuperCat
另一方面,你不能給一個方法賦值。
TReturn Foo<T, TReturn>(T t)
{
...
}
Foo = ...; // ???
顯然上述代碼是無法通過編譯的。綜上,給泛型方法的類型參數定義為協變數或者逆變數是沒有意義的,因此也沒有必要提供這一功能。
3.2.4 泛型類
C#中的泛型類的類型參數同樣只允許為不變數,這裡以常用的泛型List<>為例,下麵的代碼是不允許的:
List<Cat> cats = new List<SuperCat>();
哪怕從概念上說一個SuperCat的對象的集合用於需要Cat對象的集合的場景是合法的,但是這一行為確實是不允許的,原因是CLR不支持。此外,C#限制協變數只能為方法的返回類型(後文會解釋),所以下麵的類定義是不可行的:
class Foo<out T>
{
public T Get() { } // 可以,協變數用於返回類型
public Set(T arg) { } // 錯誤,協變數不可用於方法參數
public T Field; // 錯誤,參數類型T既不是作為方法的返回類型,也不是作為方法的參數
}
既然連欄位的類型都不能是協變的泛型類型,那麼顯然這樣的類沒有太大的意義。由於以上原因,泛型變體對於定義泛型類的意義不大。
4. 變體限制
C#對泛型中允許變體的類型參數有嚴格的使用限制,主要限制如下:
- 協變數只能作為輸出參數(方法的返回值,不包out參數)
- 逆變數只能作為輸入參數(方法的參數,不包括in、out以及ref參數)
- 只能是不變數、協變數或者逆變數三者之一
上述限制也說明瞭為何C#選擇用out關鍵字來修飾協變數,in關鍵字來修飾逆變數。如果沒有以上限制,可能出現一些很奇怪的操作,例如:
(1)假設:協變數可用於輸入參數:
delegate void Action<out T>(T arg); // 此處協變數T作為了方法參數
void Call(SuperCat cat)
{
}
Action<Cat> f = GetCat;
上述代碼中當委托f被調用時可能會傳入一個Cat對象,然而其引用Call方法需要的是一個SuperCat對象,此時Cat類型無法安全轉化為SuperCat類型,因此會出現運行時錯誤。
(2)假設:逆變數可用於方法的輸出參數
delegate T Func<in T>(); // 此處類型參數T作為了方法返回類型
Cat GetCat()
{
...
}
Func<SuperCat> f = GetCat;
上述代碼中當委托f被調用後,應當返回一個SuperCat對象,然而其引用的GetCat方法返回的只是一個Cat對象,同樣,會出現運行時錯誤。
從上述例子中可以看出,對變體的適用範圍進行限制顯然有助於提高編寫更安全的代碼。
6. 變體雜談
6.1 歷史問題
C#的數組支持協變,也就是說下麵的代碼是允許的:
Cat[] cats = new SuperCat[10];
咋一看沒什麼問題,SuperCat的數組當然可以安全轉化為Cat數組使用,然而這意味著下述代碼也能通過編譯:
object[] objs = new Cat[10];
objs[0] = new Dog();
但顯然這會在運行時出現錯誤。數組協變在某些場合下可能有用,但很多時候錯誤的使用或者誤用會導致沒必要的運行時錯誤,因此應當儘可能避免使用這一特性。
6.2 缺點
使用變體要求類型可以在引用類型的層面上進行轉換,簡單來說就是變體只作用於引用類型之間。因此儘管object是所有類型的基類,但是下述代碼依然無法通過編譯:
IEnumerable<object> data = new List<int>();
這是由於int為值類型,顯然值類型無法在引用類型層面轉化為object。