原著:Bill Venners、Bruce Eckel 2004.2.26

翻譯:lover_P

[人物介紹]

Anders Hejlsberg,微軟著名工程師,帶領他的小組設計了C#(讀作:C-Sharp)程序設計語言。Hejlsberg第一次登上軟件界歷史舞台是在80年代早期,因為他為MS-DOS和CP/M設計了Pascal編譯器。當時,還是一個小公司的Borland很快僱用了他,並買下了他的編譯器,改稱Turbo Pascal。在Borland,Hejlsberg繼續開發Turbo Pascal,並最終帶領他的小組設計了Turbo Pascal的替代品:Delphi。1996年,在進入Borland 13年後,Hejlsberg加入了微軟。最初,他做Visual J++和Windows Fundatioin Classes(WFC)的架構師。隨後,Hejlsberg成為C#的首席設計師和.NET Framework的關鍵參與者。目前,還在領導著C#程序設計語言的繼續開發。

Bruce Eckel,Think in C++(C++編程思想)和Think in Java(Java編程思想)的作者。

Bill Venners,Artima.com的主編。

[內容]

泛型概述

C#中的泛型

C#泛型和java泛型的比較

C#泛型和C++模板的比較

C#泛型中的約束

泛型概述

Bruce Eckel:您能對泛型做一個快速的介紹麼?

Anders Hejlsberg:泛型其實就是能夠向你的類型中加入類型參數的一種能力,也稱作參數化的類型或參數多態性。最著名的例子就是List集合類。一個List是一個易於增長的數組。它有一個排序方法,你可以為 它做索引,等等。現在,如果沒有參數化的類型,那麼不論使用數組還是使用List都不是很好。如果你使用數組,你能獲得強類型,因為你可以聲明一個Customer類型的數組,但你失去了可增長性和那些方便的方法;如果你使用一個List,你能夠得到所有的便利,但你失去了強類型。你難以說出一個List是什麼(類型的)List,它只是一個Object的List【譯註:「什麼類型的List」指的是List存放的元素是什麼類型的】。這會給你帶來麻煩 ,因為類型只能在運行形時進行檢查,也就是說在編譯時不會進行類型檢查。就算你硬要把一個Customer放進一個List並試圖從中得到一個String,編譯器也不會不高興。在運行之前你根本無法發現它不能工作。同時,當你將簡單類型【譯註:指值類型】放入List時,還必須對它們進行裝箱。正是由於這些問題,你不得不在List和數組之間徘徊,你經常要很痛苦地決定應該使用哪一個。

泛型的偉大之處在於你現在可以盡情地享受你的蛋糕了,因為你能夠定義一個List<T>(讀作:List of T)【譯註:中文可以說成「T類型的List」】。當你使用List時,你居然能夠說出它是什麼類型的List,並且你將獲得強類型,編譯器會為你檢查它的類型。這些只是直覺上的好處,它還有其它許多優點。當然,你並不是只能將它用於List,Hastable、Dictionary(將鍵影射到值上的數據結構)——所有你想調用的都行。你可能想將String影射到Customer、將int影射到Order,在這些情況下你都能獲得強類型。

C#中的泛型

Bill Venners:泛型在C#中是如何工作的呢?

Anders Hejlsberg:在沒有泛型的C#中,你只能寫class List {...};而在帶有泛型的C#中,你可以寫class List<T> {...},這裡的T是一個類型參數。在List<T>中,你可以把T就當作一個類型來用。當它實際用來建立一個List對像時,你要寫List<int>或List<Customer>。這樣你就從List<T>構造了一個新的類型,看起來就好像你用你的類型變量替換了所有的類型參數。所有的T都變成了int或Customer,你無須進行向下轉換【譯註:即將int和Customer轉換為Object】,它們是強類型的,任何時候都會被檢查。

