13.3.5 【接口和委托的泛型可變性】限制和說明
1. 不支持類的類型參數的可變性
只有接口和委托可以擁有可變的類型參數。即使類中包含只用於輸入(或只用於輸出)的類
型參數,仍然不能為它們指定 in 或 out 修飾符。例如, IComparer<T> 的公共實現 Comparer<T>
是不變的——不能將 Comparer<IShape> 轉換為 Comparer<Circle> 。
除了實現方面的困難,從理論上看來也應該是這樣的。接口是一種從特定視角觀察對象的方
式,而類則更多地植根於對象的實際類型。不可否認,繼承可以將一個對象視為它繼承層次結構
中任何類的實例,由此在一定程度上削弱了這種理由的說服力。但不管怎樣,CLR不允許這麽做。
2. 可變性只支持引用轉換
你不能對任意兩個類型參數使用可變性,因為在它們之間會產生轉換。這種轉換必須為引用
轉換。基本上,這使轉換只能操作引用類型,並且不能影響引用的二進制表示。因此,編譯器知
道操作是類型安全的,並且不會在任何地方插入實際的轉換代碼。我們在13.3.2節提到過,可變
轉換本身是引用轉換,所以不會有任何額外的代碼。
特別地,這種限制禁止任何值類型轉換和用戶定義的轉換。比如下面的轉換是無效的。
? 將 IEnumerable<int> 轉換為 IEnumerable<object> ——裝箱轉換;
? 將 IEnumerable<short> 轉換為 IEnumerable<int> ——值類型轉換;
用戶定義的轉換比較少見,因此不成什麽問題,但對值類型的限制可能會令你痛苦萬分。
3. out 參數不是輸出參數
這曾讓我大為詫異,盡管事後看來是有道理的。考慮使用以方法定義的委托:
delegate bool TryParser<T>(string input, out T value);
你可能會認為 T 可以是協變的——畢竟它只用在輸出位置,是這樣嗎?
CLR並不真正了解 out 參數。在它看來, out 參數只是應用了 [Out] 特性的 ref 參數。C#以明
此如果類型 T 為 ref 參數,也就意味著 T 是不變的。
事實上,即使CLR支持 out 參數,也仍然不安全,因為它可用於方法本身的輸入位置;寫入
變量之後,同樣也可以從中讀取它。如果將 out 參數看成是“運行時復制值”似乎好一些,但它
本質上是實參和參數的別名,如果不是完全相同的類型,將會產生問題。由於稍微有些繁瑣,此
處不再演示,但本書的網站上可以看到有關示例。
委托和接口使用 out 參數的情況很少,因此這可能不會對你產生影響,但為了以防萬一,還
是有必要了解的。
4. 可變性必須顯式指定
在介紹表示可變性的語法時(即對類型參數使用 in 或 out 修飾符),你可能會問為什麽要這
麽麻煩。編譯器可以檢查正在使用的可變性是否有效,因此為什麽不能自動應用呢?
這樣可以——至少在很多情況下是可以的——但我寧願它不可以。通常我們向接口添加方法
時,只會影響實現,而不會影響調用者。但如果聲明了一個可變的類型參數,然後又添加了一個
破壞這種可變性的方法,所有的調用者都會受影響。這會造成混亂不堪的局面。可變性要求你對
未來發生的事情考慮周全,並且強迫開發者顯式指定修飾符,鼓勵他們在執行可變性之前做到心
中有數。
對於委托來說,這種顯式的特性就沒有那麽多爭論了:任何對簽名所做的影響可變性的修改,
都會破壞已有的使用。但如果在接口的定義中指定了可變性的修飾符,而在委托聲明中不指定,
則會顯得很奇怪,因此要保持它們的一致性。
5. 註意破壞性修改
每當新的轉換可用時,當前代碼都有被破壞的風險。例如,如果你依賴於不允許可變性的
is 或 as 操作符的結果,運行在.NET 4時,代碼的行為將有所不同。同樣,在某些情況下,因為
有了更多可用的選項,重載決策也會選擇不同的方法。因此這也成了另一個顯式指定可變性的理
由:降低代碼被破壞的風險。
這些情況應該是很少見的,而且可變性的優點也比潛在的缺點更加重要。你已經有了單元測
試,可以捕獲那些微小的變化,對不對?嚴肅地說,C#團隊對於代碼破損的態度非常認真,但有
時引入新特性難免會破壞代碼。
6. 多播委托與可變性不能混用
通常情況下,對於泛型來說,除非涉及強制轉換,否則不用擔心執行時遇到類型安全問題。
不幸的是,當多個可變委托類型組合到一起時,情況就比較討厭了。用代碼可以更好地描述:
Func<string> stringFunc = () => ""; Func<object> objectFunc = () => new object(); Func<object> combind = objectFunc + stringFunc;
這段代碼可以通過編譯,因為將 Func<string> 類型的表達式轉換為 Func<object> 是協變
的引用轉換。但對象本身仍然為 Func<string >,並且實際進行處理的 Delegate.Combine 方法
要求參數必須為相同的類型——否則它將無法確定要創建什麽類型的委托。因此以上代碼在執行
時會拋出 ArgumentException 。
這個問題在.NET 4快發布的時候才被發現,但微軟察覺到了,並且很可能會在未來的版本中
予以解決(.NET 4.5中還未得到解決)。在此之前的應對之策是:基於可變委托新建一個類型正
確的委托對象,然後再與同一類型的另一個委托進行組合。例如,略微修改之前的代碼即可使其工作:
Func<string> stringFunc = () => ""; Func<object> objectFunc = () => new object(); Func<object> defensiveCopy = new Func<object>(stringFunc); Func<object> combind = objectFunc + defensiveCopy;
慶幸的是,以我的經驗來說,這種情況很少見。
7. 不存在調用者指定的可變性,也不存在部分可變性
與其他問題相比,這個問題的確更能引起你的興趣,但值得註意的是,C#的可變性與Java
系統相去甚遠。Java的泛型可變性相當靈活,它從另一側面來解決問題:不在類型本身聲明可變
性,而是在使用類型的代碼處表示所需的可變性。
例如,Java的 List<T> 接口大體上相當於C#的 IList<T> 。它包含添加和提取項的方法,這
在C#中顯然是不變的,而在Java中,你可以在調用代碼時聲明類型來說明所需的可變性。然後編
譯器會阻止你使用具有相反可變性的成員。例如,以下代碼是完全合法的
List<Shape> shapes1 = new ArrayList<Shape>(); List <? super Square > squares = shapes1; //聲明為逆變的 squares.add(new Square(10, 10, 20, 20)); List<Circle> circles = new ArrayList<Circle>(); circles.Add(new Circle(10, 10, 20)); List <? extends Shape > Shapes2 = circles; //聲明為協變的 Shape shape = Shapes2.get(0);
我在很大程度上更傾向於C#泛型,而不是Java泛型。特別是類型擦除(type erasure)在很
多時候會讓你痛苦萬分。但我發現這種處理可變性的方式真的很有趣。我認為C#未來版本中不會
出現類似的東西,所以你應該仔細考慮如何在不增加復雜性的前提下,將接口分割以增加靈活性。
在結束本章之前,還要介紹兩處幾乎是微不足道的改變——編譯器如何處理 lock 語句和字段風格的事件。
13.3.5 【接口和委托的泛型可變性】限制和說明