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> ——值型別轉換;
將 IEnumerable<string> 轉換為 IEnumerable<XName> ——使用者定義的轉換。
使用者定義的轉換比較少見,因此不成什麼問題,但對值型別的限制可能會令你痛苦萬分。
3. out 引數不是輸出引數
這曾讓我大為詫異,儘管事後看來是有道理的。考慮使用以方法定義的委託:
delegate bool TryParser<T>(string input, out T value);
你可能會認為 T 可以是協變的——畢竟它只用在輸出位置,是這樣嗎?
CLR並不真正瞭解 out 引數。在它看來, out 引數只是應用了 [Out] 特性的 ref 引數。C#以明
確賦值的方式為該特性附加了特殊的含義,但CLR沒有。並且 ref 引數意味著資料是雙向的,因
此如果型別 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 語句和欄位風格的事件。