在 C# 4.0 中,協變與逆變的引入極大地增強了泛型介面與委派的靈活性。然而,MSDN 的官方定義——「協變允許使用比原始指定更衍生的類型,逆變允許使用較不衍生的類型」——雖然精確,卻往往讓開發者感到抽象難懂。本文將透過直觀的類比、程式碼示範與 IL 層面的剖析,幫助你徹底掌握這兩大核心概念。
抛開晦澀的術語,我們可以用「賦值相容性」的方向來區分:
Dog → Animal。子類別實例賦值給父類別變數,這是「和諧的、自然的」變化,故稱協變。關鍵字 out 代表「輸出」,型別參數只作為返回值出現。Animal → Dog。父類別委派賦值給子類別委派,這是「逆常的、看似不合邏輯」的變化,故稱逆變。關鍵字 in 代表「輸入」,型別參數只作為參數出現。預設情況下,泛型是不變的。即使 Dog : Animal,List<Dog> 也不繼承自 List<Animal>,無法隱式轉換:
List<Dog> lstDogs = new List<Dog>();
// 編譯錯誤:無法將 List<Dog> 隱式轉換為 List<Animal>
List<Animal> lstAnimal = lstDogs;
若要轉換,必須逐項投射:
List<Animal> lstAnimal2 = lstDogs.Select(d => (Animal)d).ToList(); // 複雜且產生新集合
為解決此痛點,C# 引入 out (協變) 與 in (逆變) 修飾泛型型別參數,僅限於介面與委派。
IEnumerable<out T>IEnumerable<T> 定義為 IEnumerable<out T>,意味著 T 只能作為輸出(如 GetEnumerator() 返回 IEnumerator<T>)。因此:
IEnumerable<Dog> someDogs = new List<Dog>();
// 合法:協變允許 IEnumerable<Dog> → IEnumerable<Animal>
IEnumerable<Animal> someAnimals = someDogs;
編譯器視角 vs IL 視角:C# 編譯器允許此轉換,但 IL 實際上仍執行強制轉換:
// 反編譯 IL 近似邏輯
IEnumerable<Animal> enumerable2 = (IEnumerable<Animal>)enumerable1;
這也解釋了為何 List<Animal> lstAnimal3 = (List<Animal>)lstDogs; 會失敗——List<T> 未標記 out,且包含 Add(T) 等輸入操作,違反協變安全性。
Action<in T> 與 IComparer<in T>Action<T> 定義為 Action<in T>,T 僅作為輸入參數。邏輯上:「能處理 Animal 的方法,必定能處理 Dog」。
Action<Animal> actionAnimal = a => { /* 讓動物叫 */ };
// 合法:逆變允許 Action<Animal> → Action<Dog>
Action<Dog> actionDog = actionAnimal;
actionDog(new Dog()); // 執行無誤
透過自定義介面 IMyList<out T> 與 IMyList<in T> 可直觀驗證編譯器限制:
| 修飾詞 | 允許成員 | 轉換方向 | 典型場景 |
|---|---|---|---|
out T (協變) | 只能作為返回值 (Getter) | IMyList<Dog> → IMyList<Animal> | 唯讀集合、IEnumerable |
in T (逆變) | 只能作為參數 (Setter/參數) | IMyList<Animal> → IMyList<Dog> | 比較器、Action、事件處理 |
若在 out T 介面中加入 void ChangeT(T t),編譯器將報錯:「型別參數 'T' 必須僅用於輸出位置」。
Out 只能出 (返回值),協變向上走;
In 只能進 (參數),逆變向下走。
掌握此原理,不僅能避免泛型轉換的編譯錯誤,更能設計出符合 Liskov 替換原則、可擴充性極高的 API。