假設你寫了一個印清單的方法,參數是 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 |
你早就知道 object o = "咪咪"; 沒問題,所以很自然會以為整個清單也能這樣換。
問題是清單不只能讀,還能 Add。假設編譯器放行,看看會發生什麼事:
cats 是貓的清單,裡面有「咪咪、小花」。List<object> cage = cats;:同一個籠子貼了兩張標籤,cats 寫「貓」,cage 寫「動物」。cage 看,這是動物籠,所以 cage.Add(1) 完全合法,狗就這樣被塞進去了。cats 的人以為裡面全是貓,cats[2] 卻拿到 1。圖 A:如果放行,狗會從「動物」那張標籤被塞進貓籠(動畫依序點亮四步,點圖可放大)
所以 C# 在第 2 步就不讓你過。List<T> 的 T 是 invariant(不能換):<> 裡寫什麼,就只能當什麼用。
能往裡放的籠子換上更大的標籤,等於讓別人有機會塞錯東西進來。
可是很多時候你只是要讀,像開頭那個印清單的方法,根本不會往裡面塞東西。連這種情況都擋掉,就太嚴格了。
這時就輪到 out。在 T 前面加 out,等於跟編譯器保證:T 只會出現在「往外給」的位置,例如回傳值。
IEnumerable<out T>:只能一個一個往外拿,沒有 Add。從貓籠裡拿出來的一定是貓,貓當然是動物,所以 IEnumerable<object> look = cats; 可以。Func<out TResult>:回傳值也是往外給。一個會交出貓的方法,說它「會交出動物」也沒錯。這個保證不是說說而已,編譯器會檢查。如果你在標了 out T 的介面裡寫 void Put(T item),會直接編譯錯誤(練習 3 會看到)。
out 就是只出不進,所以 <貓> 可以當 <動物> 用。這就叫 covariance。
有些型別剛好相反,只收不吐。例如 Action<T>:你給它一個值,它去做事,什麼都不回傳。
這種型別用的是 in:T 只出現在「往裡收」的位置,例如參數。能換的方向也剛好和 out 相反。
Action<in T>:Action<object> print 什麼都能印,就像什麼動物都收的收容所。把它放到需要 Action<string> 的地方很安全,因為送進來的貓本來就是動物。反過來就不行:只會照顧貓的地方被當成什麼都收,就可能有人送狗進來,它應付不了。IComparer<in T>:比較器只接收兩個值、回傳一個 int。能比較任何 object 的比較器,拿來排 string 當然也行。圖 B:看籠子開了哪個門,決定標籤能往哪邊換(點圖可放大)
整理一下:out 是 <貓> 可以當 <動物>;in 是 <動物> 可以當 <貓>;兩個方向都用到的,就必須一模一樣。Covariance 和 contravariance 講的就是這些。
照第一步的道理,陣列能讀也能寫,string[] 不該能當 object[]。但 C# 從 1.0 起就允許,這叫 array covariance。
那放錯東西時誰來擋?陣列和泛型在這裡走了不同的路。
陣列的做法是在籠子門口站一個警衛:每次寫入,執行時都會檢查這個值符不符合陣列原本的型別,不符合就丟出 ArrayTypeMismatchException。
圖 C:陣列把檢查留到執行時,放錯要跑到那一行才爆(點圖可放大)
為什麼陣列要這樣?C# 編譯器團隊的 Eric Lippert 說明過:.NET 執行環境為了支援 Java 這類語言加了 array covariance,C# 也就跟著沿用。這樣做有兩個代價:
泛型介面和委派的 covariance、contravariance 是後來(C# 4)才加的,做法不一樣:編譯時就確保安全,不用靠警衛。
陣列是先放行,執行時再檢查;泛型只放行安全的方向,編譯時就檢查完。
看到這裡,你可能會想把自己寫的 class 也標上 out,或讓 int 清單也支援 covariance。這兩件事都做不到:
List<T> 永遠是 invariant。要用 covariance,就改用它實作的介面,例如 IEnumerable<T>、IReadOnlyList<T>。IEnumerable<int> 不能當 IEnumerable<object>。為什麼 int 不行?string 當 object 用,東西還是同一個,只是換張標籤。int 要變成 object 卻得裝箱,每個元素都要另外包一個盒子。Covariance 只會換標籤,不會幫你一個一個裝箱。
Covariance 和 contravariance 只是換標籤,所以只適用於介面、委派,以及 class、string 這類參考型別。
List<object>,別人就傳不進 List<string>。如果方法只會讀,參數就改成 IEnumerable<object> 或 IReadOnlyList<object>。(IEnumerable<object>)nums 編譯會過,執行時卻會丟 InvalidCastException(實際跑過)。真的要轉,就自己一個一個轉:nums.Select(n => (object)n)。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都把 Program.cs 整個換掉。先猜,再跑,再對答案。下面的輸出都是在 .NET 10 實際跑出來的。
建檔
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)。
建檔
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 要裝箱,不能只換標籤)。
建檔
// 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 的泛型沒有 covariance 和 contravariance。[]string 不能當 []any,要轉就得自己一個一個複製過去(見 Go FAQ)。
能不看筆記回答下面 4 題,這篇就過了。
out T),拿出來的 string 一定是 object。List<object> 的標籤塞一個 1 進去,拿 string 的人就拿到 1(圖 A)。in T),方向和 out 相反。
Action<object> 後可能收到 1,它處理不了。object[] arr = new string[2]; arr[0] = 1; 為什麼編譯得過、執行才爆?泛型為什麼不這樣設計?看答案收合nums.Select(n => (object)n)。[]T 不能直接轉 []interface{})