19.1.1 為什麼泛型?
沒有泛型,一些通用的資料結構只能使用object類型來存貯各種類型的資料。例如,下面這個簡單的Stack類將它的資料存放在一個object數組中,而它的兩個方法,Push和Pop,分別使用object來接受和返回資料:
public class Stack
{
object[] items;
int count;
public void Push(object item) {...}
public object Pop() {...}
}
儘管使用object類型使得Stack類非常靈活,但它也不是沒有缺點。例如,可以向堆棧中壓入任何類型的值,譬如一個Customer執行個體。然而,重新取回一個值得時候,必須將Pop方法返回的值顯式地轉換為合適的類型,書寫這些轉換變更要提防運行時類型檢查錯誤是很乏味的:
Stack stack = new Stack();
stack.Push(new Customer());
Customer c = (Customer)stack.Pop();
如果一個實值型別的值,如int,傳遞給了Push方法,它會自動裝箱。而當待會兒取回這個int值時,必須顯式的類型轉換進行拆箱:
Stack stack = new Stack();
stack.Push(3);
int i = (int)stack.Pop();
這種裝箱和拆箱操作增加了執行的負擔,因為它帶來了動態記憶體分配和運行時類型檢查。
Stack類的另外一個問題是無法強制堆棧中的資料的種類。確實,一個Customer執行個體可以被壓入棧中,而在取回它的時候會意外地轉換成一個錯誤的類型:
Stack stack = new Stack();
stack.Push(new Customer());
string s = (string)stack.Pop();
儘管上面的代碼是Stack類的一種不正確的用法,但這段代碼從技術上來說是正確的,並且不會發生編譯期間錯誤。為題知道這段代碼啟動並執行時候才會出現,這時會拋出一個InvalidCastException異常。
Stack類無疑會從具有限定其元素類型的能力中獲益。使用泛型,這將成為可能。
19.1.2 建立和使用泛型
泛型提供了一個技巧來建立帶有型別參數(type parameters)的類型。下面的例子聲明了一個帶有型別參數T的泛型Stack類。型別參數又類名字後面的定界符“<”和“>”指定。通過某種類型建立的Stack<T>的執行個體 可以無欲轉換地接受該種類型的資料,這強過於與object相互裝換。型別參數T扮演一個預留位置的角色,直到使用時指定了一個實際的類型。注意T相當於內部數組的資料類型、Push方法接受的參數類型和Pop方法的傳回值類型:
public class Stack<T>
{
T[] items;
int count;
public void Push(T item) {...}
public T Pop() {...}
}
使用泛型類Stack<T>時,需要指定實際的類型來替代T。下面的例子中,指定int作為參數類型T:
Stack<int> stack = new Stack<int>();
stack.Push(3);
int x = stack.Pop();
Stack<int>類型稱為已構造類型(constructed type)。在Stack<int>類型中出現的所有T被替換為型別參數int。當一個Stack<int>的執行個體被建立時,items數組的本地存貯是int[]而不是object[],這提供了一個實質的存貯,效率要高過非泛型的Stack。同樣,Stack<int>中的Push和Pop方法只操作int值,如果向堆棧中壓入其他類型的值將會得到編譯期間的錯誤,而且取回一個值時不必將它顯示轉換為原類型。
泛型可以提供強型別,這意味著例如向一個Customer對象的堆棧上壓入一個int將會產生錯誤。這是因為Stack<int>只能操作int值,而Stack<Customer>也只能操作Customer對象。下面例子中的最後兩行會導致編譯器報錯:
Stack<Customer> stack = new Stack<Customer>();
stack.Push(new Customer());
Customer c = stack.Pop();
stack.Push(3); // 類型不符錯誤
int x = stack.Pop(); // 類型不符錯誤
泛型型別的聲明允許任意數目的型別參數。上面的Stack<T>例子只有一個型別參數,但一個泛型的Dictionary類可能有兩個型別參數,一個是鍵的類型另一個是值的類型:
public class Dictionary<K,V>
{
public void Add(K key, V value) {...}
public V this[K key] {...}
}
使用Dictionary<K,V>時,需要提供兩個型別參數:
Dictionary<string,Customer> dict = new Dictionary<string,Customer>();
dict.Add("Peter", new Customer());
Customer c = dict["Peter"];
19.1.3 泛型型別執行個體化
和非泛型型別類似,編譯過的泛型型別也由中繼語言(IL, Intermediate Language)指令和中繼資料表示。泛型型別的IL表示當然已由型別參數進行了編碼。
當程式第一次建立一個已構造的泛型型別的執行個體時,如Stack<int>,.NET通用語言執行平台中的即時編譯器(JIT, just-in-time)將泛型IL和中繼資料轉換為本地代碼,並在進程中用實際類型代替型別參數。後面的對這個以構造的泛型型別的引用使用相同的本地代碼。從泛型型別建立一個特定的構造類型的過程稱為泛型型別執行個體化(generic type instantiation)。
.NET通用語言執行平台為每個由之類型執行個體化的泛型型別建立一個專門的拷貝,而所有的參考型別共用一個單獨的拷貝(因為,在本地代碼層級上,引用知識具有相同表現的指標)。
19.1.4 約束
通常,一個泛型類不會只是存貯基於某一型別參數的資料,他還會調用給定類型的對象的方法。例如,Dictionary<K,V>中的Add方法可能需要使用CompareTo方法來比較索引值:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...} // 錯誤,沒有CompareTo方法
...
}
}
由於指定的型別參數K可以是任何類型,可以假定存在的參數key具有的成員只有來自object的成員,如Equals、GetHashCode和ToString;因此上面的例子會發生編譯錯誤。當然可以將參數key轉換成為一具有CompareTo方法的類型。例如,參數key可以轉換為IComparable:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (((IComparable)key).CompareTo(x) < 0) {...}
...
}
}
當這種方案工作時,會在運行時引起動態類型轉換,會增加開銷。更要命的是,它還可能將錯誤報表延遲到運行時。如果一個鍵沒有實現IComparable介面,會拋出InvalidCastException異常。
為了提供更強大的編譯期間類型檢查和減少類型轉換,C#允許一個可選的為每個型別參數提供的約束(constraints)列表。一個型別參數的約束指定了一個類型必須遵守的要求,使得這個型別參數能夠作為一個變數來使用。約束由關鍵字where來聲明,後跟型別參數的名字,再後是一個類或介面類型的列表,或構造器約束new()。
要想使Dictionary<K,V>類能保證索引值始終實現了IComparable介面,類的聲明中應該對型別參數K指定一個約束:
public class Dictionary<K,V> where K: IComparable
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
通過這個聲明,編譯器能夠保證所有提供給型別參數K的類型都實現了IComparable介面。進而,在調用CompareTo方法前不再需要將索引值顯式轉換為一個IComparable介面;一個受約束的型別參數類型的值的所有成員都可以直接使用。
對於給定的型別參數,可以指定任意數目的介面作為約束,但只能指定一個類(作為約束)。每一個被約束的型別參數都有一個獨立的where子句。在下面的例子中,型別參數K有兩個介面約束,而型別參數E有一個類約束和一個構造器約束:
public class EntityTable<K,E>
where K: IComparable<K>, IPersistable
where E: Entity, new()
{
public void Add(K key, E entity)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
上面例子中的構造器約束,new(),保證了作為的E類型變數的類型具有一個公用、無參的構造器,並允許泛型類使用new E()來建立該類型的一個執行個體。
型別參數約束的使用要小心。儘管它們提供了更強大的編譯期間類型檢查並在一些情況下改進了效能,它還是限制了泛型型別的使用。例如,一個泛型類List<T>可能約束T實現IComparable介面以便Sort方法能夠比較其中的元素。然而,這麼做使List<T>不能用於那些沒有實現IComparable介面的類型,儘管在這種情況下Sort方法從來沒被實際調用過。
19.1.5 泛型方法
有的時候一個型別參數並不是整個類所必需的,而只用於一個特定的方法中。通常,這種情況發生在建立一個需要一個泛型型別作為參數的方法時。例如,在使用前面描述過的Stack<T>類時,一種公用的模式就是在一行中壓入多個值,如果寫一個方法通過單獨調用它類完成這一工作會很方便。對於一個特定的構造過的類型,如Stack<int>,這個方法看起來會是這樣:
void PushMultiple(Stack<int> stack, params int[] values) {
foreach (int value in values) stack.Push(value);
}
這個方法可以用於將多個int值壓入一個Stack<int>:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);
然而,上面的方法只能工作於特定的構造過的類型Stack<int>。要想使他工作於任何Stack<T>,這個方法必須寫成泛型方法(generic method)。一個泛型方法有一個或多個型別參數,有方法名後面的“<”和“>”限定符指定。這個型別參數可以用在參數列表、返回至和方法體中。一個泛型的PushMultiple方法看起來會是這樣:
void PushMultiple<T>(Stack<T> stack, params T[] values) {
foreach (T value in values) stack.Push(value);
}
使用這個方法,可以將多個元素壓入任何Stack<T>中。當調用一個泛型方法時,要在函數的調用中將型別參數放入角括弧中。例如:
Stack<int> stack = new Stack<int>();
PushMultiple<int>(stack, 1, 2, 3, 4);
這個泛型的PushMultiple方法比上面的版本更具可重用性,因為它能工作於任何Stack<T>,但這看起來並不舒服,因為必須為T提供一個型別參數。然而,很多時候編譯器可以通過傳遞給方法的其他參數來推斷出正確的型別參數,這個過程稱為類型推斷(type inferencing)。在上面的例子中,由於第一個正式的參數的類型是Stack<int>,並且後面的參數類型都是int,編譯器可以認定型別參數一定是int。因此,在調用泛型的PushMultiple方法時可以不用提供型別參數:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);
19.2 匿名方法
實踐處理方法和其他回調方法通常需要通過專門的委託來調用,而不是直接調用。因此,迄今為止我們還只能將一個實踐處理和回調的代碼放在一個具體的方法中,再為其顯式地建立委託。相反,匿名方法(anonymous methods)允許將與一個委託關聯的代碼“內聯(in-line)”到使用委託的地方,我們可以很方便地將代碼直接寫在委託執行個體中。除了看起來舒服,匿名方法還共用對本地語句所包含的函數成員的訪問。如果想在命名方法(區別於匿名方法)中達成這種共用,需要手動建立一個輔助類並將本地成員“提升(lifting)”到這個類的域中。
下面的例子展示了從一個包含一個列表框、一個文字框和一個按鈕的表單中擷取一個簡單的輸入。當按鈕按下時文字框中的文本會被添加到列表框中。
class InputForm: Form
{
ListBox listBox;
TextBox textBox;
Button addButton;
public MyForm() {
listBox = new ListBox(...);
textBox = new TextBox(...);
addButton = new Button(...);
addButton.Click += new EventHandler(AddClick);
}
void AddClick(object sender, EventArgs e) {
listBox.Items.Add(textBox.Text);
}
}
儘管對按鈕的Click事件的響應只有一條語句,這條語句也必須放到一個獨立的具有完整的參數列表的方法中,並且要手動建立引用該方法的EventHandler委託。使用匿名方法,事件處理的代碼會變得更加簡潔:
class InputForm: Form
{
ListBox listBox;
TextBox textBox;
Button addButton;
public MyForm() {
listBox = new ListBox(...);
textBox = new TextBox(...);
addButton = new Button(...);
addButton.Click += delegate {
listBox.Items.Add(textBox.Text);
};
}
}
一個匿名方法由關鍵字delegate和一個可選的參數列表組成,並將語句放入“{”和“}”限定符中。前面例子中的匿名方法沒有使用提供給委託的參數,因此可以省略參數列表。要想訪問參數,你名方法應該包含一個參數列表:
addButton.Click += delegate(object sender, EventArgs e) {
MessageBox.Show(((Button)sender).Text);
};
上面的例子中,在匿名方法和EventHandler委託類型(Click事件的類型)之間發生了一個隱式的轉換。這個隱式的轉換是可行的,因為這個委託的參數列表和傳回值類型和匿名方法是相容的。精確的相容規則如下:
• 當下麵條例中有一條為真時,則委託的參數列表和匿名方法是相容的:
o 匿名方法沒有參數列表且委託沒有輸出(out)參數。
o 匿名方法的參數列表在參數數目、類型和修飾符上與委託參數精確匹配。
• 當下面的條例中有一條為真時,委託的傳回值與匿名方法相容:
o 委託的傳回值類型是void且匿名方法沒有return語句或其return語句不帶任何錶達式。
o 委託的傳回值類型不是void但和匿名方法的return語句關聯的運算式的值可以被顯式地轉換為委託的傳回值類型。
只有參數列表和傳回值類型都相容的時候,才會發生匿名型別向委託類型的隱式轉換。
下面的例子使用了匿名方法對函數進行了“內聯(in-lian)”。匿名方法被作為一個Function委託類型傳遞。
using System;
delegate double Function(double x);
class Test
{
static double[] Apply(double[] a, Function f) {
double[] result = new double[a.Length];
for (int i = 0; i < a.Length; i++) result = f(a);
return result;
}
static double[] MultiplyAllBy(double[] a, double factor) {
return Apply(a, delegate(double x) { return x * factor; });
}
static void Main() {
double[] a = {0.0, 0.5, 1.0};
double[] squares = Apply(a, delegate(double x) { return x * x; });
double[] doubles = MultiplyAllBy(a, 2.0);
}
}
Apply方法需要一個給定的接受double[]元素並返回double[]作為結果的Function。在Main方法中,傳遞給Apply方法的第二個參數是一個匿名方法,它與Function委託類型是相容的。這個匿名方法只簡單地返回每個元素的平方值,因此調用Apply方法得到的double[]包含了a中每個值的平方值。
MultiplyAllBy方法通過將參數數組中的每一個值乘以一個給定的factor來建立一個double[]並返回。為了產生這個結果,MultiplyAllBy方法調用了Apply方法,向它傳遞了一個能夠將參數x與factor相乘的匿名方法。
如果一個本地變數或參數的範圍包括了匿名方法,則該變數或參數稱為匿名方法的外部變數(outer variables)。在MultiplyAllBy方法中,a和factor就是傳遞給Apply方法的匿名方法的外部變數。通常,一個局部變數的生存期被限制在塊內或與之相關聯的語句內。然而,一個被捕獲的外部變數的生存期要擴充到至少對匿名方法的委託引用符合垃圾收集條件時。
19.2.1 方法群組轉換
像前面章節中描述過的那樣,一個匿名方法可以被隱式轉換為一個相容的委託類型。C# 2.0允許對一組方法進行相同的轉換,即所任何時候都可以省略一個委託的顯式執行個體化。例如,下面的語句:
addButton.Click += new EventHandler(AddClick);
Apply(a, new Function(Math.Sin));
還可以寫做:
addButton.Click += AddClick;
Apply(a, Math.Sin);
當使用短形式時,編譯器可以自動地推斷應該執行個體化哪一個委託類型,不過除此之外的效果都和長形式相同。
19.3 迭代器
C#中的foreach語句用於迭代一個可枚舉(enumerable)的集合中的元素。為了實現可枚舉,一個集合必須要有一個無參的、返回列舉程式(enumerator)的GetEnumerator方法。通常,列舉程式是很難實現的,因此簡化列舉程式的任務意義重大。
迭代器(iterator)是一塊可以產生(yields)值的有序序列的語句塊。迭代器通過出現的一個或多個yield語句來區別於一般的語句塊:
• yield return語句產生本次迭代的下一個值。
• yield break語句指出本次迭代完成。
只要一個函數成員的傳回值是一個列舉程式介面(enumerator interfaces)或一個可枚舉介面(enumerable interfaces),我們就可以使用迭代器:
• 所謂列舉程式借口是指System.Collections.IEnumerator和從System.Collections.Generic.IEnumerator<T>構造的類型。
• 所謂可枚舉介面是指System.Collections.IEnumerable和從System.Collections.Generic.IEnumerable<T>構造的類型。
理解迭代器並不是一種成員,而是實現一個功能成員是很重要的。一個通過迭代器實現的成員可以用一個或使用或不使用迭代器的成員覆蓋或重寫。
下面的Stack<T>類使用迭代器實現了它的GetEnumerator方法。其中的迭代器按照從頂端到底端的順序枚舉了棧中的元素。
using System.Collections.Generic;
public class Stack<T>: IEnumerable<T>
{
T[] items;
int count;
public void Push(T data) {...}
public T Pop() {...}
public IEnumerator<T> GetEnumerator() {
for (int i = count – 1; i >= 0; --i) {
yield return items;
}
}
}
GetEnumerator方法的出現使得Stack<T>成為一個可枚舉類型,這允許Stack<T>的執行個體使用foreach語句。下面的例子將值0至9壓入一個整數堆棧,然後使用foreach迴圈按照從頂端到底端的順序顯示每一個值。
using System;
class Test
{
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
foreach (int i in stack) Console.Write("{0} ", i);
Console.WriteLine();
}
}
這個例子的輸出為:
9 8 7 6 5 4 3 2 1 0
語句隱式地調用了集合的無參的GetEnumerator方法來得到一個列舉程式。一個集合類中只能定義一個這樣的無參的GetEnumerator方法,不過通常可以通過很多途徑來實現枚舉,包括使用參數來控制枚舉。在這些情況下,一個集合可以使用迭代器來實現能夠返回可枚舉介面的屬性和方法。例如,Stack<T>可以引入兩個新的屬性——IEnumerable<T>類型的TopToBottom和BottomToTop:
using System.Collections.Generic;
public class Stack<T>: IEnumerable<T>
{
T[] items;
int count;
public void Push(T data) {...}
public T Pop() {...}
public IEnumerator<T> GetEnumerator() {
for (int i = count – 1; i >= 0; --i) {
yield return items;
}
}
public IEnumerable<T> TopToBottom {
get {
return this;
}
}
public IEnumerable<T> BottomToTop {
get {
for (int i = 0; i < count; i++) {
yield return items;
}
}
}
}
TopToBottom屬性的get訪問器只返回this,因為堆棧本身就是一個可枚舉類型。BottomToTop屬性使用C#迭代器返回了一個可枚舉介面。下面的例子顯示了如何使用這兩個屬性來以任意順序枚舉棧中的元素:
using System;
class Test
{
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
foreach (int i in stack.TopToBottom) Console.Write("{0} ", i);
Console.WriteLine();
foreach (int i in stack.BottomToTop) Console.Write("{0} ", i);
Console.WriteLine();
}
}
當然,這些屬性還可以用在foreach語句的外面。下面的例子將調用屬性的結果傳遞給一個獨立的Print方法。這個例子還展示了一個迭代器被用作一個帶參的FromToBy方法的方法體:
using System;
using System.Collections.Generic;
class Test
{
static void Print(IEnumerable<int> collection) {
foreach (int i in collection) Console.Write("{0} ", i);
Console.WriteLine();
}
static IEnumerable<int> FromToBy(int from, int to, int by) {
for (int i = from; i <= to; i += by) {
yield return i;
}
}
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
Print(stack.TopToBottom);
Print(stack.BottomToTop);
Print(FromToBy(10, 20, 2));
}
}
這個例子的輸出為:
9 8 7 6 5 4 3 2 1 0
0 1 2 3 4 5 6 7 8 9
10 12 14 16 18 20
泛型和非泛型的可枚舉介面都只有一個單獨的成員,一個無參的GetEnumerator方法,它返回一個列舉程式介面。一個可枚舉介面很像一個列舉程式工廠(enumerator factory)。每當調用了一個正確地實現了可枚舉介面的類的GetEnumerator方法時,都會產生一個獨立的列舉程式。
using System;
using System.Collections.Generic;
class Test
{
static IEnumerable<int> FromTo(int from, int to) {
while (from <= to) yield return from++;
}
static void Main() {
IEnumerable<int> e = FromTo(1, 10);
foreach (int x in e) {
foreach (int y in e) {
Console.Write("{0,3} ", x * y);
}
Console.WriteLine();
}
}
}
上面的代碼列印了一個從1到10的簡單乘法表。注意FromTo方法只調用了一次用來產生可枚舉介面e。而e.GetEnumerator()被調用了多次(通過foreach語句)來產生多個相同的列舉程式。這些列舉程式都封裝了FromTo聲明中指定的代碼。注意,迭代其代碼改變了from參數。不過,列舉程式是獨立的,因為對於from參數和to參數,每個列舉程式擁有它自己的一份拷貝。在實現可枚舉類和列舉程式類時,列舉程式之間的過渡狀態(一個不穩定點)是必須消除的眾多細微瑕疵之一。C#中的迭代器的設計可以協助消除這些問題,並且可以用一種簡單的本能的方式來實現健壯的可枚舉類和列舉程式類。
19.4 不完全類型
儘管在一個單獨的檔案中維護一個類型的所有代碼是一項很好的編程實踐,但有些時候,當一個類變得非常大,這就成了一種不切實際的約束。而且,程式員經常使用代碼產生器來產生一個應用程式的初始結構,然後修改產生的代碼。不幸的是,當以後需要再次發布原代碼的時候,現存的修正會被重寫。
不完全類型允許類、結構和介面被分成多個小塊兒並存貯在不同的源檔案中使其容易開發和維護。另外,不完全類型可以分離機器產生的代碼和使用者書寫的部分,這使得用工具來加強產生的代碼變得容易。
要在多個部分中定義一個類型的時候,我們使用一個新的修飾符——partial。下面的例子在兩個部分中實現了一個不完全類。這兩個部分可能在不同的源檔案中,例如第一部分可能是機器通過資料庫影射工具產生的,而第二部分是手動創作的:
public partial class Customer
{
private int id;
private string name;
private string address;
private List<Order> orders;
public Customer() {
...
}
}
public partial class Customer
{
public void SubmitOrder(Order order) {
orders.Add(order);
}
public bool HasOutstandingOrders() {
return orders.Count > 0;
}
}
當上面的兩部分編譯到一起時,產生的代碼就好像這個類被寫在一個單元中一樣:
public class Customer
{
private int id;
private string name;
private string address;
private List<Order> orders;
public Customer() {
...
}
public void SubmitOrder(Order order) {
orders.Add(order);
}
public bool HasOutstandingOrders() {
return orders.Count > 0;
}
}
不完全類型的所有部分必須放到一起編譯,才能在編譯期間將它們合并。需要特別注意的是,不完全類型並不允許擴充已編譯的類型。