# 原則29:讓接口支持協變和逆變
**By D.S.Qiu**
**尊重他人的勞動,支持原創,轉載請注明出處:[http://dsqiu.iteye.com](http://dsqiu.iteye.com)**
類型可變性,具體地,協變和逆變,定義了一個類型變化為另一個類型的兩種情況。如果可能,你應該讓泛型接口和委托支持泛型的協變和逆變。這樣做可以讓你的 APIs 能安全地不同方式使用。如果你不能將一個類型替換為另一個,那么就是不可變。
類型可變性是很多開發者遇到的卻又不真正理解的很多問題之一。協變和逆變是類型替換的兩種不同形式。如果你用聲明類型的子類返回那么就是協變的。如果你用聲明類型的基類作為參數傳入那么就是逆變。面向對象原因普遍支持參數類型的協變。你可以傳遞子類對象到任何期望是基類參數的方法。例如, Console.WriteLine() 函數有一個使用 System.Object 參數的版本。你可以傳入任何 System.Object 的子類對象。如果你重載實例方法返回 System.Object ,你可以返回任何繼承自 System.Object 的對象。
普遍的行為讓很多開發者認為泛型也遵循這個規則。你可以使用 IEnumerable<MyDerived> 傳給參數為 IEnumerable<Object> 的方法。你會期望返回的 IEnumerable<MyDerivedType> 可以賦值給 IEnumerable<Object> 變量。不是這樣的。在 C# 4.0之前,所有泛型類型都是不可變的。這意味著,很多次你都自以為泛型也有協變和逆變時,編譯器卻告訴你的代碼是有問題的。數組是被看做協變的。然而,數組不支持安全的協變。隨著 C# 4.0 ,新關鍵字可以讓你的泛型支持協變和逆變。這使得泛型更有用,特別是在泛型接口和委托上你應該盡可能使用 in 和 out 參數。
我們開始通過數組理解協變的問題。考慮下面簡單的類繼承結構:
```
abstract public class CelestialBody
{
public double Mass { get; set; }
public string Name { get; set; }
// elided
}
public class Planet : CelestialBody
{
// elided
}
public class Moon : CelestialBody
{
// elided
}
public class Asteroid : CelestialBody
{
// elided
}
```
下面這個方法把 CelestialBody 對象數組當做協變,而且那樣做事安全的:
```
public static void CoVariantArray(CelestialBody[] baseItems)
{
foreach (var thing in baseItems)
Console.WriteLine("{0} has a mass of {1} Kg", thing.Name, thing.Mass);
}
```
下面這個方法也把 CelestialBody 對象數組當做協變,但這是不安排的。賦值語句會拋出異常。
```
public static void UnsafeVariantArray(
CelestialBody[] baseItems)
{
baseItems[0] = new Asteroid
{ Name = "Hygiea", Mass = 8.85e19 };
}
```
如果你將子類賦給基類的數組元素一樣會有相同的問題:
```
CelestialBody[] spaceJunk = new Asteroid[5];
spaceJunk[0] = new Planet();
```
把集合看著協變意味著當如果有兩個類有繼承關系是,你可以認為他們的關系和兩個類型的數組是一樣的。這不是一個嚴格的定義,但要記住它是很用的。 Planet 對可以傳遞給任何期望參數為 CelestialBody 的方法。這是因為 Planet 繼承于 CelestialBody 。類似地,你可以將 Planet[] 傳遞給任何期望參數為 CelestianlBody[] 的方法。但是,正如上面的例子一樣,它們總是不能如你期望一樣工作。
當泛型被引入時,這個問題被十分嚴格的處理。泛型總是被當做不可變的。泛型類型不得不正確匹配。然而,在 C# 4.0,你可以將方向接口修飾變為協變或逆變。我們先討論泛型協變,而后在討論逆變。
下面這個方法調用參數為 List<Planet> :
```
public static void CoVariantGeneric(
IEnumerable<CelestialBody> baseItems)
{
foreach (var thing in baseItems)
Console.WriteLine("{0} has a mass of {1} Kg", thing.Name, thing.Mass);
}
```
這是因為 IEnumerable<T> 已經被擴展為限制 T 只能出現在輸出位置:
```
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<out T> :IDisposable, IEnumerator
{
T Current { get; }
// MoveNext(), Reset() inherited from IEnumerator
}
```
我給出了 IEnumerable<T> 和 IEnumerator<T> 的定義,因為 IEnumerator<T> 會有比較重要的限制。注意到 IEnumerator<T> 現在的參數類型 T 已經被修飾符 out 修飾。這就強制編譯器類型 T 只能在輸出位置。輸出位置僅限于函數返回值,屬性 get 訪問器和委托的參數。
因此,使用 IEnumerable<out T> ,編譯器知道你會查看序列的每個 T ,但是不會修改序列的內容。這個例子中把 Planet 當做 CelestailBody 就是這樣的。
IEnumerable<T> 可以協變是因為 IEnumerator<T> 也是協變的。如果 IEnumerable<T> 返回的接口不是協變的,編譯器會產生一個錯誤。協變類型必須返回值類型的參數或這個接口是協變的。
然而,下面方法替換隊列的第一個元素的泛型是不可變的:
```
public static void InvariantGeneric(
IList<CelestialBody> baseItems)
{
baseItems[0] = new Asteroid { Name = "Hygiea", Mass = 8.85e19 };
}
```
因為 IList<T> 的參數 T 既沒有被 in 又沒有被 out 修飾符,你必須使用正確的類型進行匹配。
當然,你也可以創建逆變泛型接口和委托。用 in 修飾符替換 out 。這個告訴編譯器類型參數只能出現在輸入位置。.NET 框架已經為 IComparable<T> 加上了 in 修飾符:
```
public interface IComparable<in T>
{
int CompareTo(T other);
}
```
這說明如果 CelestialBody 實現 IComparable<T> ,可以使用很多不同的對象。它可以比較兩個 Planet ,一個 Planet 和一個 Moon ,一個 Moon 和一個 Asteroid ,或者其他組合。比較了多個不同的對象,但這是有效的比較。
你會注意到 IEquatable<T> 是不可變的。按照定義, Planet 對象不會和 Moon 對象相等。它們是不同的類型,所以沒有意義。如果兩個對象是相同類型的如果相等而且不充分的,它是必要的(查看原則6)。
類型參數是可逆變的只有作為方法參數或某些地方的委托參數。
現在,你應該已經注意到我已經用了詞組“某些地方的委托參數”兩次。委托的定義可以協變也可以逆變。這是相當簡單:方法參數逆變( in ),方法的返回值是協變( out )。BCL 更新了包括下面變種的很多委托的定義:
```
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, T2, out TResult>(T1 arg1,T2 arg2);
public delegate void Action<in T>(T arg);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
public delegate void Action<in T1, in T2, T3>(T1 arg1, T2 arg2, T3 arg3);
```
在重復一次,這也許不太難。但是,如果你把它們混淆了,事情就得開動你的腦筋了。你已經看到你不能從協變接口返回不可變接口。你使用委托要么限制協變要么限制逆變。
如果你不仔細的話,委托在接口里會向協變和逆變偏移。這里有幾個例子:
```
public interface ICovariantDelegates<out T>
{
T GetAnItem();
Func<T> GetAnItemLater();
void GiveAnItemLater(Action<T> whatToDo);
}
public interface IContravariantDelegate<in T>
{
void ActOnAnItem(T item);
void GetAnItemLater(Func<T> item);
Action<T> ActOnAnItemLater();
}
```
接口里的方法的命名展示了它們具體的工作。仔細看 ICovariantDelegate 接口的定義。 GetAnItemLater() 只是檢索元素。方法中可以調用 Func<T> 返回檢索的元素。 T 仍然出現在輸出位置上。這可能是有意義。 GetAnItemLater() 很容易讓人困擾。這里,你的委托方法只是接收 T 對象。所以,即使 Action<T> 是協議的,它出現的 ICovarinatDelegate 接口的位置其實是 T 由實現 ICovariantDelegate<T> 的對象返回的。它看起來是逆變的,但是相對于接口來說是協變的。
IContravariantDelegate<T> 和一般的接口一樣但是展示如何使用逆變接口。再說一次, ActOnAnItemLater() 方法就很明顯。 ActOnAnItemLater() 方法有些復雜。你返回一個接受 T 類型對象的方法。這個最后方法,一次又一次強調,會引起一些困擾。它和其他接口的概念是一樣的。 GetAnItemLater() 方法接受一個方法并返回 T 對象。即使 Func<out T> 聲明為協變,它的作用是為實現 IContravariantDelegate 對象引入輸入。它相對于 IContravariantDelegate 的作用是逆變的。
描述協變和逆變如何正確的工作十分復雜。值得慶幸的是,語法現在支持使用 in (逆變) 和 out (協變)修飾接口。你應該盡可能使用 in 或 out 修飾符修復接口和委托。然后,編譯器就會糾正和你定義的有差異的用法。編譯器會捕獲到接口和委托的定義,并且發現你創建的類型的任何誤用。
小結:
這個原則作為第三章的最后一個,雖然介紹的是類型的可變性,有些類似類型轉換,但是情況卻復雜的多,理解起來難度很大,想要更徹底的理解協變和逆變的概念,可以參考①。
歡迎各種不爽,各種噴,寫這個純屬個人愛好,秉持”分享“之德!
有關本書的其他章節翻譯請[點擊查看](/category/297763),轉載請注明出處,尊重原創!
如果您對D.S.Qiu有任何建議或意見可以在文章后面評論,或者發郵件(gd.s.qiu@gmail.com)交流,您的鼓勵和支持是我前進的動力,希望能有更多更好的分享。
轉載請在**文首**注明出處:[http://dsqiu.iteye.com/blog/2086977](/blog/2086977)
更多精彩請關注D.S.Qiu的博客和微博(ID:靜水逐風)
參考:
①[1-2-3.cnblogs.com](http://www.cnblogs.com/1-2-3/): [http://www.cnblogs.com/1-2-3/archive/2010/09/27/covariance-contravariance-csharp4.html](http://www.cnblogs.com/1-2-3/archive/2010/09/27/covariance-contravariance-csharp4.html)
- 第一章 C# 語言習慣
- 原則1:使用 屬性(Poperty)代替可直接訪問的數據成員(Data Member)
- 原則2:偏愛 readonly 而不是 const
- 原則3:選擇 is 或 as 而不是強制類型轉換
- 原則4:使用條件特性(conditional attribute)代替 #if
- 原則5:總是提供 ToString()
- 原則6:理解幾個不同相等概念的關系
- 原則7:明白 GetHashCode() 的陷阱
- 原則8:優先考慮查詢語法(query syntax)而不是循環結構
- 原則9:在你的 API 中避免轉換操作
- 原則10:使用默認參數減少函數的重載
- 原則11:理解小函數的魅力
- 第二章 .NET 資源管理
- 原則12:選擇變量初始化語法(initializer)而不是賦值語句
- 原則13:使用恰當的方式對靜態成員進行初始化
- 原則14:減少重復的初始化邏輯
- 原則15:使用 using 和 try/finally 清理資源
- 原則16:避免創建不需要的對象
- 原則17:實現標準的 Dispose 模式
- 原則17:實現標準的 Dispose 模式
- 原則18:值類型和引用類型的區別
- 原則19:確保0是值類型的一個有效狀態
- 原則20:更傾向于使用不可變原子值類型
- 第三章 用 C# 表達設計
- 原則21:限制你的類型的可見性
- 原則22:選擇定義并實現接口,而不是基類
- 原則23:理解接口方法和虛函數的區別
- 原則24:使用委托來表達回調
- 原則25:實現通知的事件模式
- 原則26:避免返回類的內部對象的引用
- 原則27:總是使你的類型可序列化
- 原則28:創建大粒度的網絡服務 APIs
- 原則29:讓接口支持協變和逆變
- 第四章 和框架一起工作
- 原則30:選擇重載而不是事件處理器
- 原則31:用 IComparable&lt;T&gt; 和 IComparer&lt;T&gt; 實現排序關系
- 原則32:避免 ICloneable
- 原則33:只有基類更新處理才使用 new 修飾符
- 原則34:避免定義在基類的方法的重寫
- 原則35:理解 PLINQ 并行算法的實現
- 原則36:理解 I/O 受限制(Bound)操作 PLINQ 的使用
- 原則37:構造并行算法的異常考量
- 第五章 雜項討論
- 原則38:理解動態(Dynamic)的利與弊
- 原則39:使用動態對泛型類型參數的運行時類型的利用
- 原則40:使用動態接收匿名類型參數