偉大的泛型epaper.gotop.com.tw/pdf/AXP013300.pdf · 64 y Part II:C# 2.0改善的議題...

22
偉大的泛型 本章涵蓋的範圍 泛型型別與方法 .NET 2.0 的泛型集合 泛型的限制 和其他語言做比較 在開始之前先講一個小故事 1 :有一天我和我太太到雜貨店去買些東西,在離開之 前她問我是否有帶清單,確認之後我們就離開了雜貨店,在這我們犯了一個錯誤, 就是我太太問我是否有帶「購物清單」,但我以為是另外一張清單,在這邊就提到 C# 2.0 一個新特性:泛型(因為清單可以是代表各種類型的清單),之後透過購物 清單去購買一些商品(購買的動作就會使用到匿名方法),如果可以透過這樣的方 式來達成,不管是哪一種清單,都會自動的去採購對應的商品,那這就是 C# 2.0 泛型的主要目的。泛型的其中一個用途,就是可以讓不同型別去做相同動作的事情。 1 這邊的目的是為了方便介紹本章的主題。

Transcript of 偉大的泛型epaper.gotop.com.tw/pdf/AXP013300.pdf · 64 y Part II:C# 2.0改善的議題...

偉大的泛型

本章涵蓋的範圍

泛型型別與方法 .NET 2.0的泛型集合 泛型的限制 和其他語言做比較

在開始之前先講一個小故事1:有一天我和我太太到雜貨店去買些東西,在離開之

前她問我是否有帶清單,確認之後我們就離開了雜貨店,在這我們犯了一個錯誤,

就是我太太問我是否有帶「購物清單」,但我以為是另外一張清單,在這邊就提到

C# 2.0一個新特性:泛型(因為清單可以是代表各種類型的清單),之後透過購物

清單去購買一些商品(購買的動作就會使用到匿名方法),如果可以透過這樣的方

式來達成,不管是哪一種清單,都會自動的去採購對應的商品,那這就是 C# 2.0泛型的主要目的。泛型的其中一個用途,就是可以讓不同型別去做相同動作的事情。

1 這邊的目的是為了方便介紹本章的主題。

Part II:C# 2.0改善的議題 64

對大多數人而言,泛型(generic)是 C# 2.0相當重要的一個特性,它改善了集合物件存取上的效能,以及讓程式碼能夠更有彈性,並且解決執行期間可能發生的錯

誤(關於型別上的錯誤),透過泛型的特性,就可以很清楚的告訴編譯器,目前是

屬於哪一種型別,例如在 ArrayList存取資料時,若取出的資料轉型錯誤,就會在執行時期拋出例外,若使用 List<T> 就可以在編譯時期偵測出來。然而泛型可以使用在型別以及方法上,一般在傳遞參數的時候,都只是將值傳入到方法中,而泛型

方法(generic method)則是多了一個型別參數,用來告訴方法目前所要使用的型別。看完這些可能會很難理解這裡所要表達的意思,但如果對它有一些概念存在

了,相信熟悉之後會對泛型愛不釋手。

在這一章我們將了解如何使用泛型以及相關的方法(.NET Framework所提供的一些 API),並透過範例來教導各位如何寫泛型的程式。在前面已經有提到泛型重要的原因,之後將帶各位來了解泛型的一些用法,以及如何改善弱型別的集合物件,

而在本章的最後,會說明一些泛型經常遇到的限制,以及它如何去解決這樣的問

題,之後會比較與 C# 泛型相似的其他語言。

下面會先讓各位了解目前所遇到的問題,然後會說明泛型是如何做改善。

3.1 為什麼需要泛型? 在寫 C# 1.0 程式的時候,不知道各位是否常常有使用到轉型?如果希望讓任何型別的資料都可以放到集合中,可以發現到一件事情,就是程式碼要做大量的轉型工

作。然而這些集合物件所有的 API,參數以及回傳值都是使用 object的型別,雖然object可以用來接收不同型別的物件,但是 object所能提供的資訊是相當少的,所以為了能夠取得到原來型別的物件,就只能透過轉型來達成。

轉型是否就是不好呢?如果程式碼不常使用到轉型,其實轉型並不算壞,但是相反

的,大量的轉型將會影響到效能上的運作,轉型是為了要告訴編譯器更多的資訊,

而編譯器也會信任我們所做的型別轉換,所以到執行時期時,才會對型別做檢查,

並判斷是否屬於有效的轉型。

為了避免不必要的轉型,因此就有了一些新的想法,例如宣告變數或是方法時,可

以透過額外的資訊,來告訴目前屬於哪種型別,可以方便其他人了解在集合內所要

處理的資料型別。泛型可以確保在編譯時期間,判斷傳入參數的型別是相符的(過

去就需要手動的去檢查,或是在執行時期發生錯誤時,才知道該如何修正)。有了

這些額外的資訊,可以讓程式碼更加的有效率,並提供編譯器檢查型別的方式;而

Visual Studio的 IntelliSense也可以取得額外的資訊,會判斷集合內可以加入哪些型別(例如宣告 List<string>,Visual Studio 就會知道下次要加入的資料需為string);在方法的呼叫上,可以清楚的定義要傳入的型別以及回傳型別,並且程式碼可以在一開始,就定義好使用的型別,這樣可以也可以更容易閱讀。

Chapter 3 偉大的泛型 65

泛型會減少程式的 bug? 許多關於泛型的描述,都強調型別會在編譯時期做

檢查,而不需要到執行時期才知道錯誤,這邊我可以告訴各位一個經驗:「在

過去所撰寫的程式碼,常常因為缺少型別的檢查,而導致錯誤發生,換句話

說,這是 C# 1.0常發生的問題」。轉型就像是一個警告的標識,必須隨時關

注型別上的安全,而非程式的流暢性,雖然泛型不會完全降低型別上問題,

但是可讀性越高的程式碼,反而比較容易找出問題的所在,若程式碼可以容