在CLR(Common Language Runtime,公共語言運行時)中,當你編譯List<T>或其它泛型類型時,它們和普通類型一樣被轉換為IL(Intermediate Language,中間語言)和元數據。IL和元數據帶有附加信息,可以知道這是一個類型參數,當然,原則上泛型類型的編譯和其它類型一樣。在運行時,當你的應用程序第一次引用List<T>時,系統會看看你是否已經使用過List<int>。如果沒有,它會調用JIT將帶有int類型變量的List<T>編譯為IL和元數據。當JIT即時編譯IL時,同樣會替換類型參數。

Bruce Eckel:所以它是在運行時被實例化的。

Anders Hejlsberg:它確實是在運行時實例化。它在需要的時候才產生特定的原生代碼(native code)。字面上,當你說List<T>時,你會得到一個int類型的List。如果泛型類型中使用的是T類型的數組,它會變成int類型的數組。

Bruce Eckel:這個類會在某一時刻被垃圾收集器收集麼?

Anders Hejlsberg:是也不是,這是一個正交的問題。它會在該程序集中建立一個類,這個類在程序集中會一直存在。如果你終止了程序集,這個類會消失,和其它類一樣。

Bruce Eckel:但如果我的程序中聲明了一個List<int>和一個List<Cat>,但我從未使用過List<Cat>……

Anders Hejlsberg:……那麼系統不會實例化List<Cat>。當然,下面的情況除外。如果你使用NGEN產生一個鏡像,也就是說如果你預先生成了一個原生代碼的鏡像,會預先實例化。但是如果你在一般的環境下運行,則這個實例化是純需求驅動(demand driven)的,會盡可能地延遲【譯註:正如上面所說,直到使用時才進行實例化】。

實際上,我們所要進行實例化的所有類型都是值類型——如List<int>、List<long>、List<double>、List<float>——我們為每一個都建立一份唯一的可執行原生代碼的拷貝。因此,List<int>有它自己的代碼,List<long>有它自己的代碼,List<float>有它自己的代碼。對於所有的 引用類型我們共享它們的代碼,因為它們在表現上是一樣的,它們只是一些指針。

Bruce Eckel:因此你只需要轉換。

Anders Hejlsberg:不,實際上是不需要的。我們可以共享原生鏡像,但他們實際上具有獨立的VTable。我要指出的是,我們只是盡量對代碼進行有意義的共享,但我們很清楚,為了效率,有很多代碼是不能共享的。典型的就是值類型,你會很關心List<int>中到底是不是int。你肯定不希望將它們 被裝箱為Object。對值類型進行裝箱是一種共享的方法,但對它們進行裝箱開銷會很大。

Bill Venners:對於引用類型,所不同的只是類。List<Elephant>不同於List<Orangutan>,但他們實際上共享了所有方法的代碼。

Anders Hejlsberg:是的。作為實現的細節,它們實際上共享了相同的原生代碼。

C#泛型和java泛型的比較

Bruce Eckel:如何比較C#中的泛型和java中的泛型呢?

Adners hejlsberg:Java的泛型最初是基於Martin Odersky和其它人一起做的稱作Pizza的一個項目的。Pizza後改名為GJ,然後成為JSR,最後以被Java語言收容而告終。這種泛型以能夠在原有的VM(Virtual Machine,虛擬機)上運行為關鍵設計目標。也就是說,你不必修改你的VM,但它會帶來很多限制。這些限制並不會很快出現,但很快你就會說:「嗯,這有點陌生。」

例如,使用Java泛型,我覺得你實際上不會獲得任何的執行效率,因為當你編譯一個Java泛型類時,編譯器會將所有的類型參數替換為Object。當然,如果你嘗試建立一個List<int>,你就需要對所有的int進行裝箱。因此,這會有很大的開銷。另外,為了讓VM高興,編譯器必須為所有的類型插入類型轉換。如果一個List是Object的,而你想將這些Object視為Customer,就必須將Object轉換為Customer,以讓類型檢查器 滿意。而它在實現這些的時候,真的只是為你插入所有這些類型轉換。因此,你只是嘗到了語法上的甜頭,卻沒有獲得任何執行效率。所以我覺得這是(泛型的)Java實現的頭號問題。

