C# 4.0:Covariance 與 Contravariance 觀念入門

.NET Framework 4.0 進一步支援了泛型(generic types)的 covariance 與 contravariance 型別參數,這兩個術語有點抽象,不是很容易說明白。這裡嘗試用一些簡單的例子來說明相關的基礎概念。

核心概念:型別相容

就如本小節的標題所點出的,其實無論 invariance、covariance、還是 contravariance,這些「-variance」所牽涉的都是有關型別安全的議題,尤其是靜態的(編譯時期的)型別安全檢查。進一步說,設計這些語言功能的主要用意,是讓你在寫程式時能夠把某個型別的物件當成另一種(相容)型別的物件來處理

這些 variance 概念並不是 .NET Framework 4.0 才有,其實 C# 的陣列就已經支援 covariance 了。那麼,什麼是 covariance 呢?

先想一下這個簡單的問題:字串陣列是不是一種物件陣列?或者,一串香蕉是不是一串水果?答案應該很明顯,請看以下範例程式碼。

程式列表 1:C# 本來就支援陣列的相容型別轉換。
string[] strings = new string[3];
object[] objects = strings;   // 將字串陣列 assign 給物件陣列(隱含轉型)

objects[0] = DateTime.Now;    // 執行時會發生 ArrayTypeMismatchException!
string s = (string)objects[0];

我們知道,字串也是一種物件,那麼從型別相容的原則來看,將字串陣列指派(assign)給物件陣列就沒什麼好奇怪的了。這裡的型別相容原則,指的是比較 「大」的型別可以兼容同一家族後代的「小」型別(口訣:大的可以吃小的;這裡的「大」、「小」,指的是繼承階層中的位階高低)。換言之,比較「小」的子代物件可以 assign 給同一族系的父代型別的變數。有點像繞口令?以下是比較正式的說法:

若型別 A 繼承自型別 B(A 比 B 小),則型別 A 可以隱含(自動)轉換為型別 B,且陣列 A[] 可以指派給陣列 B[]。

這是 C# 原本就支援的陣列 covariance。

剛才的範例程式碼雖然在 C# 2/3/4 都可以通過編譯,但是這樣的 covariance 寫法卻是有問題的(註1),因為它允許我們將一個 DateTime 物件丟進物件陣列,而那個物件陣列裡面的每個元素的型別其實是 string;把 DateTime 物件 assign 給 string 變數,執行時當然會出錯了。

註1:既然這種寫法不太安全,為什麼 C# 要支援陣列 covariance 呢?根據 Jonathan Allen 的說法,.NET 甫推出時,為了提高 C# 與 Java 的相容性,所以 C# 和 VB 都向 Java 看齊,加入了這種寫法。雖然很多人覺得這是錯誤的決定,但此決策有其歷史背景,現在要拿掉似乎是不太可能了。

Covariance

儘管 C# 陣列的 covariance 不太「安全」,但根據型別相容的規則,編譯器還是得允許這麼寫。不過,同樣的概念如果套用到 C# 2.0 開始提供的泛型(generic types)就不是這麼回事了。考慮以下程式片段:

程式列表 2:原以為理所當然的寫法,換成泛型串列就卡住了。
List<string> stringList = new List<string>();
List<object> objectList = stringList; // 無論哪一代 C# 都不能這樣寫!

既然字串是一種物件,字串串列自然也是一種物件串列囉,這是很直覺的寫法。可是,即使到了 C# 4.0,也不允許直接把泛型字串串列 assign 給物件串列,編譯器會告訴你:

Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.List<object>'

為什麼會這樣?這是因為泛型本身具有不變性(invariance)的緣故 。你可以把 invariance 想成呼叫方法時傳遞的 ref 參數,因為它們的效果是一樣的:當某個參數是以傳參考(pass by reference)的方式傳遞時,傳遞給該參數的變數型別必須跟宣告時的參數型別完全一樣,否則編譯器將視為不合法的陳述式。

可是,字串串列明明就是一種物件串列,寫程式時卻不能將字串串列 assign 給物件串列,在某些應用場合總是不太方便。於是,.NET Framework 4.0 針對泛型的部分進一步支援了 covariance 和 contravariance,而 C# 4.0 和 Visual Basic 10.0 也提供了對應的語法。在 C# 4.0,「程式列表 2」的程式碼可以改成這樣:

程式列表 3:C# 4.0 的泛型串列型別轉換
List<string> stringList = new List<string>();
IEnumerable<object> objects = stringList;  // C# 2/3 不允許,C# 4.0 OK!

如此一來,雖然你還是不能直接把字串串列指派給物件串列(基於型別安全的理由),但相容型別之間的串列指派操作已經可以透過泛型介面 IEnumerable<T> 獲得解決。這是 covariance 的好處--你可以將比較「小」的型別當作比較「大」的型別來操作。

可是,為什麼同樣的寫法,C# 2/3 編譯會失敗,C# 4.0 卻能編譯成功?答案就在 .NET Framework 4.0 的 IEnumrable 的原型宣告:

