C#與CLR學習筆記(6)—— 輕鬆理解協變與逆變

前言

協變(covariance)和 逆變(contravariance)是我們在學習泛型委託和泛型接口中遇到的比較抽象的一組概念。它們的使用方法,其實是非常簡單的,即:

in 標記的逆變量只能出現在泛型接口的方法的輸入參數中,或者作爲泛型委託的輸入參數;
out 標記的協變量只能作爲泛型接口方法或者泛型委託的輸出參數。

但是,爲什麼需要將變量標記爲逆變或協變,即它們產生的原理,以及工作原理,卻是不太好理解。這裏我們就探究一下。

本質與原因

簡單本質

開門見山地說吧,協變和逆變的本質很簡單,就是實現 將一個 泛型類型變量 轉換爲 泛型實參不同的 同一種泛型類型變量。文字表述不好理解,舉個栗子:假設我定義了一個泛型委託 MyDelegate<T>。那麼,將T標記爲協變或者逆變,就可以實現 將一個 MyDelegate<T1> 變量轉換爲MyDelegate<T2>變量,其中T1T2是兩個泛型的實際參數。
當然,T1T2之間需要滿足一定的關聯:

  • 協變量:泛型類型參數可以從一個類更改爲它的某個基類。例如下面的代碼是合法的:
public delegate void MyDelegate<out T>();

MyDelegate<BaseClass> d_base;
MyDelegate<DerivedClass> d_son = new MyDelegate<DerivedClass>(DoSomething);
d_base = d_son; 
  • 逆變量:泛型類型參數可以從一個類更改爲它的某個派生類。例如下面的代碼是合法的:
public delegate void MyDelegate<in T>();

MyDelegate<BaseClass> d_base = new MyDelegate<BaseClass>(DoSomething);
MyDelegate<DerivedClass> d_son = new MyDelegate<DerivedClass>(DoSomething);
d_son = d_base;

這樣一來,協變和逆變就很好理解了,它們本質就是 類型轉換,只不過是針對泛型類型的。

產生原因

爲什麼泛型類型需要協變和逆變呢?我們知道,DerivedClass是可以隱式地轉換爲BaseClass的。但是當具有繼承關係的類型是 泛型類型的實際參數 的時候,情況就不一樣了。泛型類型會丟失泛型參數的繼承關係
泛型類型的一個特點就是,只要泛型實際參數的類型不同,CLR就會創建不同的類型對象(Type object,可參考這篇文章),把它們當做不同的類型。這也是好理解的。於是乎,即使DerivedClass是派生自BaseClass的,但是 IMyInterface<BaseClass>IMyInterface<DerivedClass>就是完全不同的兩個類型,它們之間不再有任何繼承關係。這就是問題產生的原因。
這種情況稱爲泛型的 不變性(invariance)。在 .NET 4 之前,尚未引入inout關鍵字,所有泛型類型的泛型參數都是不變量(invariant),這意味着泛型類型不可更改。舉一個簡單案例:List<int>List<long> 是不同的集合類型,不能將List<int> 轉換爲 List<long>
泛型的不變性(invariance)對我們的編碼限制很大,因爲有時候我們需要將泛型實參不同的同種泛型類型相互轉化,於是便產生了協變和逆變。考慮這種情況:我們有一個泛型接口IMyInterface<T>,它定義了一個方法DoSomething(),這個方法是返回泛型實參T的一個實例的,見下:

public interface IMyInterface<T>
{
    T DoSomething();
}
public class MyImplementation<T> : IMyInterface<T>
{
    public T DoSomething()
    {
        return (T)Activator.CreateInstance(typeof(T));
    }
}

static void Main(string[] args)
{
    IMyInterface<BaseClass> interface1;
    IMyInterface<DerivedClass> interface2 = new MyImplementation<DerivedClass>();
    interface1 = interface2;//編譯失敗
    interface1.DoSomething();
}

在客戶端調用時,我們定義了IMyInterface<BaseClass>IMyInterface<DerivedClass>。由於這個接口的方法返回的是泛型實參的實例,因此我們希望能將 IMyInterface<DerivedClass>轉換爲IMyInterface<BaseClass>不影響 接口方法的調用,因爲,前者返回的是派生類,可自動隱式轉化爲其父類。但是,上述Main中的轉化代碼會編譯失敗,提示無法轉換。
爲了提高泛型類型的靈活性,.NET 4 引入泛型參數的 inout 關鍵字。我們將上述 IMyInterface<T> 修改爲 IMyInterface<out T>,那麼Main中的代碼就可以編譯通過了。
這就是協變產生的原因,也是爲什麼上文中提到,協變要求泛型參數T只能出現在輸出位置的原因。
對於逆變,原因是類似的。如果泛型參數T是作爲方法的輸入參數或者委託的輸入參數使用,那麼,一個 IMyInterface<DerivedClass>變量傳入的參數就是一個 DerivedClass 實例,那麼我們希望可以將IMyInterface<DerivedClass>變量轉化爲IMyInterface<BaseClass>變量,因爲後者需要 BaseClass 實例作爲輸入,前者的DerivedClass 實例可以自動轉換爲BaseClass 實例,不影響後者方法的調用。因此,逆變就被引入了。
例如,下面代碼能通過編譯,因爲 Action 委託被定義爲 Action<in T>(T obj)

Action<BaseClass> actionBase = MyAction;
Action<DerivedClass> actionDerived;
actionDerived = actionBase ;

廣義上的協變與逆變

其實逆變和協變這兩個概念與泛型是無關的。在.NET中,參數類型本來就是協變的,即:方法可以接受其參數類型的子類型實例作爲實際參數;方法的返回類型是逆變的,返回結果(子類)可以被轉換成其父類。
逆變可以這樣理解:把一個表達式的父類形式替換成其子類的相應形式;
而協變就是:把一個表達式的子類形式替換爲其父類的相應形式。
例如:
DoSomething(BaseClass) 這一個定義可以被 DoSomething(DerivedClass) 這樣調用,就是協變的;
DerivedClass d = GetDerivedClass() 可以被 BaseClass b = GetDerivedClass() 這樣調用,就是逆變的。

參考文獻

[1] Eric Lippert 系列文章
[2] 《C#高級編程》,Christian Nagel
[3] 《CLR via C#》,第四版

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章