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; } } 不完全類型的所有部分必須放到一起編譯,才能在編譯期間將它們合並。需要特別注意的是,不完全類型並不允許擴展已編譯的類型。