易理解,那就越容易寫出一個正確的程式。

不管在執行效能上的改善,或是型別安全的檢查,這些都讓泛型變得更有價值,這

邊會說明兩件事情,第一,對於編譯器來說,需要花更多的時間來做型別的檢查,

但相對著,它可以減少執行時期所花費的時間;第二,JIT可以更聰明的操作數值型別,不需要再透過 boxing以及 unboxing的方式,來處理數值型別與參考型別的轉換,然而這些的改變,可以大量的提升執行效能,並且減少記憶體消耗。

關於泛型的優點,可能和靜態語言非常相似(比起動態語言來說),例如在編譯時

期,可以對型別進行檢查、提供程式碼更多的資訊、IDE支援 IntelliSense,以及較好的執行效能,這些的優點是相當容易理解的,例如我們在使用一般的 API(像是ArrayList),它無法區分型別的差異,因為都使用 object型別來存取資料,並且需要對元素進行轉型,若透過泛型就不是這樣的做法了。

接下來就真正的開始來使用泛型。

3.2 使用簡單的泛型 泛型可以說是一個相當大的主題,因為包含了許多重要的概念,假如想要了解泛型

的每一個功能以及特性,可以去參考 C# 2.0 的規格書,裡面會針對不同的案例進行詳細的說明,當然不需要去了解一些偏僻的主題(事實上,對於其他的特性也是

一樣,例如我們不需要了解關於所有變數的指派,或是型別的轉換,只需要能夠修

正好程式碼,讓編譯器可以正常的編譯)。

Part II:C# 2.0改善的議題 66

在這一節當中,會討論到一些平常使用的泛型,包含了一些泛型的 API,以及自行建立的泛型方法。在這邊先提醒各位,如果在閱讀本章時,有遇到一些不了解的概

念,會建議各位針對想要了解的部分來閱讀,例如如何使用 .NET Framework所提供的泛型型別以及方法,從這些感興趣的議題開始學起,會比較容易了解泛型的用

法,而不建議在看不懂的情況下繼續閱讀本章。

接下來,我們先來看 .NET 2.0所提的一個泛型型別:Dictionary<TKey, TValue>,當然它是屬於一個集合。

3.2.1 透過範例學習:泛型的資料字典 使用泛型型別(generic type),可以容易找出錯誤的地方,並且在閱讀程式碼的時候,可以容易的推測程式碼的運作方式,並不需要因為型別的轉換,而花費許多

時間(泛型的其中一項優點,在於編譯時期會檢查型別的安全,所以對於開發人員

來說,只需要確認程式碼是否能夠編譯成功),然而本章的目的在於說明泛型的好

處,所以各位不需再對 C# 1.0的型別錯誤而感到困擾。