第二號問題,我覺得也是一個很嚴重的問題,這就是由於Java泛型是依靠消除所有的類型參數來實現的,你就無法在運行時獲得一個和編譯時同樣可靠的表現。當你在Java中反射一個泛型的List的時候,你無法得知這是個List什麼類型的List。它只是一個List。因為你失去了類型信息,任何由代碼生成方案或基於反射的方案所產生的動態類型都將無法工作。唯一讓我認為清晰的趨勢就是,越來越多的東西將不能運行,就是因為你丟掉了類型信息。但在我們的實現中,所有這些信息都是可用的。你可以使用反射 來獲得List<T>對象的System.Type。但你還不能建立它的一個實例,因為你並不知道T是什麼。但是接下來你可以使用反射來獲得int的Sytem.Type。然後你就可以請求反射將這兩個System.Type結合起來並建立一個List<int>,然後你還能獲得List<int>的另一個System.Type。因此,所有你在編譯期間能做的在運行時同樣可以。

C#泛型和C++模板的比較

Bruce Eckel:如何比較C#泛型和C++模板呢?

Anders Hejlsberg:我認為對C#泛型和C++模板之間的區別最好的理解是:C#泛型更像類,只不過它帶有類型參數;C++模板接近宏,只不過它看起來像類。

C#泛型和C++模板之間最大的區別在於類型檢查發生的時機和如何進行實例化。首先, C#在運行時進行實例化。而C++在編譯時,或者可能是連接時進行實例化。不管怎麼說,C++是在程序運行前進行實例化。這是第一點不同。第二點不同是當你編譯泛型類型時,C#會進行強類型檢查。對於一個非約束的類型參數,如List<T>,能夠在類型為T的值上執行的方法僅僅是那些能夠在Object類型中找到的方法,因為只有這些方法是我們能夠保證存在的。在C#中,我們要保證在一個類型參數上執行的所有操作都能成功。

C++正相反。在C++中,你可以在類型參數所指定的類型的變量上執行你想做的任何操作。但是一旦你對它進行了實例化,它就有可能無法工作,你將會得到一些含義模糊的錯誤信息。例如,如果你有一個類型參數T,而x和y是T類型的變量,然後你執行x+y,如果你對兩個T定義了一個operator+還好說,否則你就只能得到一些沒意義的錯誤消息。因此,從某種意義上說,C++模板實際上是無類型的,或者說是弱類型的。而C#泛型是強類型的。

C#泛型中的約束

Bruce Eckel:約束是如何在C#泛型中工作的呢?

Anders Hejlsberg:在C#泛型中,我們能夠為類型參數施加約束。以我們的List<T>為例,你可以說class List<T> where T : IComparable。這意味著T必須實現IComparable接口。

Bruce Eckel:有意思。在C++中,約束是隱式的。

Anders Hejlsberg:是的。在C#中我們也可以這樣做。譬如我們有一個Dictionary<K, V>,它有一個Add()方法,這個方法帶有K key和V value參數。Add()方法的實現將希望能夠將傳遞進來的key和Dictionary中已經存在的key進行比較,而且它希望使用一個稱作IComparable的接口。唯一的途徑就是將key參數轉換為IComparable接口,然後調用CompareTo方法。當然,當你這麼做的時候,你就為K類型和key參數建立了一個隱式的約束。如果傳遞進來的key沒有實現IComparable接口,你會得到一個運行時錯誤。這在你的所有方法中都有可能出現,因為你的約定沒有要求key必須實現IComparable接口。當然,你還得為運行時類型檢查付出代價,因為你實際上進行了動態類型轉換。

使用約束,你可以消除代碼中的動態檢查,而在編譯時或裝載時進行。當你要求K必須實現IComparable接口時,會發生很多事情。對於K類型的值,你現在可以直接訪問接口方法而無需類型轉換。因為程序在語義上可以保證它實現了這個接口。無論什麼時候你嘗試建立這個類型的一個實例時, 編譯器都會檢查這些類型是否實現了這個接口,如果沒有實現,會給你一個編譯錯誤。如果你使用的是反射,你會得到一個異常。

