CLR 中的範型詳解發布日期: 1/24/2005 | 更新日期: 1/24/2005
Jason Clark
本頁內容
|
編譯器如何處理範型? |
|
規則和限制 |
|
where T : IComparable |
|
範型介面和委託 |
|
類庫中的範型 |
|
小結 |
在本專欄 2003 年 9 月一期中,我初步討論了公用語言運行庫 (CLR) 中的範型。我引入了範型的概念,介紹了範型所帶來的靈活性和代碼重用,探討了效能和型別安全,並通過一個簡單的程式碼範例展示了 C# 中的範型文法。本月,我將深入討論與範型有關的 CLR 內部工作機制。我將介紹類型約束、範型類、方法、結構和即將問世的範型類庫。
編譯器如何處理範型?
C++ 範本和 Java 語言中提議的範型等效都是它們各自編譯器的功能。這些編譯器在編譯時間根據對範型或模板類型的引用來構造代碼。這會導致代碼臃腫並降低結構之間的類型等效(即使類型變數相同)。相反,CLR 範型不採用這種工作方式。
CLR 中的範型是平台本身出類拔萃的功能。要通過這種方式實現它就需要更改整個 CLR(包括新的和修改過的中繼語言指令),並更改中繼資料、類型載入器、即時 (JIT) 編譯器、語言編譯器等等。這對 CLR 中的運行時擴充有兩個重要的好處。
首先,即使範型的每個結構(例如 Node首先,即使範型的每個結構(例如 Node<Form> 和 Node<String>)都有自己獨特的類型標識,但 CLR 能夠在類型執行個體化期間重用許多真正的 JIT 編譯的代碼。這極大地降低了代碼膨脹,並且也是切實可行的,因為範型的各種執行個體化都是在運行時才展開的。在編譯時間,構造類型的所有內容就是類型引用。當程式集 A 和 B 都引用在第三方程式集中定義的範型時,它們的構造類型就會在運行時展開。這意味著,除了共用 CLR 類型標識(在適當的時候)以外,來自程式集 A 和 B 的類型執行個體化也共用運行時資源,如機器碼和擴充的中繼資料。
類型等效是構造類型運行時擴充的第二個好處。以下為一個樣本:引用 AssemblyA.dll 中構造 Node <Int32> 的代碼和引用 AssemblyB.dll 中構造 Node <Int32> 的代碼都會在運行時建立具有相同 CLR 類型的對象。通過這種方式,如果兩個程式集由同一個應用程式使用,則它們的 Node <T> 類型的結構會解析為相同的類型,並且它們的對象可以自由交換。應該注意的是,編譯時間擴充會使得這種邏輯上很簡單的等效變得有問題或者無法實現。
在運行庫層級(而非編譯器層級)實現範型還有其他一些好處。其中一個好處是範型資訊會在編譯和執行期間保留下來,因此在代碼生存期的任何時刻都可以訪問它。例如,反射提供對範型中繼資料的完全訪問。另一個好處是 Visual Studio .NET 中豐富的 IntelliSense 支援,以及範型代碼所帶來的舒心的調試體驗。相反,Java 範型和 C++ 範本在運行時會失去它們的範型標識。
另一個好處(也是 CLR 的支柱)是交叉語言使用 — 使用一種託管語言定義的範型可以由用另一種託管語言編寫的代碼引用。同時,由於許多繁重的工作都是在這個平台上完成的,所以語言供應商在他們的編譯器中置入範型支援的可能性與日劇增。
在運行時類型擴充的眾多好處中,我最喜歡的那一個就顯得微不足道了。範型代碼只限明確用於類型構造執行個體化的操作使用。這種限制的附加好處是,使 CLR 範型比與它們相應的 C++ 範本更好理解,也更加有用。讓我們看一下 CLR 中對範型的限制。
返回頁首
規則和限制
一個困擾使用 C++ 範本的編程人員的問題是許多對類型結構所作的特殊嘗試都會失敗,包括型別參數的類型變數在實現由模板化代碼調用的方法時會失敗。同時,這些情況下的編譯器錯誤也很令人困擾,而且可能看起來與根本問題不相關。採用構造類型的運行時擴充以後,類似的錯誤會變成 JIT 編譯器錯誤或類型載入錯誤,而不是編譯時間錯誤。CLR 架構師決定了對於範型來說,這種實現是不可接受的。
相反,他們決定了對於範型(例如 Node <T>)的任何可能的類型執行個體化,即使類型執行個體化實際發生在運行時,該範型也必須在編譯時間被證實為一種有效類型。同樣,有問題的類型結構周圍的擴充錯誤不可能出現。為了實現這個目標,架構師通過一組規則和限制來約束範型的功能,從而保證在嘗試擴充其中一個範型執行個體化之前這種範型有效。
有一些規則限制了您通常可以編寫的代碼的類型。這些規則的本質可以歸納為一句話:範型代碼只有在用於範型的每個可能的構造執行個體時才有效。否則,範型代碼無效,並且也不能正確編譯(或者可以編譯,但無法在運行時通過驗證)。
首先,它看起來像是一個限制規則。以下為一個樣本:
public class GenericMath { public T Min<T>(T item1, T item2) { if (item1 < item2) { return item1; } return item2; }}
這段代碼在 CLR 範型中是無效的。C# 編譯器產生的錯誤如下所示:
invalid.cs(4,11): error CS0019: Operator '<' cannot be applied to operands of type 'T' and 'T'
同時,除了細微的文法區別外,與這基本相同的代碼在 C++ 範本中是允許的。為什麼對範型有這樣的限制呢?原因是:在 C# 中,“<”運算子只能用於特定的類型。然而,前面程式碼片段中的型別參數 T 可以在運行時擴充為任何 CLR 類型。前面的程式碼範例不是在運行時被認為是無效的,而是在編譯時間被認為是無效的。
除了運算子,更多可管理的類型使用(例如方法調用)也應用了相同的限制。以下對 Min <T> 方法的修改也是無效的範型代碼:
class GenericMath { public T Min<T>(T item1, T item2) { if (item1.CompareTo(item2) < 0) { return item1; } return item2; }}
這段代碼無效的原因與前面的樣本是一樣的。雖然類庫中的許多類型都實現了 CompareTo 方法,而且該方法也很容易由您的自訂類型實現,但不能保證這個方法適用於可用作 T 的參數的任何可能的類型。
但您也可以看出,範型代碼中並非完全禁止方法調用。在圖 1 中,GetHashCode 方法在兩個參數化變數中調用,而在圖 2 中,Node<T> 類型在它的參數化 m_data 欄位中調用了 ToString 方法。為什麼允許 GetHashCode 和 ToString,而不允許 CompareTo 呢?原因在於,GetHashCode 和 ToString 都是在 System.Object 類型中定義的,而每個可能的 CLR 類型也是從這個類型派生的。這意味著,類型 T 的每個可能的擴充都實現了 ToString 和 GetHashCode 成員函數。
如果要使範型可用於集合類以外的任何類,則範型代碼需要能夠調用由 System.Object 定義的方法以外的方法。不過要記住,只有當用於範型的任何可能的構造執行個體時,範型代碼才有效。有一個辦法可以解決這兩個看似相互矛盾的要求,那就是 CLR 範型中稱為約束的功能。
您應該知道,約束是範型或方法定義的一個可選組件。在可作為變數用於範型代碼上的一個型別參數的類型中,一個範型可以定義任意數量的約束,而每個約束可以應用任一個限制。通過限制可在範型結構中使用的類型,對引用受限型別參數的代碼的限制就可以放鬆一些(請參見圖 3)。
因為對型別參數 T 應用了約束,所以 Min <T> 和 Max <T> 在其條目上調用 CompareTo 是有效。在圖 3 的第三行代碼中,您可以看到它引入了如下所示的 where 子句:
返回頁首
where T : IComparable
這個約束表明 Min <T> 方法的任何結構都必須為實現 IComparable 介面的類型的參數 T 提供一個類型變數。這個約束限制了 Min <T> 的可能執行個體化的種類,但提高了方法中代碼的靈活性,比如現在可以在類型 T 的變數上調用 CompareTo。
約束通過允許範型代碼調用擴充類型上的任意方法,從而使範型演算法成為可能。雖然約束要求使用額外的文法才能定義範型代碼,但約束不會改變引用代碼的文法。引用代碼的唯一區別在於,類型變數必須遵守對範型的約束。例如,以下用於 Max <T> 的引用代碼是有效:
GenericMath.Min(5, 10);
這是因為 5 和 10 都是整數,而且 Int32 類型實現了 IComparable 介面。然而,試圖實現以下 Max <T> 結構會產生編譯器錯誤:
GenericMath.Min(new Object(), new Object());
下面是編譯器產生的錯誤:
MinMax.cs(32,7): error CS0309: The type 'object' must be convertibleto 'System.IComparable' in order to use it as parameter 'T' in the generic type or method 'GenericMath.Min(T, T)'
對於 T,System.Object 是一個無效的類型變數,因為它沒有實現對 T 的約束要求實現的 IComparable 介面。當類型變數與範型代碼上的型別參數不相容時,約束就可能會使編譯器產生如前面樣本中所示的描述性錯誤。
目前,範型支援三種類型的約束:介面約束、基類約束和建構函式約束。介面約束指定一個介面,該參數的所有類型變數都必須與這個介面相容。任意數量的介面約束都可以應用於給定的型別參數。
基類約束與介面約束類似,但每個型別參數只能包含一個基類約束。如果沒有為型別參數指定約束,則應用 Object 的隱式基類約束。
建構函式約束通過約束實現公用預設建構函式的類型的類型變數,使得範型代碼能夠建立由型別參數指定的類型的執行個體。目前,建構函式約束只支援預設或無參數建構函式。
where 子句用於為給定的型別參數定義約束或約束列表。每個 where 子句只應用於一個型別參數。範型或方法定義可以沒有 where 子句,也可以有與型別參數一樣多的 where 子句。一個給定的 where 子句可以包含一個約束,也可以包含由逗號分隔的約束列表。圖 4 顯示了對範型代碼應用約束的各種文法。
範型的基本規則(範型所有可能的執行個體化都是有效,或者範型本身是無效的)還有其他一些有趣的副作用。第一個是強制類型轉換。在範型代碼中,型別參數類型的變數可能只能與它的基類約束類型或基類約束類型的基類進行相互的強制轉換。這意味著,如果型別參數 T 沒有約束,它就只能與 Object 引用進行相互強制轉換。然而,如果將 T 約束為進一步派生的類型(例如 FileStream),則範型定義可以包括 T 與 FileStream 以及與 FileStream 的所有基類(直到 Object)之間的相互強制轉換。圖 5 中的代碼顯示了作用中的這種強制轉換規則。
在這個樣本中,T 沒有約束,並且被視為未綁定。它具有 Object 的隱式基類約束。T 與 Object 類之間的相互強制轉換以及與介面類型之間的相互強制轉換在編譯時間是有效。但編譯器不允許未綁定的 T 強制轉換為其他類型(如 Int32)。範型的強制轉換規則也適用於轉換,因此不允許使用強制轉換文法來進行類型轉換(比如從 Int32 轉換成 Int64);範型不支援類似的轉換。
下面是另一個有趣的事情。請考慮以下代碼:
void Foo<T>() { T x = null; // compiler error when T is unbounded •••
雖然像這樣賦空值很常使用,但可能存在一個問題。如果將 T 擴充為實值型別,會出現什麼情況呢?對一個值變數賦空值沒有意義。幸運的是,C# 編譯器提供了特殊的文法,以保證正確的結果,而不用管 T 的運行時類型:
void Foo<T>() { T x = T.default; // OK for any T}
等號右邊的運算式稱為預設值運算式。如果將 T 擴充為參考型別,則 T.default 會解析為 null。如果將 T 擴充為實值型別,則對於該變數,T.default 是所有位都為零的值。
如果 T 未綁定,則不允許對參數化變數 T 賦空值,因此可能有人會認為以下語句也是無效的,但事實不是如此:
void Foo<T>(T x) { if (x == null) { // Ok ••• }}
如果此處允許為 null,則將 T 擴充為實值型別時會出現什麼情況呢?如果 T 是實值型別,則前面樣本中的 Boolean 運算式會強製為 false。與賦空值的情況不同,對於 T 的任何擴充,空值比較是有意義的。
對於任何可能的執行個體化,在編譯時間確認範型有效這個前提的確對範型代碼有影響。然而,我發現與 C++ 中的模板相比,CLR 中範型周圍的附加結構有明顯的作用。總之,約束及其周圍的基礎結構是 CLR 範型中我最喜歡的一個方面。
返回頁首
範型介面和委託
範型類、結構和方法是 CLR 範型的主要功能。範型介面和委託是真正起支援作用的功能。範型介面如果單獨使用,則用處有限。但是,當與範型類、結構或方法一起使用時,範型介面(和委託)就會有重要的作用。
圖 3 中的 GenericMath.Min <T> 和 GenericMath.Max <T> 方法都將 T 約束為與 IComparable 介面相容。這使得這些方法可以調用方法的參數化變數上的 CompareTo。然而,在圖 3中實現的這些方法都沒有充分利用範型的優勢。原因在於,如果介面採用一個或多個對象參數(例如 CompareTo 的 obj 參數),則調用實值型別的非範型介面會導致裝箱。
對於圖 3 中的 GenericMath.Min <T>,如果該方法的執行個體化將 T 擴充為值而不是引用,則每次調用這個方法都會導致 CompareTo 方法的參數裝箱。在這種時候,範型介面就可以派上用場了。
圖 6 中的代碼重構了 GenericMath 方法,以通過範型介面 IComparable <T> 來約束 T。現在,如果 Min <T> 或 Max <T> 的執行個體化使用實值型別作為 T 的變數,則對 CompareTo 的介面調用就是介面結構的一部分,它的參數是實值型別,而且沒有發生裝箱。
範型委託具有類似於範型介面的好處,但它是面向方法而非面向類型。
返回頁首
類庫中的範型
除了在 CLR 中實現範型外,Microsoft 還計劃提供新的範型類庫,以作為代號為“Whidbey”的 CLR 版本的類庫的一部分。在本專欄出版的時候,應該會推出 Whidbey CLR 的預覽版。(有關詳細資料,請參閱本專欄的 2003 年 9 月號。)最保守的估計,會提供實現列表、詞典、棧和隊列的範型集合類。另外,類庫還將包含支援的介面類型(例如 IList<T>、ICollection<T> 和 IComparable<T>),它們是 Microsoft .NET Framework 1.0 和 1.1 類庫所附帶的簡單介面的等效範型。
最後,您還會發現,整個類庫中的類型都將以新的和現有功能的範型版本參數化。例如,System.Array 類將包含它的 BinarySearch 和 Sort 方法的範型版本,它們利用範型介面 IComparer<T>, 和 IComparable<T>。在撰寫這篇文章時,Microsoft 尚未確定要對現有類庫進行多大的改動,以便在下一個運行庫版本中包含範型支援。
返回頁首
小結
CLR 範型是一個強大的應用程式和庫開發功能。不管您是選擇通過使用集合類來應用範型,還是選擇通過架構整個應用程式來應用範型,範型都能夠使您的代碼類型更安全、可維護,而且效率高。在本專欄的這兩期中,在Managed 程式碼中進行範型編程給我帶來了很大的樂趣,我也期待著要發布的產品。看到 CLR 增添了諸如範型這樣的重要功能是一件令人興奮的事情。還可能有許多令人感興趣的內容。請繼續關注我們的工作。
請將給 Jason 的問題和意見發送到 dot-net@microsoft.com。
Jason Clark 為 Microsoft 和 Wintellect (http://www.wintellect.com) 提供培訓和諮詢,他曾經是 Windows NT 和 Windows 2000 Server 團隊的開發人員。他與人合著了 Programming Server-side Applications for Microsoft Windows 2000 (Microsoft Press, 2000) 一書。您可以通過 JClark@Wintellect.com 與 Jason 聯絡。
轉到原英文頁面