接下來我們直接來看程式碼,在範例 3.1 使用了 Dictionary<TKey, TValue>(和C#1.0的 Hashtable具有相同的功能)計算單字在一段文字所出現的次數。

範例 3.1 使用 Dictionary<TKey, TValue>計算單字在一段文字所出現的次數

將一段文字切分成

單字的陣列

建立新的對照表,並用來計

算單字出現的次數

加入或更新對照表

Chapter 3 偉大的泛型 67

在 CountWords方法中,可以用來計算單字出現的次數。在一開始建立了一個空的Dictionary的泛型物件 ,並設定對照表的索引鍵為 string的型別,而值為 int型別;接著使用了常規表示式 ,並將段落文字分割成多個單字,這邊是依據空白字元來

當做分割條件;之後透過迴圈來判斷單字是否已經在對照表當中,假如存在,就會

將目前單字的個數加 1,若不存在,則會初始一個新的單字,並設定成 1 。不知

道各位有沒有發現到,在取得以及存入到 Dictionary的泛型物件時,並沒有做任何的轉型動作,這些存取的動作,也都是使用索引子(indexer)來達成,像是在frequencies[word]++; 這行程式碼,也許某些人可能就會發現,也可以使用frequencies[word] = frequencies[word]+1; 的寫法。

在最下面的部分,透過 foreach 來取出集合內的元素,這和之前 Hashtable 使用的DictionaryEntry相似,同樣和 KeyValuePair<string,int> 型別一樣,都具有 Key以及 Value 的屬性 ,然而在 C# 1.0 就需要進行轉型,並且對於數值型別則需要做boxing。最後在迴圈的部分,使用 Console.WriteLine 來印出 entry.Key 以及entry.Value的值,並且在這邊也不需要在做任何的轉型。

雖然在 Hashtable以及 Dictionary<TKey, TValue> 有些不同,在這邊先不做進一步探討,因為在 3.4節會討論 .NET 2.0所有關於集合的議題,就目前而言,會建議各位可以開始來動手寫寫看,如果跑出來的結果並不是預期的答案,這有可能對於泛

型還有一些不熟悉的地方。

現在已經看到關於泛型的範例,接下來就先來了解關於 Dictionary<TKey, TValue> 當中的 TKey以及 TValue,以及它為何需要這麼做。

3.2.2 泛型型別與型別參數 泛型會透過兩種方式來呈現:泛型型別(generic type, 包含了類別、介面、委派還有結構,但列舉並不支援)以及泛型方法(generic method),這兩個不同的地方在於:泛型型別是用來指定類別要以哪種型別來處理資料,例如上面提到的

Dictionary<string, int>,就會將類別內有用到 TKey 以及 TValue 的型別,取代成string以及 int;然而泛型方法,只會在方法內進行型別的取代,而用來設定這些型別的稱為型別參數(type parameter)。

從對照表印出每

個索引鍵(key)與值(value)

Part II:C# 2.0改善的議題 68

泛型的型別參數(type parameter,以下統稱為型別參數),它會代表一個真實的型別,而型別參數會是使用角括號 <> 來宣告,若有多個型別參數需要定義,則會使用逗號隔開,所以 Dictionary<TKey, TValue>的型別參數就是 TKey 以及TValue,當我們使用這些泛型的時候,就會透過此參數來指定真實的型別,例如在範例 3.1將 TKey指定成 string型別,TValue指定成 int型別。

在這邊會提到很多關於泛型的專有名詞,這是為了讓本章的主題能夠更清楚

的表達,當然這也是能讓我們在溝通上能夠一致,對於要熟悉 C# 語言規格

書來說,這是相當有幫助的。

如果型別參數並未指定任何的型別,我們稱為非約束泛型型別(unbound generic type);如果型別參數已經有指定其他的型別時,這樣的型別稱為建構式型別

(constructed type)。非約束泛型可以用來提供建構式型別的輪廓,例如可以用哪些型別來初始化非約束泛型,或許這麼說會覺得不了解,可以看下圖 3.1,將會描述這兩者之間的區別。

Hashtable

Instance ofHashtable

Instantiation

Dictionary<TKey,TValue>(unbound generic type)

Dictionary<string,int>(constructed type)

Instance ofDictionary<string,int>

Specification oftype arguments

Dictionary<byte,long>(constructed type)

(etc)

Instance ofDictionary<byte,long>

Nongenericblueprints

Genericblueprints

Instantiation Instantiation

圖 3.1 非約束泛型扮演著建構式型別的輪廓,並且也扮演著實體物件的框架。

WARNING

Dictionary<TKey,TValue>(非約束泛型型別)

泛型概觀 非泛型的概觀

型別參數

特別化

其他

Dictionary<byte,long> 的實體

Dictionary<string, int>的實體

使用 new來實體化

Dictionary<string,int>(建構式型別)

Dictionary<byte,long>(建構式型別)

使用 new來實體化使用 new來實體化

Chapter 3 偉大的泛型 69

關於建構式型別,若深入的探討還可以再分成兩種類型2,分別為開放型別(open

type)與封閉型別(close type),這兩者的差別在於:是否能夠指定其他地方的型別參數。開放型別允許我們對繼承的介面或是類別,透過本身的型別參數去指定父

類別的型別參數;封閉型別則是不會指定繼承類別的型別參數(沒有繼承的泛型型

別也算是封閉型別),例如 SubClass<T, S> 繼承自 Dictionary<TKey, TValue>,寫法為 public class SubClass<T, S> : Dictionary<T, string>{…},對於 SubClass就是一種開放型別,因為本身的型別參數,有指定到父類別的型別參數;若 SubClass<T, S> 繼承自 Dictionary<string, int> 或是 ArrayList,則此時就是屬於封閉型別,因為本身的型別參數沒有使用在父類別上。在 3.4.4節會討論更多關於非約束型別的議題,以及如何搭配 typeof運算子。

關於泛型的型別參數,會在我們指定型別時,將類別內有用到型別參數宣告的變數

取代成指定的型別,3.1顯示了 Dictionary<TKey, TValue> 非約束泛型型別的一些方法以及屬性,這是還沒對型別參數做任何設定,而右邊跟它對照的

Dictionary<string, int>,則我們已經建立成的建構式型別。

表 3.1 關於泛型型別的進入點,以及對型別參數做設定

泛型型別的方法 透過型別參數取代後的方法

public void Add (TKey key, TValue value)

public TValue this [TKey key] { get; set; }

public bool ContainsValue (TValue value)

public bool ContainsKey (TKey key)

public void Add (string key, int value)

public int this [string key] { get; set; }

public bool ContainsValue (int value)

public bool ContainsKey (string key)

在表 3.1有一件很重要的事要說明,上面並沒有任何的泛型方法,都只是泛型型別所提供的方法,這些方法上的型別(TKey或 TValue),會在宣告泛型型別時決定。

現在已經知道角括號內 TKey 與 TValue 的含義(例如前面的 Dictionary<TKey, TValue>),在下面的程式碼當中,將說明 Dictionary<TKey, TValue> 的實作方式,這邊只會針對方法做描述,並不會列出方法內的邏輯:

2 對於開放型別與封閉型別或許可能還不是很了解,不必太過於執著這些名詞上的差別,在這邊會比較希望能夠讓各位明白泛型的使用方式以及一些限制。

宣告泛型類別

Part II:C# 2.0改善的議題 70

在 上 面 的 程 式 碼 當 中 , 可 以 看 到 Dictionary<TKey, TValue> 實 作 了IEnumerable<KeyValuePair<TKey, TValue>> 的泛型介面,並且 KeyValuePair型別參數的來源,會是從 Dictionary的 TKey與 TValue取得,若根據前面的範例初始成 Dictionary<string, int>,此時介面會取代成 IEnumerable<KeyValuePair<string, int>>,對於 IEnumerable<T> 來說,我們將型別參數 T取代成 KeyValuePair<TKey, TValue> 的結構,這種我們稱為雙重泛型(doubly generic)的介面,由於實作了IEnumerable<T> 介面,所以在範例 3.1才可以使用 foreach來取得Dictionary<string, int> 的索引鍵與值。這邊可以注意到一件事情,在建構子的部分,並沒有定義型別參數的角括號,這是因為型別參數是屬於型別而非建構子,所以我們會將角括號放

在型別上。

型別參數像方法一樣,可以具有多載性(overloading),所以就可以定義像MyType、MyType<T>、MyType<T, U>、MyType<T, U , V>或是其他更多的。通常型別參數的命名,並沒有很多人會去注意它,但是對於使用的人來說,可能會難

以了解這些型別參數的目的,例如泛型的方法,它是允許存在兩個相同的方法,但

型別參數的個數需不同,這對於開發人員可能就會感到相當的困擾,畢竟有兩個相

同的方法,卻有不同個數的型別參數。

實作泛型介面

宣告無參數的建

構子 宣告的方法有使用 型別參數

Chapter 3 偉大的泛型 71

關於型別參數的命名:雖然型別參數可以使用 T、S或是 V來命名,如果可以

正確的給予名稱,對於開發人員會比較容易了解,例如 Dictionary<TKey,

TValue>,可以很清楚的知道 TKey 的目的,是用來代表資料的索引鍵,而

TValue 則代表資料。當然也可能只有單一的型別參數,照慣例都會使用 T 來

命名(List<T> 就是一個很好的例子),至於有多個的型別參數,會建議命

名的時候,盡量是與它有相關的,並且避免只用 T 或 S 的方式來命名,尤其

是在多個型別參數時。

上面介紹了關於泛型型別的使用及概念,接下來將探討泛型方法的使用。

3.2.3 泛型方法與泛型的宣告 在上面也提到了泛型型別,當指定了泛型的型別參數時,會自動將方法的參數,全

部取代成指定的型別,然而泛型方法的概念,可能會比泛型型別來得難懂,這是因

為大多數的人對於方法有一定的了解,會很直覺的認為方法是具有回傳型別以及參

數,而泛型方法則是更進一步的擴充,允許每個方法擁有各自的型別參數。

Dictionary<TKey, TValue> 並沒有任何的泛型方法,但它的好鄰居 List<T>,有提供泛型方法。然而 List<T> 是用來存放相同型別的資料,如果是 List<string>,代表在 List集合內,所存放的每個元素都是 string型別,要記住一件事情,這邊的型別參數 T是屬於整個類別的,所以當型別參數指定成 string時,在 List類別內的 T都會被取代成 string型別。圖 3.2顯示了 ConvertAll方法在每個部分的含義3

List<TOutput> ConvertAll<TOutput>(Converter<T,TOutput> conv)

Return type(a generic list)

Parameter type (generic delegate)Method name

Parameter nameType parameter

圖 3.2 分析泛型方法

3 這邊將 ConvertAll方法的參數名稱做修改,將原本的 converter改成 conv,至於其他部分則是和文件定義的相同。

回傳值 (泛型的 List)

方法名稱

參數名稱 型別參數

參數的型別(泛型委派)

Part II:C# 2.0改善的議題 72

上面對於 ConvertAll 泛型方法的宣告,可能會覺得很難以親近,尤其在泛型型別已經有實作其他的泛型介面(List 有實作 IList<T> 等其他的介面),然而方法的參數又是使用泛型委派,這可能會讓我們難以理解它的用法,對於這樣的使用方

式,不需要感到太多的驚訝或是困擾,各位不需要看得太過於複雜,可以將它想像

成一般的方法,只是泛型方法多了型別參數的傳入,至於其他的部分,都是相同的。

接下來針對 ConvertAll方法來逐步的解說,由於 ConvertAll方法是宣告在 List<T> 當中,所以只要在方法中有使用到 T的型別參數,就會被類別的型別參數所取代,例如 List<string>,就會將方法中使用到的 T取代成 string,結果就會如下:

這樣看起來似乎好理解一些了,但是還有 TOutput的型別參數要處理,不過 TOutput是屬於方法的型別參數,因為方法有使用到角括號 <> 來宣告,所以在這邊會使用另一個熟悉的 Guid型別,將泛型方法的型別參數指定成 Guid,最後就會變成下面的方法宣告:

所以取代後的方法,由左至右的說明將會是如下:

方法會回傳一個 List<Guid>。 方法名稱為 ConvertAll。 方法只有一個型別參數(TOutput),並且將型別參數指定成 Guid。 此方法只有一個參數,參數的名稱為 conv,並且它是屬於 Converter<string,

Guid>的泛型型別。

看到這裡就,只剩下 Converter<string, Guid> 還沒有做解釋,事實上 Converter <string, Guid> 是一個泛型的委派型別(generic delegate type, 原本的非約束型別為Convereter<TInput, TOutput>),並且會將 string轉換成 Guid。

所以 ConvertAll的泛型方法,主要會處理 List<string>的所有元素,並且全部轉換成 Guid的型別,不過這邊要注意一件事,由於 Converter<string, Guid> 是屬於泛型的委派型別,所以需要在自己的程式碼,定義轉換的方法,方法的簽章會是這樣:

public Guid MyConverter(string convertString){…},所以就可以將方法指派給Converter<string, Guid>來執行,並執行 string轉成 Guid的動作。

在上已經看完 ConvertAll 泛型方法的說明,在範例 3.2 當中,會使用 ConvertAll的方法來做說明,我們建立了一個 List<int>的物件,在集合內所存放的元素是屬於 int的型別,並且要將它全部轉換成浮點數。

範例 3.2 List<T>.ConvertAll<TOutput> 方法的執行動作

Chapter 3 偉大的泛型 73

在一開始建立了 List的物件 ,並且指定型別參數為 int,所以 List物件內就只能存放 int的資料;接下來建立了一個泛型的委派實體 ,由於 Converter<int, double>的型別參數分別為 int以及 double,表示方法的簽章需要傳入 int的參數,並且回傳值為 double的型別,關於泛型的委派型別,會在 5.2節有更多的說明,在這邊各位只要知道委派實體(執行的動作為 TakeSquareRoot方法),是如何將 int開根號後並取得會傳值;接下來就會去呼叫泛型方法 ,並且型別參數指定為 double,以及傳入了委派實體 converter,所以 doubles就是轉換後的 List集合,最後就會印出1、1.1414…、1.732…以及 2。為了轉換 List內所有的元素,就需要額外定義一個方法,或許各位會覺得相當的麻煩,若使用匿名方法(在 5.2節會介紹)就可以很簡單的達成,會將整個方法當作參數來使用(會轉換成類似 C++的 inline程式碼)。然而泛型方法並不需要每次都要指定型別參數,如果在類別已經指定型別參數,編

譯器就會自動去推論型別參數。

或許你會覺得範例 3.2的做法有甚麼意義?只是印出數字的平方根,用 for迴圈也是可以做到相同的目的。這邊並不是要說明印平方根的程式如何寫,而是要告訴各

位 ConvertAll 的用法,可以將整個 List 的元素轉換成另一種集合,這樣就是不需要做不同型別的 ConvertAll 方法(因為每個方法傳入的型別都不同),可以透過型別參數來指定想要處理的型別。在之前有提到 ArrayList 的物件,同樣也是有ConvertAll的方法,不過是將 object轉成 object,這樣就會容易造成型別上的安全。

看完了泛型方法的使用,下面範例 3.3將會介紹泛型方法的宣告,以及如何去撰寫方法內的程式邏輯。

範例 3.3 實作泛型方法(在非泛型的類別上)

建立委派實體

建立 List的集合物件,並且存放的元素為 int型別

呼叫泛型方法,用來

轉換 List物件內的所有元素

Part II:C# 2.0改善的議題 74

在這邊宣告了 MakeList<T> 的泛型方法,並且只有一個型別參數 T,然而在方法定義了兩個參數,並且型別都是屬於 T。在方法內建立了 List<T> 的物件,這表示List<T> 的型別參數,會根據泛型方法的型別參數來決定,所以在呼叫泛型方法時,若將 T指定為 string型別,這時候就會將方法內(包含方法的參數)的 T全部取代成 string,所以就會改變成 List<string>。不過大部分的情況下,很多泛型方法並沒有使用類別的型別參數。

說到這邊,相信對泛型也有了簡單的了解,如果還不太了解關於泛型的概念,例如

非約束型別、建構式型別、封閉型別或是開放型別等,可以再回去閱讀一下,接下

來會探討比較複雜的泛型,讓各位可以更深入的了解以及使用。

List<T> 與 Dictionary<TKey, TValue> 算是最常使用的泛型型別,如果想要對這些泛型的集合物件有更多的了解,可以先跳到 3.5.1 以及 3.5.2 節,在那會有更多的說明,相信學會這些泛型的集合物件時,應該就不會想再使用 C# 1.0的 ArrayList或是 Hashtable。

當你在實際撰寫泛型的時候,相信會慢慢的體會到泛型的用意,尤其在自定一個泛

型型別或是泛型方法,將會發現泛型可以減少許多程式碼的重寫,這是因為執行的

邏輯都相同,但卻要針對不同型別做處理,就像上面提到的 ConvertAll 方法,然而泛型也可以讓型別變得更安全,也可以避免轉型上的錯誤,這些是強型別所帶來

的好處。

3.3 泛型的進階 接下來會繼續介紹泛型的其他特性。首先來談談型別的條件約束(type constraint, 在後面會簡稱條件約束),條件約束是用來限制型別參數,可以用來限制要繼承哪個

類別或介面,若指定的型別不符合條件約束,此時就會被拒絕使用,然而這樣的功

能,對於在建立泛型型別或是泛型方法是相當有用的,至少可以將型別參數限制在

一定的範圍內。

之後將會提到型別推論(type inference)。在使用泛型方法時,不需要每次都明確的指定型別參數,編譯器會自動的偵測泛型方法,並且推論所使用的型別參數,讓

Chapter 3 偉大的泛型 75

程式碼可以看起來更簡單,也可以確保型別上的安全,這部分會在第三篇關於 C# 編譯器的章節討論。

在本節的最後一部分,會介紹型別參數要如何設定預設值,以及型別參數是如何進

行比對的動作,之後會透過一個完整的範例來說明泛型的特性,讓我們可以對這些

特性更加的了解。

雖然本節介紹的有點深入,但是這些泛型的特性並沒有想像中的難,有很多的特性

會幫助我們在程式的開發上,接下來就先從條件約束開始介紹。

3.3.1 型別的條件約束 到目前所介紹的泛型,在型別參數都是沒有任何的限制,例如使用到的 List<int> 或是 Dictionary<object, FileMode>,全部都是無條件約束,不過對於集合物件的使用,通常不會去在意元素的類型資料,不過在某些情況下,可能會希望型別參數是

有限制的,例如型別參數只能是屬於參考型別或是數值型別,換句話說,我們會希

望可以針對型別參數來指定有效的型別,在 C# 2.0 可以使用條件約束來達到這樣的需求。

條件約束會宣告在泛型方法或泛型型別的最後面,並且使用 where關鍵字來表示,有點像是 SQL語法一樣,會使用 where來做一些篩選,並且條件可以混合使用,在邊會介紹四種的條件約束,然而在所有的泛型,條件約束的語法都會是相同的,

首先介紹的是參考型別的條件約束。

參考型別的條件約束

參考型別的條件約束(reference type constraints),它會在泛型的後面加上 where T : class,並且型別參數所指定的型別,必須是屬於參考型別,所以型別可以是類別、介面、陣列或是委派等,下面則是結構使用條件約束的宣告方式:

有效的型別會是如下的列表:

RefSample<IDisposable> RefSample<string> RefSample<int[]>

以下是無效的型別:

RefSample<Guid> RefSample<int>

這邊故意使用 RefSample結構的型別(是屬於數值型別的一種),這是為了強調條件約束只會對型別參數做限制,雖然 RefSample<string> 是屬於數值型別,不過條

Part II:C# 2.0改善的議題 76

件約束只會針對型別參數 T來做限制,所以在這邊指定型別參數為 string,這樣的使用是有效的。

若將型別參數限制成參考型別時,這時候透過型別參數所宣告的變數(包含方法的

參數),可以透過 == 或是 != 的運算子(也包含 null)來比對。不過這邊會建議各位,盡量不要使用 == 和 != 運算子,因為這些運算子只會比對參考的位址,而不會比對兩個值是否相等,即使這些型別有對運算子多載(operator overload),例如 string型別有對 == 運算子重新多載,它仍然會比對兩個物件的參考值。例如附錄的補充程式碼:<補充程式碼,參見附錄 B 譯註 3>

數值型別的條件約束

數值型別的條件約束(value type constraints),只允許型別參數必須為數值型別(會使用 where T : struct來表示數值型別的限制),這當中也包含了 Nullable型別(將會在第 4章描述),所以條件約束的宣告方式如下:

有效的型別:

ValSample<int> ValSample<FileMode>

無效的型別:

ValSample<object> ValSample<StringBuilder>

儘管 T 已經被限制成數值型別,但是在這邊 ValSample 仍然是屬於參考型別,不過這邊要注意一件事情,System.Enum 以及 System.ValueType 是屬於參考型別,所以不允許用來當作 ValSample的型別參數。如果同時存在多個條件約束,必須要把數值型別的條件約束擺在第一個位置(和參考型別的條件約束一樣),如果型別

已經指定成數值型別時,這時候會不允許使用 == 或是 != 的運算子來做比較。

雖然很少會使用到參考型別或是數值型別的條件約束,除非是在下一將會介紹的

Nullable型別,就有可能會使用到數值型別的條件約束,但是接下來的兩個條件約束,會是平常寫程式容易使用到的。

建構子的條件約束

建構子的條件約束(constructor type constraints, 會是使用 where T : new(),若與其他條件約束一起使用時,一定要將其指定為最後一個),它可以透過型別參數來建

立物件,不過型別必須要有一個無參數的建構子,如果沒有無參數的建構子,則會

被條件約束給限制住。然而建構子的條件約束,可以是參考型別或是數值型別,只

Chapter 3 偉大的泛型 77

要是符合非靜態類別、非抽象類別,並且具有公開的無參數建構子(parameterless constructor),以上這些都是有效的。

C# vs CLI標準:關於數值型別以及建構子,C# 與 CLI的定義是有差異的。

在 CLI的規格當中,規定數值型別不可以擁有無參數的建構子,所以在建立一

個新的數值,就必須要傳入初始值到建構子;但在 C# 的規格書規中,對於

所有的數值型別,都會預設一個無參數的建構子,並且可以使用有參數或是

無參數的建構子來建立實體,這是為了讓數值型別可以透過 Reflection來建立

實體(動態的建立實體)。

下面是關於建構子的條件約束:

方法會回傳我們所指定型別的實體,並且型別必須擁有無參數的建構子,例如使用

CreateInstance<int>(); 或是 CreateInstance<object>(); 都是有效的,但是如果使用CreateInstance<string>() ; 則會出現錯誤的訊息,這是因為 string並沒有提供任何無參數的建構子。

在上面的範例中,透過建構子的條件約束,可以方便我們建立一個實體物件(這是

因為使用無參數的建構子,所以不需要考慮參數初始化的問題),這樣就像是設計

模式(design pattern)所用到的工廠樣版一樣,可以隨時建立需要的實體物件(就像工廠一樣,需要一直生產產品,但通常都會依照標準的介面來實作),接下來則

是最後一個條件約束。

衍生型別的條件約束

衍生型別的條件約束(derivation type constraints),會限制型別參數必須是衍生自某個類別或是介面

4,這種條件約束是相當的有用,當我們確定型別參數都是繼承

自某個類別時,就可以透過這種條件約束來限制,例如 public class MyGeneric<T> where T : Stream{…},此時在 MyGeneric類別內,只要是使用 T所宣告的變數,就可以使用 Stream所提供的方法以及屬性。然而也可以指定某一個型別參數,當成是另一個型別參數的條件限制,像是 public class SampleClass<T, S> where T : S,這種我們稱為 Naked 的條件約束(naked constraint)。在表 3.2當中,顯示一些衍生型別的條件約束範例,在表格的右半部,則會說明合法與不合法的宣告方式。

4 若型別可以透過隱含的方式來轉換,這樣也符合衍生型別的條件約束,例如有個條件約束為 where T :

IList<Shape>,此時使用 Circle[] 也會是有效的,即使 Circle[] 並沒有實作任何的 IList<Shape>,這邊就只是隱含的參考轉換而已。

Part II:C# 2.0改善的議題 78

表 3.2 衍生型別條件約束的範例

條件約束的宣告 範例

class Sample<T> where T : Stream

合法:Sample<Stream> Sample<MemoryStream>

不合法:Sample<object> Sample<string>

struct Sample<T> where T : IDisposable

合法:Sample<IDisposable> Sample<DataTable>

不合法:Sample<StringBuilder>

class Sample<T> where T : IComparable<T>

合法:Sample<string> 不合法:Sample<FileInfo>

class Sample<T,U> where T : U

合法:Sample<Stream,IDisposable> Sample<string,string>

不合法:Sample<string,IDisposable>

在第三個條件約束 where T : IComparable<T>,會將泛型介面當作一個條件約束,這代表型別參數必須是實作 IComparable<T> 的介面,所以若將 T指定成 string的型別,將會是合法的,因為 string有實作 IComparable<string> 的介面,至於 where T : IList<string> 都是一樣的做法。當然也可以使用多的介面來當作條件約束,但最多只能有一個類別,例如下面的寫法:

但這樣的寫法是不允許的:

上面的條件約束是不可能存在,因為必須同時繼承 Stream以及 ArrayList的類別。但是使用此條件約束有一些限制,就是指定的類別不可以是結構類型、密封類別

(sealed class, 例如 string就是)或是其他以下的特殊型別:

System.Object System.Enum System.ValueType System.Delegate

衍生型別的條件約束可能是最常使用的一種,它會限制型別參數必須繼承哪些類別

或是介面,這樣不管對於泛型型別或是泛型方法,就可以知道型別參數的一些限

制,例如 where T : IComparable<T>,它會用來比較兩個 T實體是否相同,因為在IComparable介面當中,有定義一個 CompareTo(T other)的方法,是用來比對傳入的型別是否和本身相同。在 3.3.3節會看到關於這些條件限制的範例,接下來則是關於條件約束的合併。

Chapter 3 偉大的泛型 79

條件約束的合併

在前面已經有提過,條件約束是可以同時多個存在。在前面已經有看過條件約束的

寫法,但是還沒有看過將多個條件約束合併在一起,可以很清楚的知道一件事,並

沒有任何的型別同時是屬於參考型別以及數值型別,所以對於這樣的合併是拒絕

的。不同的型別參數,可以擁有獨立的條件約束,並且要使用各自的 where來限制型別。下面就來看一下關於一些有效以及無效的合併條件限制:

有效:

無效:

上面是關於條件約束合併的列表,可以簡單的區分別出有效以及無效的版本,只要

記住每個型別參數都需要有各自的 where來限制。然而第三個合法的條件限制是有趣的,如果 U是屬於數值型別並且又繼承自 T,但是 T是屬於參考型別,這樣不會覺得不合理嗎?答案是 T必須是 object或者是 U所實作的介面,當然這樣的寫法會容易讓人混淆,所以不太建議使用這樣的方式。

關於所有的泛型型別的宣告以及條件約束的用法,相信大家已經對這些已經有了一

些認知,接下來會討論型別參數的推論,這和之前提到的 var關鍵字很像,編譯器會去推論它真實的型別,然而在前面範例 3.2的 List.ConvertAll就是其中的一種,關於編譯器是如何推算出可能的型別,這就是下一節要說明的主題。

3.3.2 泛型方法的型別推論 在呼叫某一些泛型方法時,可能對於型別參數的指定是多餘的,通常可以從傳入的

的參數來得知是屬於哪一種的型別,這樣對於編譯器來說,就可以知道型別參數所

指定的型別,所以在 C# 2.0 的編譯器,可以讓我們更簡單的來呼叫泛型方法,而不需要使用角括號來指明它的型別。

在開始之前,必須要強調一件事情,泛型的型別推論只適用於泛型方法上,所以它

並不能在泛型型別上使用,可以看到前面在範例 3.3泛型方法的宣告:

在上面的程式碼,可以看到 MakeList方法傳入的參數都是型別 T,然後在下一行則指定 T為 string型別,所以 MakeList所傳入的參數都是 string型別。事實上我

Part II:C# 2.0改善的議題 80

們並不需要去特別的指定,可以直接使用呼叫方法的方式,傳入兩個 string型別的參數,這時候編譯器就會知道 T所指的型別就是 string,如下面的寫法:

上面的寫法看起來簡短許多,而且不需要去指定型別參數的型別,但是在某些情況

下,這可能會造成閱讀上變得更加困難,因為會不知道目前所使用的型別為何,雖

然對於編譯器是會相當的容易識別,不過建議各位可以根據當時的情況來決定,如

果可以很容易看出型別參數的型別,就可以不需要標明。

編譯器是如何知道目前使用的型別參數是 string呢?因為在宣告變數 list時,也有指明它原本的型別為 List<string>,所以就知道型別參數為 string 型別,然而這樣的指派動作,並沒有對型別參數做推論,所以這代表著編譯器如果做出錯誤的判

斷,仍然會在編譯時期發生錯誤。

不過在哪種情況下編譯器會發生錯誤呢?我們來看一個範例,如果將上面的型別參

數指定成 object,並且傳入的參數為字串,對於這樣的寫法仍然是有效,例如下面的寫法: List<object> list = MakeList<object> ("Line 1", "Line 2");

在上面的泛型方法,有指明型別參數為 object型別,所以傳入的參數即使是 string型別,也會自動轉型成 object(這樣是有效的),但如果改成下面的寫法: List<object> list = MakeList("Line 1", "Line 2"); //編譯器會發出錯誤

這時候編譯器就會發生錯誤訊息,因為傳入的參數是 string型別,所以編譯器會將MakeList的型別參數推論成 string,但是在 List的型別參數已經指定成 object,所以無法將 MakeList 的回傳值指派到 list 上面(因為 List<string>無法隱含轉換為List<object>)。下面列出了 C# 2.0編譯器型別推論的步驟:

1. 對於編譯器而言,會透過每一個方法上的參數做推論(這裡指的是傳入的參數值),用來判斷泛型方法上的型別參數為何。

2. 檢查第一步驟是否推論正確,如果方法上的某一個參數,與判斷的型別參數不相同,編譯器就會推論失敗。

3. 檢查所有的型別參數是否都有被推論到,並且不能只針對某些型別參數做指定,要就全部指定,否則就全部不指定。

在這邊並沒有列出所有的規則(並不推薦去了解關於編譯器的型別推論,除非很有

興趣的想要知道),假如會認為編譯器可能會推論所有的型別參數,並試著去呼叫

沒有指定型別參數的方法(當然也有可能會推論錯誤),這只不過是讓編譯器花較

多的時間去處理這樣的事情,對於開發人員只是少了一個指定型別參數的動作。

Chapter 3 偉大的泛型 81

3.3.3 泛型的實作 當你在實作泛型的一些類別或是方法時,大多會使用 T 來當作型別(也就是型別參數)的名稱,並且在泛型的類別也都使用這樣的命名方式,不過下面有幾點關於

泛型的事情是必須知道的。

使用預設值

現在已經知道在泛型使用的型別,但是要如何取得型別的預設值呢?以及如何對型

別 T 做初始化的動作呢?因為現在不知道型別參數所指定的型別,會無法對型別設定預設值,並且也不知道它是屬於參考型別或是數值型別,所以就不知道該設定

成 null或者是 0的數值。

一般而言,很少會對型別參數設定預設值,但是如果能夠這樣做的話,在某些情況

下是相當有用的,然而 Dictionary<TKey, TValue> 就是一個相當好的例子。在Dictionary 類別中有提供一個 TryGetValue 的方法,這方法就像數值型別使用的TryParse方法一樣,會使用到 out的參數來取得到數值,並且回傳一個布林值來判斷是否取值成功,若有取到值,則會將數值寫入到 out的參數上,但如果失敗的話,這時候就會取得 TValue型別的預設值(關於 out參數的使用,需要將一個變數傳入到方法中,並且要在參數的前面加上 out關鍵字,假設目前是 Dictionary<string, int>,所以就可以這樣呼叫 TryGetValue(“35”, out returnValue);)。

TryXXX 樣版模型:在 .NET當中,有一些樣版模型是可以透過名稱來識別,

例如 BeginXXX以及 EndXXX,代表這是屬於非同步的操作方法,然而 TryXXX

的樣版模型,通常是用在可能會發生錯誤的情況下,但如果處理過程中有錯

誤,並不會拋出任何的例外。事實上我們可以使用這種方式來取得資料,就

不需要透過 try…catch的來捕捉例外,同時也能夠提高效,但最重要的是,它

可以避免一些錯誤而導致例外的發生,對於這樣的樣版模型是相當的有用。

C# 2.0有提供一種預設值表達式(default value expression),但它並不是一種運算子,可以將它想像成和 typeof運算子相似的關鍵字,範例 3.4顯示了泛型方法和型別推論的一些範例,並且使用衍生型別的條件約束來限制型別參數。

範例 3.4 在泛型上使用預設值

Part II:C# 2.0改善的議題 82

在上面的範例,使用了三種不同型別來呼叫泛型方法,分別是 string、int 以及DateTime 型別,然而在 CompareToDefault 的方法,限制了型別參數必須要實作IComparable<T> 的介面,這樣就可以呼叫 CompareTo(T)的方法,並傳入一個數值;在 CompareToDefault方法當中,會將傳入的值和型別的預設值做比較,例如在第一個使用 CompareToDefault(“x”),這時會回傳 1(代表傳入的值大於預設值null),因為 string參考型別的預設值會是 null,所以傳入字串 “x” 一定會比 null來的大;而接下來的三行則是會和 int的預設值做比較,然而 int的預設值會是 0,這三行會依序印出 1、0、-1;最後的 DataTime的預設值將會是 DataTime.MinValue。

如果在範例 3.4傳入了一個 null值,正常來說,在呼叫 CompareTo的這行程式碼就會拋出 NullReferenceException,不過別擔心,因為衍生型別的條件約束是使用IComparer<T>,所以在編譯時期就會發出錯誤的警告。

直接比較

雖然在範例 3.4的比較方式是可行的,但總不能每次要比對型別的時候,都限制型別要實作 IComparable<T> 或是 IEquatable<T> 的介面(會提供強型別的 Equal(T)方法),但是這些介面,並沒有提供實際型別的額外資訊,然而透過這些介面,就

只能去進行比對的對作,就像呼叫 Equal(object)方法一樣,如果想要比對一個數值型別的資料,它就會將此數值進行 boxing的動作,但卻不知道它原本的型別為何。

如果型別參數是無任何條件約束的時候,就可以使用 == 或是 != 運算子來進行比對,但是這僅限於去判斷變數的值是否為 null,並無法去比對兩個 T的型別參數。在某些情況下,型別參數有可能是指派成數值型別(除了 Nullable型別),若數值型別和 null 進行比對,將永遠不會相等;當型別參數為參考型別時,對於比對會是有用的;但是如果是屬於 Nullable 型別的話,在比對的過程中,可能會無法將Nullable型別視為 null值。

當型別參數限制成數值型別時,== 以及 != 運算子將會無法使用。相反的,若型別參數限制成參考型別時,這種比對的方式,將會取決於型別是否還有額外的條件約

束存在,如果沒有,對於 == 以及 != 的運算子將是可以用來比較的;如果所限制的型別具有運算子多載(operator overload),例如將運算子 == 以及 != 多載,對於型別參數的比較,並不會去使用多載的運算子來比較。範例 3.5使用了參考型別當作條件約束,而且型別參數指定成 string的型別。

Chapter 3 偉大的泛型 83

範例 3.5 使用 == 以及 != 運算子來比較參考型別

儘管 string型別將 == 運算子多載(在 的描述會印出 True),但在泛型方法 內

的比較,並不會使用 string的運算子多載,因為當使用 AreReferencesEqual<T> 方法時,對於編譯器來說,還不知道有型別參數 T 有多載的運算子可以使用,所以編譯器只會視為傳入的參數是 object型別(所以最後一行會印出 False),因此會去比對這兩個物件的參考位置是否相同,而不像倒數第二行的程式碼,會去比對字

串的內容是否相等。

對於兩個類別的比較,比較常使用 EqualityComparer<T> 以及Comparer<T>,這兩個都是在 System.Collections.Generic的命名空間中,並且都實作了 IEqualityComparer<T>(用來比對Dictionary的 key是相當有用的)以及 IComparer<T> 的介面(對於排序是相當的有用),然而 Default 的屬性,會回傳一個預設的比較物件(如果型別參數指定為 string,就會回傳 string

型別預設的 StringComparer比較物件),關於更多的可以參考 MSDN的文件。下面將會有範例介紹 EqualityComparer<T> 的使用。

完整的範例:使用一對數值來表達

接下來要介紹的這個範例,會實作一個泛型的介面,並且型別參數會指定成

Pair<TFirst, TSecond>,這是用來記錄兩個數值,有點類似 key/value的概念,但要注意的是,這兩個數值彼此之間是沒有任何的相關性,在程式中也提供了一些屬

性,並覆寫(override)Equal 以及 GetHashCode 的方法,允許我們的型別可以做相等的比對,範例 3.6顯示了完整的程式碼。

使用 string運算子多載來比較

參考的比較

Part II:C# 2.0改善的議題 84

範例 3.6 使用泛型類別來比較兩個值

在上面的程式碼,會將透過建構子傳入數值並存放在變數上,然而可以透過唯讀的

屬性取出,在這邊我們有實作了 IEquatable<Pair<TFirst, TSecond>>,並使用強型別的方式。在這類別中覆寫了 Equal以及 GetHashCode的方法,這是為了透過 Equal方法來比對兩個 Pair 是否相同,並且在我們所寫的 Equal 方法中,使用到了