程式語言 · Covariance 與 Contravariance

貓的名單能不能當動物名單用?

Chance Lu · 學習文章 · 2026-11-04 20:51
Chance Lu

假設你寫了一個印清單的方法,參數是 IEnumerable<object>,什麼都能印。你手上有一串貓的名字,型別是 List<string>,傳進去完全沒問題。

後來你把參數改成 List<object>,同一串名字就傳不進去了,編譯器直接畫紅線。string 明明是一種 object,為什麼一個可以、一個不行?

差別就在:這個型別讓不讓你往裡面放東西。

可以把泛型想成籠子。只能往外拿的籠子,貓籠拿來當動物籠沒問題;能往裡放的就不行,因為別人看到「動物籠」的標籤,可能會塞一隻狗進去。

下面都用同一套比喻:string 是貓、object 是動物、1(int)是狗。1 也是 object,但不是 string,就像狗是動物,但不是貓。

名詞白話
Invariance(不變)預設規則:<> 裡的型別必須一模一樣,List<string> 就只能當 List<string>
Covariance(共變,out)只往外給東西的型別:<string> 可以當 <object> 用
Contravariance(逆變,in)只往裡收東西的型別:方向反過來,<object> 可以當 <string> 用
委派(delegate)把「一個方法」存成變數的型別,例如 Func、Action

第一步:List<string> 為什麼不能當 List<object>

你早就知道 object o = "咪咪"; 沒問題,所以很自然會以為整個清單也能這樣換。

問題是清單不只能讀,還能 Add。假設編譯器放行,看看會發生什麼事:

  1. cats 是貓的清單,裡面有「咪咪、小花」。
  2. List<object> cage = cats;:同一個籠子貼了兩張標籤,cats 寫「貓」,cage 寫「動物」。
  3. 從 cage 看,這是動物籠,所以 cage.Add(1) 完全合法,狗就這樣被塞進去了。
  4. 拿 cats 的人以為裡面全是貓,cats[2] 卻拿到 1。
① 貓的清單 cats:List<string> "咪咪" "小花" ② 假設放行 cage = cats cats=貓 cage=動物 "咪咪" "小花" 同一個籠子,兩張標籤 ③ 照動物籠塞 cage.Add(1) "咪咪" "小花" 1(狗) ④ 拿貓拿到狗 string s = cats[2] "咪咪" "小花" 1 ✕ 1 不是 string 實際上 C# 在第 ② 步就擋下(error CS0029),③ ④ 根本不會發生

圖 A:如果放行,狗會從「動物」那張標籤被塞進貓籠(動畫依序點亮四步,點圖可放大)

所以 C# 在第 2 步就不讓你過。List<T> 的 T 是 invariant(不能換):<> 裡寫什麼,就只能當什麼用。

能往裡放的籠子換上更大的標籤,等於讓別人有機會塞錯東西進來。

第二步:只往外拿,就可以換大標籤(out)

可是很多時候你只是要讀,像開頭那個印清單的方法,根本不會往裡面塞東西。連這種情況都擋掉,就太嚴格了。

這時就輪到 out。在 T 前面加 out,等於跟編譯器保證:T 只會出現在「往外給」的位置,例如回傳值。

這個保證不是說說而已,編譯器會檢查。如果你在標了 out T 的介面裡寫 void Put(T item),會直接編譯錯誤(練習 3 會看到)。

out 就是只出不進,所以 <貓> 可以當 <動物> 用。這就叫 covariance。

第三步:只往裡收,方向反過來(in)

有些型別剛好相反,只收不吐。例如 Action<T>:你給它一個值,它去做事,什麼都不回傳。

這種型別用的是 in:T 只出現在「往裡收」的位置,例如參數。能換的方向也剛好和 out 相反。

Covariance:只有出口 IEnumerable<out T>、Func<out T> 貓籠 <string>只能往外拿 拿出來的一定是動物 ✔ <string> 能當 <object> Contravariance:只有入口 Action<in T>、IComparer<in T> 收容口 <object>什麼動物都收 送進來的貓也是動物 ✔ <object> 能當 <string> Invariance:兩邊都開 List<T> 貓籠 <string>能拿也能放 有人會照動物籠塞狗 ✕ 型別要一模一樣

圖 B:看籠子開了哪個門,決定標籤能往哪邊換(點圖可放大)

整理一下:out 是 <貓> 可以當 <動物>;in 是 <動物> 可以當 <貓>;兩個方向都用到的,就必須一模一樣。Covariance 和 contravariance 講的就是這些。

第四步:陣列是例外,錯到執行時才爆

照第一步的道理,陣列能讀也能寫,string[] 不該能當 object[]。但 C# 從 1.0 起就允許,這叫 array covariance。

那放錯東西時誰來擋?陣列和泛型在這裡走了不同的路。

陣列的做法是在籠子門口站一個警衛:每次寫入,執行時都會檢查這個值符不符合陣列原本的型別,不符合就丟出 ArrayTypeMismatchException。

object[] arr = new string[2]; 編譯器放行 arr[0] = "咪咪" arr[1] = 1 門口警衛(執行時)這個值真的是 string 嗎? 標籤:object[] 真身:string[]"咪咪" 放進去了 ✕ ArrayTypeMismatchException 泛型 List 在寫程式時就擋(圖 A);陣列放行,每次寫入才檢查

圖 C:陣列把檢查留到執行時,放錯要跑到那一行才爆(點圖可放大)

為什麼陣列要這樣?C# 編譯器團隊的 Eric Lippert 說明過:.NET 執行環境為了支援 Java 這類語言加了 array covariance,C# 也就跟著沿用。這樣做有兩個代價:

