You are on page 1of 22

偉大的泛型

本章涵蓋的範圍
ƒ 泛型型別與方法
ƒ .NET 2.0 的泛型集合
ƒ 泛型的限制
ƒ 和其他語言做比較

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

1
這邊的目的是為了方便介紹本章的主題。
64 y Part II:C# 2.0 改善的議題

對大多數人而言,泛型(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 偉大的泛型 y 65

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


檢查,而不需要到執行時期才知道錯誤,這邊我可以告訴各位一個經驗:「在
過去所撰寫的程式碼,常常因為缺少型別的檢查,而導致錯誤發生,換句話
說,這是 C# 1.0 常發生的問題」。轉型就像是一個警告的標識,必須隨時關
注型別上的安全,而非程式的流暢性,雖然泛型不會完全降低型別上問題,
但是可讀性越高的程式碼,反而比較容易找出問題的所在,若程式碼可以容
易理解,那就越容易寫出一個正確的程式。

不管在執行效能上的改善,或是型別安全的檢查,這些都讓泛型變得更有價值,這
邊會說明兩件事情,第一,對於編譯器來說,需要花更多的時間來做型別的檢查,
但相對著,它可以減少執行時期所花費的時間;第二,JIT 可以更聰明的操作數值
型別,不需要再透過 boxing 以及 unboxing 的方式,來處理數值型別與參考型別的
轉換,然而這些的改變,可以大量的提升執行效能,並且減少記憶體消耗。

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

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

3.2 使用簡單的泛型
泛型可以說是一個相當大的主題,因為包含了許多重要的概念,假如想要了解泛型
的每一個功能以及特性,可以去參考 C# 2.0 的規格書,裡面會針對不同的案例進
行詳細的說明,當然不需要去了解一些偏僻的主題(事實上,對於其他的特性也是
一樣,例如我們不需要了解關於所有變數的指派,或是型別的轉換,只需要能夠修
正好程式碼,讓編譯器可以正常的編譯)。
66 y Part II:C# 2.0 改善的議題

在這一節當中,會討論到一些平常使用的泛型,包含了一些泛型的 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 偉大的泛型 y 67

從對照表印出每
個索引鍵(key)
與值(value)

在 CountWords 方法中,可以用來計算單字出現的次數。在一開始建立了一個空的
Dictionary 的泛型物件n,並設定對照表的索引鍵為 string 的型別,而值為 int 型別;
接著使用了常規表示式o,並將段落文字分割成多個單字,這邊是依據空白字元來
當做分割條件;之後透過迴圈來判斷單字是否已經在對照表當中,假如存在,就會
將目前單字的個數加 1,若不存在,則會初始一個新的單字,並設定成 1p。不知
道各位有沒有發現到,在取得以及存入到 Dictionary 的泛型物件時,並沒有做任何
的轉型動作,這些存取的動作,也都是使用索引子(indexer)來達成,像是在
frequencies[word]++; 這 行 程 式 碼 , 也 許 某 些 人 可 能 就 會 發 現 , 也 可 以 使 用
frequencies[word] = frequencies[word]+1; 的寫法。

在最下面的部分,透過 foreach 來取出集合內的元素,這和之前 Hashtable 使用的


DictionaryEntry 相似,同樣和 KeyValuePair<string,int> 型別一樣,都具有 Key 以
及 Value 的屬性q,然而在 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)。
68 y Part II:C# 2.0 改善的議題

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

在這邊會提到很多關於泛型的專有名詞,這是為了讓本章的主題能夠更清楚
的表達,當然這也是能讓我們在溝通上能夠一致,對於要熟悉 C# 語言規格
書來說,這是相當有幫助的。 WARNING

如果型別參數並未指定任何的型別,我們稱為 非約束泛型型別 (unbound generic


type);如果型別參數已經有指定其他的型別時,這樣的型別稱為 建構式型別
(constructed type)。非約束泛型可以用來提供建構式型別的輪廓,例如可以用哪
些型別來初始化非約束泛型,或許這麼說會覺得不了解,可以看下圖 3.1,將會描
述這兩者之間的區別。

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

Specification of
型別參數
type arguments
特別化
Dictionary<string,int>
Dictionary<string,int> Dictionary<byte,long>
Dictionary<byte,long> (etc)
其他
Hashtable (建構式型別)
(constructed type) (建構式型別)
(constructed type)

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