public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}

注意泛型參數 T 的前面多了一個修飾詞:out。這是 C# 4.0 新增的 covariance 語法,它的作用是告訴編譯器:IEnumerable<T> 這個泛型介面的型別參數 T 在該介面中只能用於輸出的場合。簡單地說,型別 T 在這個泛型介面中只能用於 method 的傳回值,而不能用來當作 method 的參數。當你做了這樣的宣告,編譯器就會強制檢查這項規則。因此,下面這段程式碼會無法通過編譯:

public interface IFoo<out T>
{
    string Convert(T obj);  // 編譯失敗:型別 T 在這裡只能用於方法的傳回值,不可當作參數。
    T GetInstance();        // OK!
}

如果你對「covariant 型別只能用於輸出的場合」這句話沒有太大的感覺,不妨記住它在寫程式時提供的好處:任何參考型別(reference type)的泛型串列,你都可以將它當作 IEnumerable<object> 來操作。 或許您會質疑:「這樣寫固然方便,可是用 IEnumerable<T> 把一串物件包起來,難道不會跟前面的陣列 covariance 範例一樣產生型別安全的問題嗎?」答案是不會。因為 IEnumerable<T> 的操作都是唯讀的,你無法透過它去替換或增刪串列中的元素。

在看更多範例之前,讓我們稍微整理一下前面提過的兩個重點:
  1. Covariance 指的是可以將比較「小」的型別當作比較「大」的型別來處理。
  2. 無論你對 covariance 的理解為何,編譯器在乎的是:宣告為 out 的泛型參數只能用於輸出的場合。

泛型介面的 Covariance 應用例

經過前面的討論,如果您已經瞭解 covariance 的意義和作用,大可跳過這個小節。如果還是覺得有點模糊,就再看一個例子吧。假設我們設計了三個類別:Fruit(水果)、Apple(蘋果)、和 Peach(水蜜桃),其中 Apple 和 Peach 皆繼承自 Fruit(蘋果和水蜜桃都是一種水果)。這些類別的屬性和方法並不重要,所以這裡就不列出來。現在我們打算建立兩個串列,分別用來儲存蘋果和水蜜桃,如 「程式列表 4」所示。

程式列表 4:蘋果與水蜜桃串列
// 蘋果串列
List<Apple> apples = new List<Apple>();
apples.Add(new Apple());
apples.Add(new Apple());

// 水蜜桃串列
List<Peach> peaches = new List<Peach>();
peaches.Add(new Peach());
peaches.Add(new Peach());

接 下來,我們可能需建立一個綜合水果串列,把剛才的 apples 和 peaches 裡面的元素都加到這個串列裡。以下程式碼示範了在 C# 4.0 的兩種作法,一種是建立一個 List<Fruit> 泛型串列,另一種作法則是使用 IEnumerable<T>。

程式列表 5:綜合水果串列。
List<Fruit> fruits = new List<Fruit>();
fruits.AddRange(apples);   // C# 4.0 OK! C# 2/3 編譯失敗。
fruits.AddRange(peaches);  // C# 4.0 OK! C# 2/3 編譯失敗。

// 或者也可以這樣寫:
IEnumerable<Fruit> fruits2 = apples;  // C# 4.0 OK! C# 2/3 編譯失敗。
fruits2 = fruits2.Concat(peaches);

是否有點驚訝?原來「把一堆蘋果和水蜜桃包裝成一個綜合水果禮盒」的概念在 C# 3.0 竟然無法用這麼直觀的方式實作。在這個範例裡面,我們先示範以泛型串列的 AddRange 方法來加入蘋果和水蜜桃串列。AddRange 方法的原型宣告是:

public void AddRange(IEnumerable<T> collection)

請注意傳入參數的型別是 IEnumerable<T>,其中的 T 就是程式在宣告 fruits 變數時指定的參數型別,所以這裡的 AddRange 的傳入參數型別就是 IEnumerable<Fruit>。當我們將 apples 和 peaches 當作引數傳遞給 AddRange 方法時,就等於把比較「小」的 List<Apple> 和 List<Peach> 指派給比較「大」的 IEnumerable<Fruit>。編譯器知道 IEnumerable<out T> 的型別 T 符合 covariance,故基於型別相容原則,編譯器會允許這個指派操作,並自動轉換型別(注意只是型別的轉換,其中並未建立任何新物件)。

第二種寫法是先把 apples 指定給 IEnumerable<Fruit> 的串列(這裡也同樣有發生 covariance 的隱含轉型動作),然後再利用 System.Linq.Enumerable 類別提供的擴充方法 Concat 來附加另一個 peaches 串列。

你會發現,不管哪一種寫法,主角都是泛型介面 IEnumerable<T>。事實上,.NET Framework 4.0 只有泛型介面和泛型委派有支援 covariance 和 contravariance,而且這類型別的數量並不多。所以在解釋 variance 概念時,大部分都是拿比較常用的 IEnumerable<T> 來舉例說明。

