先做點准備工作,定義兩個類:Animal類和其子類Dog類,一個泛型接口IMyInterface<T>, 他們的定義如下:
{
}
public class Dog : Animal
{
}
interface IMyInterface<T>
{
}
一. 協變和逆變的定義
從.Net Framework 4.0開始引入了一個新特性:協變與逆變,有人翻譯為協變和反變,他們實際上所指的就是不同類型之間的一種轉變(Variance). 那么具體來說什么是協變和逆變呢?
就拿普通類來做個類比吧,對於普通類來說,下面兩種轉換你肯定不會陌生:
Dog dog = (Dog)animal; // 類型的強制轉換
與上面兩種轉換相類似,從.Net4.0開始,對於泛型接口來說,下面兩種轉換就是協變和逆變:
IMyInterface<Dog> iDog = null;
iAnimal = iDog; // “子類”向“父類”轉換,即泛型接口的協變
iDog = iAnimal; // “父類”向“子類”轉換,即泛型接口的逆變
所以如果進行簡單類比下這兩者的定義的話就是:所謂協變就是泛型接口從子類向父類轉化,所謂逆變就是父類向子類轉換. 在.Net 4.0以前,是沒有協變逆變的概念的,即上面兩行代碼中的任何一行都是不允許的, 因為雖然IMyInterface<Animal>和IMyInterface<Dog>表面上看起來有點類似父類和子類的關系, 但實際上他們根本沒有任何繼承上的關系. 從.Net 4.0開始, 有條件地允許上面的協變和逆變的兩種轉化. 這個條件就是在申明接口的時候使用in或out關鍵字來修飾限制泛型參數T的使用范圍.
實際上,如果你在Visual Studio嘗試了上面的兩行協變和逆變的代碼的話, 你就會發現, 那兩行代碼根本就不能編譯通過,原因就在於我們並未按照語法所要求的那樣使用修飾符in或out, 但是如果我們在泛型接口的聲明中加上了in或out限制條件來修飾泛型參數T的時候,代碼就可以編譯通過了. 如果我們像下面這樣聲明接口:
{
}
那么協變(即iAnimal = iDog;)是可以編譯通過的,逆變則不行.而如果我們像這樣聲明接口:
{
}
那么逆變(即iDog = iAnimal;)是可以編譯通過的,協變則不行.所以我們總結起來就是: 用out來修飾泛型參數的時候則允許協變,用in來修飾泛型參數的時候則允許逆變.
那么現在你很可能會想到幾個問題,為什么.Net4.0以前不支持協變和逆變呢? 為什么.Net4.0開始微軟要引入協變逆變呢? in和out又代表了什么意思呢?
二. 為什么以前不支持協變和逆變
注意:以下代碼只是基於假設的分析用,不能實際編譯和執行.
為什么.Net 4.0以前不支持協變和逆變呢? 還是以本文開頭的准備工作中的兩個類一個接口為例, 不過那個接口需修改一下,給它增加兩個方法,如下:
{
void ShowMe(T t);
T GetMe();
}
如果允許協變的話,那么在調用ShowMe方法的時候就可能出現問題, 請考慮如下代碼:
IMyInterface<Animal> iAnimal = null;
IMyInterface<Dog> iDog = null;
iAnimal = iDog;
iAnimal.ShowMe(animal);
我們在寫iAnimal.ShowMe(animal)這行代碼的時候,Visual Studio按照IMyInterface<Animal>來進行代碼提示,如下圖所示
Visual Studio要我們輸入Animal類型的對象,但是在運行時執行ShowMe方法的時候, 因為實際對象是IMyInterface<Dog>,所以實際執行的方法是ShowMe(Dog t)方法, 所以最終就有可能導致用一個Animal的實例去調用ShowMe(Dog t)方法,這顯然是錯誤的!
與上面對協變的分析類似,再來看逆變, 如果允許逆變的話,那么在調用GetMe方法的時候就可能出現問題,代碼如下:
IMyInterface<Animal> iAnimal = null;
IMyInterface<Dog> iDog = null;
iDog = iAnimal;
Dog dog=iDog.GetMe();
我們在寫Dog dog=iDog.GetMe()這行代碼的時候,Visual Studio按照IMyInterface<Dog>來進行代碼提示,如下圖所示
Visual Studio提示返回Dog類型的對象,但是在運行時執行GetMe方法的時候, 因為實際對象是IMyInterface<Animal>,所以實際執行的方法GetMe()的返回值為Animal, 所以最終就有可能導致用一個Animal的實例去賦值給dog,這顯然也是錯誤的!
通過上面的分析我們知道, 如果允許協變的話,那么可能會導致在有泛型輸入參數的方法在運行時出錯, 如果允許逆變的話, 則有可能導致在有泛型返回值的方法在運行時出錯. 由此可見,泛型參數T用在方法的輸出參數還是輸入參數決定了這個泛型接口是支持協變還是逆變. 歸根結底, 無論是協變問題還是逆變問題都是因為這樣的一個原則: 子類可以向父類隱式轉換, 但是父類不能向子類隱式轉換. 協變和逆變的問題只是這個原則變化了一個偽裝外衣而已.
三. 為什么.Net4.0開始要引入協變逆變, 以及in和out的用法
如果沒有協變的情況下,假設有這樣一個場景,我們需要將IEnumerable<Animal>和IEnumerable<Dog>合並成List<Animal>,代碼如下:
IEnumerable<Dog> dogs = GetDogs();
List<Animal> list = new List<Animal>();
list.AddRange(animals);
list.AddRange(dogs); // 因為沒有協變,所以這行代碼編譯報錯
上面的最后一行代碼會編譯報錯,因為沒有協變,所以就不能用IEnumerable<Dog>作為參數去調用要求參數為IEnumerable<Animal>, 那么我們就不得不為此寫一個循環, 把IEnumerable<Dog>中的Dog都提取出來,隱式轉換為Animal再一個一個加入到list中去. 是不是覺得有點麻煩了, 明明就只是把Dog轉化為Animal, 為什么微軟你就不能代勞一下呢?
由此看來, 支持協變和逆變確實能讓我們方便地寫出更簡潔優雅的代碼, 而為了避免出現上文中所討論的錯誤, 必須對泛型參數T的使用范圍進行限制, 所以引入了in和out修飾符, 從.Net4.0開始,用in來修飾泛型參數T的時候, 表示T只能用於方法的輸入參數, 此時參數T是逆變的, 如果你將T用於輸出參數的話就會編譯報錯, 同理, out所修飾的T只能用於輸出參數,此時參數T是協變的. 並且,微軟改寫了很多的原來的泛型接口, 盡可能地加上了in或out修飾符, 讓這些泛型接口支持逆變或者協變, 例如將IEnumerable<T>重新聲明為IEnumerable<out T>。. 所以在.Net4.0中, 上面的那行代碼list.AddRange(dogs)就不再會編譯報錯了.
四. 總結
通過上面的一些簡單示例代碼和說明, 相信大家對泛型的協變和逆變應該有了一個基本的了解. 協變和逆變的引入讓我們可在不同的泛型接口之間可以相互賦值, 它提供了一種類似多態的特性, 增加了靈活性, 極大地方便了代碼的編寫. 但是同時也在一定程度上限制了泛型參數T使用的自由度, 被in或out修飾的泛型參數T將只能用於輸入參數或者輸出參數. 對於只需要輸入或者只需要輸出的泛型接口來說無疑是有利無弊的. 當然, 如果我們不加修飾符in或out, 則T仍然可以同時用於輸入參數和輸出參數的. 除了泛型接口外, 泛型委托也有協變和逆變的問題, 正如本文中所提到的那樣, 泛型接口也好,泛型委托也罷, 甚至包括逆變協變對象作為方法參數的時候, 他們的協變逆變的問題實際上都源於一個根本的原則: 子類可以向父類隱式轉換, 但是父類不能向子類隱式轉換.