Bruce Eckel:你是說編譯器和運行時(都會進行檢查)?

Anders Hejlsberg:編譯器會檢查它,但你仍有可能在運行時通過反射來做這些,因此系統還會檢查它。正像我前面說的,編譯時可以做的任何事都可以在運行是通過反射來做。

Bruce Eckel:我可以做一個函數模板,換句話說,一個帶有不知道類型的參數的函數?你為約束添加了強類型檢查,但我是不是能像C++模板那樣得到一個弱類型模板? 例如,我能否寫一個函數,它帶有兩個參數A a和B b,並在代碼中寫a+b?我能不能說我不在乎對於A和B是否有operator+,因為 它們是弱類型的?

Anders Hejlsberg:你真正要問的問題應該是這在約束中如何說吧?約束,和其他特性一樣,最終將可以是任意複雜的。當你考慮它的時候,約束只是一個模式匹配機制。你可能希望能夠說「這個類型參數必須有一個帶有兩個參數的構造器、實現了operator+、有這個靜態方法、有那兩個實例方法、等等」。問題是,你希望這種模式匹配機制有多複雜?

從沒有任何東西到完全模式匹配是一個整個的連續體。沒有任何東西(的模式匹配)太小了,不能說明問題;而完全模式匹配又太複雜了,因此我們需要在中間找一個平衡點。我們允許你將約束指定為一個類、一個或多個接口,以及一些構造器約束。譬如,你可以說:「這個類型必須實現IFoo和IBar」或「這個類型必須繼承基類X」。一旦你這麼做了,在編譯時和運行時都會檢查這個約束是否為真。這個約束所隱含的任何方法對於類型參數所指定的類型的值都是直接有效的。

現在,在C#中,運算符是靜態成員。因此,運算符不能是接口的成員,因此接口約束不能帶給你operator+。你只能通過類約束獲得operator+,你可以說這個類型參數必須繼承自比如說Number類,並且Number類對於兩個Nubmer有operator+。但你不能抽像地說「必須有一個operator+」,我們無法知道這句話的具體含義。

Bill Venners:你通過類型進行約束,而不是簽名。

Anders Hejlsberg:是的。

Bill Venners:因此這個類型必須擴展一個類或實現一個接口。

Anders Hejlsberg:是的。而且我們還能夠走得更遠。實際上我們也想過再走遠一些,但這會變得相當複雜。而且增加的複雜性與所得到的相比很不值得。如果你想做的事情在約束系統中不直接支持,你可以使用一個工廠模式。例如你有一個Martix<T>,而在這個Martix(矩陣)中,你可能想定義一個「點乘」【譯註:矩陣上的一種乘法運算,另一種稱為「叉乘」】方法。這意味著你最終將要考慮如何將兩個T相乘,但你不能將這說成是一個約束,至少當T不是int、double或float時你不能這麼說。但你可以讓你的Martix帶有一個Calculator<T>作為參數,而在Calculator<T>中,有一個稱為Multiply的方法。你可以在其中進行實現,並將結果傳遞給Martix。

Bruce Eckel:而且Calculator也是一個參數化的類型。

Anders Hejlsberg:是的。這有些像工廠模式,還有很多方法可以做到,這也許不是你最喜歡的方法,但做任何事情都要付出代價。

Bruce Eckel: 是呀,我開始認為C++模板是一種弱類型機制。而當你想其中添加了約束後,你從弱類型走向了強類型。但這一定會帶來更多的複雜性。這就是代價吧。

Anders Hejlsberg: 關於類型你可以認為它是一個輪盤。這個輪盤放得越高,程序員的日子就會越不好過,但更高的安全性隨之而來。但你可以把這個輪盤向任何一個方向轉得任意遠。