泛型介面和委派的 covariance、contravariance 是後來(C# 4)才加的,做法不一樣:編譯時就確保安全,不用靠警衛。

陣列是先放行,執行時再檢查;泛型只放行安全的方向,編譯時就檢查完。

兩個限制:只有介面和委派、只有參考型別

看到這裡,你可能會想把自己寫的 class 也標上 out,或讓 int 清單也支援 covariance。這兩件事都做不到:

為什麼 int 不行?string 當 object 用,東西還是同一個,只是換張標籤。int 要變成 object 卻得裝箱,每個元素都要另外包一個盒子。Covariance 只會換標籤,不會幫你一個一個裝箱。

Covariance 和 contravariance 只是換標籤,所以只適用於介面、委派,以及 class、string 這類參考型別。

常見的坑

動手做

先開一個練習資料夾:

mkdir lab && cd lab && dotnet new console

每個練習都把 Program.cs 整個換掉。先猜,再跑,再對答案。下面的輸出都是在 .NET 10 實際跑出來的。

練習 1:陣列放錯,什麼時候爆?

建檔

object[] arr = new string[2];   // 編譯器放行
arr[0] = "咪咪";                 // OK:本來就是 string
Console.WriteLine("第一格放好了");
arr[1] = 1;                      // 1 是 object,但不是 string
Console.WriteLine("第二格也放好了");

先猜編譯得過嗎?會印出幾行?

執行dotnet run

第一格放好了
Unhandled exception. System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.

這證明編譯時完全沒擋,跑到第 4 行才被門口的警衛攔下(圖 C)。

練習 2:清單換標籤,編譯器擋哪幾行?

建檔

var cats = new List<string> { "咪咪", "小花" };

IEnumerable<object> look = cats;   // 只能往外拿
foreach (var x in look) Console.WriteLine(x);

List<object> cage = cats;          // 能往裡放
cage.Add(1);

var nums = new List<int> { 1, 2 };
IEnumerable<object> boxed = nums;  // int 不是參考型別

先猜第 3、6、10 行,哪幾行會錯?

執行dotnet build

Program.cs(6,21): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.List<object>'
Program.cs(10,29): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<int>' to 'System.Collections.Generic.IEnumerable<object>'. An explicit conversion exists (are you missing a cast?)

你會看到第 3 行沒錯(out,只出不進);第 6 行錯(圖 A 的第 ② 步);第 10 行錯(int 要裝箱,不能只換標籤)。

練習 3:out 和 in 都能編譯

建檔

// out:只往外給,Func<string> 可以當 Func<object>
Func<string> getCat = () => "咪咪";
Func<object> getAny = getCat;
Console.WriteLine($"out:拿到 {getAny()}");

// in:只往裡收,Action<object> 可以當 Action<string>
Action<object> print = o => Console.WriteLine($"in:收到 {o}");
Action<string> printCat = print;
printCat("小花");

// in:會比任何 object 的比較器,也能拿來排 string
IComparer<object> byText = Comparer<object>.Create(
    (a, b) => string.CompareOrdinal(a.ToString(), b.ToString()));
IComparer<string> forCats = byText;
var names = new List<string> { "Tom", "Amy", "Leo" };
names.Sort(forCats);
Console.WriteLine("排好:" + string.Join(", ", names));

執行dotnet run

out:拿到 咪咪
in:收到 小花
排好:Amy, Leo, Tom

再試自己寫一個 out 介面,卻放了「往裡放」的方法:

Console.WriteLine("hi");

interface ICage<out T>
{
    T Take();            // 往外給:OK
    void Put(T item);    // 往裡放:out 不准
}

dotnet build:

Program.cs(6,14): error CS1961: Invalid variance: The type parameter 'T' must be contravariantly valid on 'ICage<T>.Put(T)'. 'T' is covariant.

這證明out 不是隨便標的:標了只出不進,編譯器就不讓你偷開入口。

Go 對照

Go 的泛型沒有 covariance 和 contravariance。[]string 不能當 []any,要轉就得自己一個一個複製過去(見 Go FAQ)。

✔ 過關標準

能不看筆記回答下面 4 題,這篇就過了。

  1. IEnumerable<string> 可以當 IEnumerable<object>,List<string> 卻不能當 List<object>,差在哪?看答案收合
    差在能不能往裡放。
    • IEnumerable 只出不進(out T),拿出來的 string 一定是 object。
    • List 能 Add。如果放行,別人會照 List<object> 的標籤塞一個 1 進去,拿 string 的人就拿到 1(圖 A)。
  2. 為什麼 Action<object> 能當 Action<string>,反過來卻不行?看答案收合
    Action 只往裡收(in T),方向和 out 相反。
    • 會處理任何 object 的方法,送 string 進去一定處理得了。
    • 只會處理 string 的方法,被當成 Action<object> 後可能收到 1,它處理不了。
  3. object[] arr = new string[2]; arr[0] = 1; 為什麼編譯得過、執行才爆?泛型為什麼不這樣設計?看答案收合
    陣列從 C# 1.0 就允許 covariance,放錯靠執行時的檢查擋(ArrayTypeMismatchException)。
    • 這是 .NET 執行環境為支援 Java 類語言加的,代價是錯誤晚發現、每次寫入都要檢查。
    • 泛型的 covariance、contravariance 只放行安全的方向,在編譯時就擋掉。
  4. 為什麼 IEnumerable<int> 不能當 IEnumerable<object>?自己寫的 class 能標 out 嗎?看答案收合
    Covariance 只換標籤,int 變 object 要裝箱,不是換標籤;class 不能標。
    • in / out 只能用在介面和委派,而且只對參考型別有效。
    • int 要轉就自己做:nums.Select(n => (object)n)。

參考來源