Covariance 討論得夠多了,接下來要看的是 contravariance。

Contravariance

瞭解 covariance 之後,contravariance 應該就比較容易了。基本上,二者只是「方向相反」而已:
  • Covariance 限定用於輸出的場合,contravariance 則是指泛型參數型別只能用於輸入的場合(方法的傳入參數)。
  • Covariance 讓你可以將比較「小」的型別 assign 給比較「大」的型別;contravariance 則是允許將比較「大」的型別 assign 給比較「小」的型別。
沿用前面的水果例子,假設 Fruit 類別有一個公開屬性 SweetDegree,代表水果的甜度。現在我們想要對一個蘋果串列依甜度排序,程式碼如下所示。

程式列表 6:Contravariance 範例。
public class ContravarianceDemo
{
    public void Run()
    {
        // 建立蘋果串列.
        List<Apple> apples = new List<Apple>();
        apples.Add(new Apple() { SweetDegree = 54 });
        apples.Add(new Apple() { SweetDegree = 60 });
        apples.Add(new Apple() { SweetDegree = 35 });

        // 依甜度排序.
        IComparer<Fruit> cmp = new FruitComparer();
        apples.Sort(cmp);  

        foreach (Fruit fruit in apples)
        {
            Console.WriteLine(fruit.SweetDegree);
        }
    }
}

public class FruitComparer : IComparer<Fruit>
{
    public int Compare(Fruit x, Fruit y)
    {
        return x.SweetDegree - y.SweetDegree;
    }
}

注意此範例中的 apples.Sort(cmp) 方法呼叫。在撰寫這行敘述時,Visual Studio 的 Intellisense 功能會提示這裡的 apples.Sort 方法需要的參數型別是 IComparer<Apple>,但我們傳入的參數卻是 IComparer<Fruit>。也就是說,這裡會發生「把較大型別指派給較小型別」的動作(跟 covariance 正好相反)。之所以能夠這樣寫,是因為 ICompare<T> 的泛型參數 T 在 .NET Framework 4.0 已經加入了 contravariance 的宣告(關鍵字 in):

interface IComparer<in T>
{
int Compare(T x, T y); 
}

小結

整理幾個重點:
  • Covariance 在型別宣告的語法上指的是泛型參數僅限於輸出的場合(方法的傳回值),其作用是讓你可以將比較「小」的型別 assign 給比較「大」的型別。
  • Contravariance 在型別宣告的語法上指的是泛型參數僅限於輸入的場合(方法的傳入參數),其作用是讓你可以將比較「大」的型別 assign 給比較「小」的型別。
  • Invariance 指的是同時可用於輸入和輸出的場合(因此傳遞變數時,型別必須與宣告時的參數型別完全一致,無法做隱含型別轉換)。
  • .NET Framework 4.0 只有泛型介面和泛型委派才有支援 covariance 和 contravariance,一般的類別則不支援。
最後簡單提一下術語的翻譯。Invariant 和 invariance 我是比較傾向翻譯成「不可變(的)」以及「不變性」;Covariant 和 covariance 也許可譯為「共變的」、「共變性」;Contravariant 和 contravariance 則為「逆變的」與「逆變性」。

9 則留言:

  1. 贊喔~你的例子相當淺顯易懂~

    Vivid

    回覆刪除
  2. Thanks! Vivid 說 OK,我就放心多了 :)

    回覆刪除
  3. 感覺起來Covariance 和 Contravariance就好像是泛型中有限制的多型(Polymorphism)

    回覆刪除
  4. 有限制的多型....有意思的說法。我想,多型和 *variance 都和類別的繼承有關,而多型的基本精神在於「使用相同操作,在執行時期會自動根據物件實際的型別而產生不同效果」,可免除撰寫一堆 if...else 或 switch...case 等判斷不同型別來執行不同操作的程式碼。而 *variance 的關鍵在於型別相容的 assign 動作,也就是讓我們能夠更自然的在「理應相容」型別的物件之間做 assignment,如此一來,在處理泛型串列的時候,或許也有助於實現多型機制。例如:我們可以把一堆 List<Circle> 和 List<Triangle> 都塞進 IEnumerable<Shape> 裡面,然後對那個 "形狀" 串列的每個元素呼叫 Draw() 方法。
    呃...好像解釋得太複雜了 XD
    總之,我覺得二者有些關聯,但仍不太一樣。

    回覆刪除
  5. 這篇文章相當淺顯易懂阿....

    回覆刪除
  6. 請問~ 那在 2/3 時要怎麼寫?
    只能一個個重新add進去list<object>嗎?

    回覆刪除
  7. C# 2/3 的話,大概就只能一個一個塞進物件串列了。或者改用陣列來操作。

    回覆刪除
  8. fruits2 = fruits.Concat(peaches); ==> fruits2 = fruits2.Concat(peaches);

    回覆刪除

技術提供:Blogger.
回頂端⬆️