Instance of Dictionary<string,
Instance of int> Dictionary<byte,long>
Instance of
Hashtable 的實體
Dictionary<string,int> Dictionary<byte,long>
的實體

非泛型的概觀
Nongeneric 泛型概觀
Generic
blueprints blueprints

圖 3.1 非約束泛型扮演著建構式型別的輪廓,並且也扮演著實體物件的框架。
Chapter 3 偉大的泛型 y 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 public void Add
(TKey key, TValue value) (string key, int value)
public TValue this [TKey key] public int this [string key]
{ get; set; } { get; set; }
public bool ContainsValue public bool ContainsValue
(TValue value) (int value)
public bool ContainsKey public bool ContainsKey
(TKey key) (string key)

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

現在已經知道角括號內 TKey 與 TValue 的含義(例如前面的 Dictionary<TKey,


TValue>),在下面的程式碼當中,將說明 Dictionary<TKey, TValue> 的實作方式,
這邊只會針對方法做描述,並不會列出方法內的邏輯:

宣告泛型類別

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

實作泛型介面

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

在 上 面 的 程 式 碼 當 中 , 可 以 看 到 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 偉大的泛型 y 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
3
都會被取代成 string 型別。圖 3.2 顯示了 ConvertAll 方法在每個部分的含義 。
回傳值
Return type
(a generic list) 型別參數
Type parameter 參數名稱
Parameter name
(泛型的 List)

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

方法名稱
Method name 參數的型別(泛型委派)
Parameter type (generic delegate)

圖 3.2 分析泛型方法

3
這邊將 ConvertAll 方法的參數名稱做修改,將原本的 converter 改成 conv,至於其他部分則是和文件定
義的相同。
72 y Part II:C# 2.0 改善的議題

上面對於 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 偉大的泛型 y 73

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

建立委派實體

呼叫泛型方法,用來
轉換 List 物件內的所
有元素

在一開始建立了 List 的物件n,並且指定型別參數為 int,所以 List 物件內就只能


存放 int 的資料;接下來建立了一個泛型的委派實體o,由於 Converter<int, double>
的型別參數分別為 int 以及 double,表示方法的簽章需要傳入 int 的參數,並且回
傳值為 double 的型別,關於泛型的委派型別,會在 5.2 節有更多的說明,在這邊各
位只要知道委派實體(執行的動作為 TakeSquareRoot 方法),是如何將 int 開根號
後並取得會傳值;接下來就會去呼叫泛型方法p,並且型別參數指定為 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 實作泛型方法(在非泛型的類別上)
74 y Part II:C# 2.0 改善的議題

在這邊宣告了 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 偉大的泛型 y 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> 是屬於數值型別,不過條
76 y Part II:C# 2.0 改善的議題

件約束只會針對型別參數 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 偉大的泛型 y 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>,這邊
就只是隱含的參考轉換而已。
78 y Part II:C# 2.0 改善的議題

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

條件約束的宣告 範例
class Sample<T> 合法:Sample<Stream>
where T : Stream Sample<MemoryStream>
不合法:Sample<object>
Sample<string>
struct Sample<T> 合法:Sample<IDisposable>
where T : IDisposable Sample<DataTable>
不合法:Sample<StringBuilder>
class Sample<T> 合法:Sample<string>
where T : IComparable<T> 不合法:Sample<FileInfo>
class Sample<T,U> 合法:Sample<Stream,IDisposable>
where T : U 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 偉大的泛型 y 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 型別。事實上我
80 y Part II:C# 2.0 改善的議題

們並不需要去特別的指定,可以直接使用呼叫方法的方式,傳入兩個 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 偉大的泛型 y 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 在泛型上使用預設值
82 y Part II:C# 2.0 改善的議題

在上面的範例,使用了三種不同型別來呼叫泛型方法,分別是 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 偉大的泛型 y 83

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

參考的比較

使用 string 運算子
多載來比較

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


的比較,並不會使用 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 顯示了完整的程式碼。
84 y Part II:C# 2.0 改善的議題

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

在上面的程式碼,會將透過建構子傳入數值並存放在變數上,然而可以透過唯讀的
屬性取出,在這邊我們有實作了 IEquatable<Pair<TFirst, TSecond>>,並使用強型
別的方式。在這類別中覆寫了 Equal 以及 GetHashCode 的方法,這是為了透過 Equal
方法來比對兩個 Pair 是否相同,並且在我們所寫的 Equal 方法中,使用到了

You might also like