深入淺出.NET泛型編程

前言

.NET 2.0中泛型的出現是一個令人激動的特徵。但是,什麼是泛型?你需要它們嗎?你會在自己的應用軟件中使用它們?在本文中,我們將回答這些問題並細緻地分析泛型的使用,能力及其侷限性。

類型安全

.NET中的許多語言如C#,C++和VB.NET(選項strict爲on)都是強類型語言。作爲一個程序員,當你使用這些語言時,總會期望編譯器進行類型安全的檢查。例如,如果你把對一個Book類型的引用轉換成一個Vehicle型的引用,編譯器將告訴你這樣的cast是無效的。

然而,當談到.NET 1.0和1.1中的集合時,它們是無助於類型安全的。請考慮一個ArrayList的例子,它擁有一個對象集合--這允許你把任何類型的對象放於該ArrayList中。讓我們看一下例1中的代碼。

例1.缺乏類型安全的ArrayList

using System;
using System.Collections;
namespace TestApp
{
class Test
{
[STAThread]
static void Main(string[] args)
{
ArrayList list = new ArrayList();
list.Add(3);
list.Add(4);
//list.Add(5.0);
int total = 0;
foreach(int val in list)
{
total = total + val;
}
Console.WriteLine("Total is {0}", total);
}
}
}

本例中,我們建立了一個ArrayList的實例,並把3和4添加給它。然後我循環遍歷該ArrayList,從中取出整型值然後把它們相加。這個程序將產生結果"Total is 7"。現在,如果我註釋掉下面這句: 

list.Add(5.0);

程序將產生如下的運行時刻異常:

Unhandled Exception: System.InvalidCastException: Specified cast is not valid.
AtTestApp.Test.Main(String[]args)in :/workarea/testapp/class1.cs:line 17

哪裏出錯了呢?記住ArrayList擁有一個集合的對象。當你把3加到ArrayList上時,你已把值3裝箱了。當你循環該列表時,你是把元素拆箱 成int型。然而,當你添加值5.0時,你在裝箱一個double型值。在第17行,那個double值被拆箱成一個int型。這就是失敗的原因。

注意:上面的實例,如果是用VB.NET書寫的話,是不會失敗的。原因在於,VB.NET不使用裝箱機制,它激活一個把該double轉換成整型的方法。但是,如果ArrayList中的值是不能轉換成整型的,VB.NET代碼還會失敗。

作爲一個習慣於使用語言提供的類型安全的程序員,你希望這樣的問題在編譯期間浮出水面,而不是在運行時刻。這正是泛型產生的原因。

3. 什麼是泛型?

泛型允許你在編譯時間實現類型安全。它們允許你創建一個數據結構而不限於一特定的數據類型。然而,當使用該數據結構時,編譯器保證它使用的類型與類型安 全是相一致的。泛型提供了類型安全,但是沒有造成任何性能損失和代碼臃腫。在這方面,它們很類似於C++中的模板,不過它們在實現上是很不同的。

4. 使用泛型集合

.NET 2.0的System.Collections.Generics 命名空間包含了泛型集合定義。各種不同的集合/容器類都被"參數化"了。爲使用它們,只需簡單地指定參數化的類型即可。請看例2:

例2.類型安全的泛型列表

List<int> aList = new List<int>();
aList.Add(3);
aList.Add(4);
// aList.Add(5.0);
int total = 0;
foreach(int val in aList)
{
total = total + val;
}
Console.WriteLine("Total is {0}", total);

在例2中,我編寫了一個泛型的列表的例子,在尖括號內指定參數類型爲int。該代碼的執行將產生結果"Total is 7"。現在,如果我去掉語句doubleList.Add(5.0)的註釋,我將得到一個編譯錯誤。編譯器指出它不能發送值5.0到方法Add(),因爲 該方法僅接受int型。不同於例1,這裏的代碼實現了類型安全。

5. CLR對於泛型的支持

泛型不僅是一個語言級上的特徵。.NET CLR能識別出泛型。在這種意義上說,泛型的使用是.NET中最爲優秀的特徵之一。對每個用於泛型化的類型的參數,類也同樣沒有脫離開微軟中 間語言(MSIL)。換句話說,你的配件集僅包含你的參數化的數據結構或類的一個定義,而不管使用多少種不同的類型來表達該參數化的類型。例如,如果你定 義一個泛型類型MyList<T>,僅僅該類型的一個定義出現在MSIL中。當程序執行時,不同的類被動態地創建,每個類對應該參數化類型的一種類型。如 果你使用MyList<int>和MyList<double>,有兩種類即被創建。當你的程序執行時,讓我們進一步在例3中分析這一點。


例3.創建一個泛型類

//MyList.cs
#region Using directives
using System;
using System.Collections.Generic;
using System.Text;
#endregion
namespace CLRSupportExample
{
public class MyList<T>
{
private static int objCount = 0;
public MyList()
{objCount++; }
public int Count
{
get
{return objCount; }
}
}
}
//Program.cs
#region Using directives
using System;
using System.Collections.Generic;
using System.Text;
#endregion
namespace CLRSupportExample
{
class SampleClass {}
class Program
{
static void Main(string[] args)
{
MyList<int> myIntList = new MyList<int>();
MyList<int> myIntList2 = new MyList<int>();
MyList<double> myDoubleList = new MyList<double>();
MyList<SampleClass> mySampleList = new MyList<SampleClass>();
Console.WriteLine(myIntList.Count);
Console.WriteLine(myIntList2.Count);
Console.WriteLine(myDoubleList.Count);
Console.WriteLine(mySampleList.Count);
Console.WriteLine(new MyList<sampleclass>().Count);
Console.ReadLine();
}
}
}

該例中,我創建了一個稱爲MyList泛型類。爲把它參數化,我簡單地插入了一個尖括號。在<>內的T代表了實際的當使用該類時要指定的類型。在 MyList類中,定義了一個靜態字段objCount。我在構造器中增加它的值。因此我能發現使用我的類的用戶共創建了多少個那種類型的對象。屬性 Count返回與被調用的實例同類型的實例的數目。

在Main()方法,我創建了MyList<int>的兩個實例,一個 MyList<double>的實例,還有兩個MyList<SampleClass>的實例--其中SampleClass是我已定義了的類。問題是: Count(上面的程序的輸出)的值該是多少?在你繼閱讀之前,試一試回答這個問題。

解決了上面的問題?你得到下列的答案了嗎?

2
2
1
1
2

前面兩個2對應MyList <int>,第一個1對應MyList<double>,第二個1對應MyList<SampleClass>--在此,僅創建一個這種類型的實例。最後 一個2對應MyList<SampleClass>,因爲代碼中又創建了這種類型的另外一個實例。上面的例子說明MyList<int>是一個與 MyList<double>不同的類,而MyList<double>又是一個與MyList<SampleClass>不同的類。因此,在這個例中, 我們有四個類:MyList: MyList<T>,MyList<int>,MyList<double>和MyList<X>。注意,雖然有4個MyList類,但僅有一個被存儲在 MSIL。怎麼能證明這一點?請看圖1顯示出的使用工具ildasm.exe生成的MSIL代碼。

g1837sq8s579.jpg
圖 1.例3的MSIL

6. 泛型方法

除了有泛型類,你也可以有泛型方法。泛型方法可以是任何類的一部分。讓我們看一下例4:

例4.一個泛型方法

public class Program
{
public static void Copy<T>(List<T> source, List<T> destination)
{
foreach (T obj in source)
{
destination.Add(obj);
}
}
static void Main(string[] args)
{
List<int> lst1 = new List<int>();
lst1.Add(2);
lst1.Add(4);
List<int> lst2 = new List<int>();
Copy(lst1, lst2);
Console.WriteLine(lst2.Count);
}
}

Copy()方法就是一個泛型方法,它與參數化的類型T一起工作。當在Main()中激活Copy()時,編譯器根據提供給Copy()方法的參數確定出要使用的具體類型。
7. 無限制的類型參數

如果你創建一個泛型數據結構或類,就象例3中的MyList,注意其中並沒有約束你該使用什麼類型來建立參數化類型。然而,這帶來一些限制。如,你不能在參數化類型的實例中使用象==,!=或<等運算符,如:

if (obj1 == obj2) …

象==和!=這樣的運算符的實現對於 值類型和引用類型都是不同的。如果隨意地允許之,代碼的行爲可能很出乎你的意料。另外一種限制是缺省構造器的使用。例如,如果你編碼象new T(),會出現一個編譯錯,因爲並非所有的類都有一個無參數的構造器。如果你真正編碼象new T()來創建一個對象,或者使用象==和!=這樣的運算符,情況會是怎樣呢?你可以這樣做,但首先要限制可被用於參數化類型的類型。讀者可以自己先考慮如 何實現之。

8. 約束機制及其優點

一個泛型類允許你寫自己的類而不必拘泥於任何類型,但允許你的類的使用者以後可以指定要使用的具體類型。通過對可能會用於參數化的類型的類型施加約束,這給你的編程帶來很大的靈活性--你可以控制建立你自己的類。讓我們分析一個例子:

例5.需要約束:代碼不會編譯成功

public static T Max<T>(T op1, T op2) 
{
if (op1.CompareTo(op2) < 0)
return op1;
return op2;
}

例5中的代碼將產生一個編譯錯誤:

Error 1 ’T’ does not contain a definition for ’CompareTo’

假定我需要這種類型以支持CompareTo()方法的實現。我能夠通過加以約束--爲參數化類型指定的類型必須要實現IComparable接口--來指定這一點。例6中的代碼就是這樣:

例6.指定一個約束

public static T Max<T>(T op1, T op2) where T : IComparable
{
if (op1.CompareTo(op2) < 0)
return op1;
return op2;
}

在例6中,我指定的約束是,用於參數化類型的類型必須繼承自(實現)Icomparable。下面的約束是可以使用的:

where T : struct 類型必須是一種值類型(struct)

where T : class 類型必須是一種引用類型(class)

where T : new() 類型必須有一個無參數的構造器

where T : class_name 類型可以是class_name或者是它的一個子類

where T : interface_name 類型必須實現指定的接口

你可以指定約束的組合,就象: where T : IComparable, new()。這就是說,用於參數化類型的類型必須實現Icomparable接口並且必須有一個無參構造器。

9. 繼承與泛型

一個使用參數化類型的泛型類,象MyClass1<T>,稱作開放結構的泛型。一個不使用參數化類型的泛型類,象MyClass1<int>,稱作封閉結構的泛型。

你可以從一個封閉結構的泛型進行派生;也就是說,你可以從另外一個稱爲MyClass1的類派生一個稱爲MyClass2的類,就象:

public class MyClass2<T> : MyClass1<int>

你也可以從一個開放結構的泛型進行派生,如果類型被參數化的話,如:

public class MyClass2<T> : MyClass2<T>

是有效的,但是

public class MyClass2<T> : MyClass2<Y>

是無效的,這裏Y是一個被參數化的類型。非泛型類可以從一個封閉結構的泛型類進行派生,但是不能從一個開放結構的泛型類派生。即:

public class MyClass : MyClass1<int>

是有效的, 但是

public class MyClass : MyClass1<T>

是無效的。

10. 泛型和可代替性

當我們使用泛型時,要小心可代替性的情況。如果B繼承自A,那麼在使用對象A的地方,可能都會用到對象B。假定我們有一籃子水果(a Basket of Fruits (Basket<Fruit>)),而且有繼承自Fruit的Apple和Banana(皆爲Fruit的種類)。一籃子蘋果--Basket of Apples (Basket<apple>)可以繼承自Basket of Fruits (Basket<Fruit>)?答案是否定的,如果我們考慮一下可代替性的話。爲什麼?請考慮一個a Basket of Fruits可以工作的方法:
public void Package(Basket<Fruit> aBasket)
{
aBasket.Add(new Apple());
aBasket.Add(new Banana());
}

如果發送一個Basket<Fruit>的實例給這個方法,這個方法將添加一個Apple對象和一個Banana對象。然而,發送一個Basket<Apple>的實例給這個方法時,會是什麼情形呢?你看,這裏充滿技巧。這解釋了爲什麼下列代碼:

Basket<Apple> anAppleBasket = new Basket<Apple>();
Package(anAppleBasket);

會產生錯誤:

Error 2 Argument ’1’: 
cannot convert from ’TestApp.Basket<testapp.apple>’ 
to ’TestApp.Basket<testapp.fruit>’

編譯器通過確保我們不會隨意地傳遞一個集合的派生類(此時需要一個集合的基類),保護了我們的代碼。這不是很好嗎?

這在上面的例中在成功的,但也存在特殊情形:有時我們確實想傳遞一個集合的派生類,此時需要一個集合的基類。例如,考慮一下Animal(如Monkey),它有一個把Basket<Fruit>作參數的方法Eat,如下所示:

public void Eat(Basket<Fruit> fruits)
{
foreach (Fruit aFruit in fruits)
{
//將吃水果的代碼
}
}

現在,你可以調用:

Basket<Fruit> fruitsBasket = new Basket<Fruit>();
… //添加到Basket對象中的對象Fruit
anAnimal.Eat(fruitsBasket);

如果你有一籃子(a Basket of)Banana-一Basket<Banana>,情況會是如何呢?把一籃子(a Basket of)Banana-一Basket<Banana>發送給Eat方法有意義嗎?在這種情形下,會成功嗎?真是這樣的話,編譯器會給出錯誤信息:

Basket<Banana> bananaBasket = new Basket<Banana>();
//…
anAnimal.Eat(bananaBasket);

編譯器在此保護了我們的代碼。我們怎樣才能要求編譯器允許這種特殊情形呢?約束機制再一次幫助了我們:

public void Eat<t>(Basket<t> fruits) where T : Fruit
{
foreach (Fruit aFruit in fruits)
{
//將吃水果的代碼
}
}

在建立方法Eat()的過程中,我要求編譯器允許一籃子(a Basket of)任何類型T,這裏T是Fruit類型或任何繼承自Fruit的類。
11. 泛型和代理

代理也可以是泛型化的。這樣就帶來了巨大的靈活性。 

假定我們對寫一個框架程 序很感興趣。我們需要提供一種機制給事件源以使之可以與對該事件感興趣的對象進行通訊。我們的框架可能無法控制事件是什麼。你可能在處理某種股票價格變化 (double price),而我可能在處理水壺中的溫度變化(temperature value),這裏Temperature可以是一種具有值、單位、門檻值等信息的對象。那麼,怎樣爲這些事件定義一接口呢?

讓我們通過pre-generic代理技術細緻地分析一下如何實現這些:

public delegate void NotifyDelegate(Object info);
public interface ISource
{
event NotifyDelegate NotifyActivity;
}

我們讓NotifyDelegate接受一個對象。這是我們過去採取的最好措施,因爲Object可以用來代表不同類型,如double, Temperature,等等--儘管Object含有因值類型而產生的裝箱的開銷。ISource是一個各種不同的源都會支持的接口。這裏的框架展露了 NotifyDelegate代理和ISource接口。

讓我們看兩個不同的源碼:

public class StockPriceSource : ISource
{
public event NotifyDelegate NotifyActivity;
//…
}
public class BoilerSource : ISource
{
public event NotifyDelegate NotifyActivity;
//…
}

如果我們各有一個上面每個類的對象,我們將爲事件註冊一個處理器,如下所示:

StockPriceSource stockSource = new StockPriceSource();
stockSource.NotifyActivity 
+= new NotifyDelegate(stockSource_NotifyActivity);
//這裏不必要出現在同一個程序中
BoilerSource boilerSource = new BoilerSource();
boilerSource.NotifyActivity 
+= new NotifyDelegate(boilerSource_NotifyActivity);
在代理處理器方法中,我們要做下面一些事情: 
對於股票事件處理器,我們有:
void stockSource_NotifyActivity(object info)
{
double price = (double)info; 
//在使用前downcast需要的類型
}

溫度事件的處理器看上去會是:

void boilerSource_NotifyActivity(object info)
{
Temperature value = info as Temperature; 
//在使用前downcast需要的類型
}

上面的代碼並不直觀,且因使用downcast而有些凌亂。藉助於泛型,代碼將變得更易讀且更容易使用。讓我們看一下泛型的工作原理:

下面是代理和接口:

public delegate void NotifyDelegate<t>(T info);
public interface ISource<t>
{
event NotifyDelegate<t> NotifyActivity;
}

我們已經參數化了代理和接口。現在的接口的實現中應該能確定這是一種什麼類型。

Stock的源代碼看上去象這樣:

public class StockPriceSource : ISource<double>
{
public event NotifyDelegate<double> NotifyActivity;
//…
}

而Boiler的源代碼看上去象這樣:

public class BoilerSource : ISource<temperature>
{
public event NotifyDelegate<temperature> NotifyActivity;
//…
}

如果我們各有一個上面每種類的對象,我們將象下面這樣來爲事件註冊一處理器: 

StockPriceSource stockSource = new StockPriceSource();
stockSource.NotifyActivity += new NotifyDelegate<double>(stockSource_NotifyActivity);
//這裏不必要出現在同一個程序中
BoilerSource boilerSource = new BoilerSource();
boilerSource.NotifyActivity += new NotifyDelegate<temperature>(boilerSource_NotifyActivity);

現在,股票價格的事件處理器會是:

void stockSource_NotifyActivity(double info)
{ //… }

溫度的事件處理器是: 

void boilerSource_NotifyActivity(Temperature info)
{ //… }

這裏的代碼沒有作downcast並且使用的類型是很清楚的。

 12. 泛型與反射

既然泛型是在CLR級上得到支持的,你可以使用反射API來取得關於泛型的信息。如果你是編程的新手,可能有一件事讓你疑惑:你必須記住既有你寫的泛型 類也有在運行時從該泛型類創建的類型。因此,當使用反射API時,你需要另外記住你在使用哪一種類型。我將在例7說明這一點:

例7.在泛型上的反射

public class MyClass<t> { }
class Program
{
static void Main(string[] args)
{
MyClass<int> obj1 = new MyClass<int>();
MyClass<double> obj2 = new MyClass<double>();
Type type1 = obj1.GetType();
Type type2 = obj2.GetType();
Console.WriteLine("obj1’s Type");
Console.WriteLine(type1.FullName);
Console.WriteLine(type1.GetGenericTypeDefinition().FullName);
Console.WriteLine("obj2’s Type");
Console.WriteLine(type2.FullName);
Console.WriteLine(type2.GetGenericTypeDefinition().FullName);
}
}

在本例中,有一個MyClass<int>的實例,程序中要查詢該實例的類名。然後我查詢這種類型的GenericTypeDefinition()。 GenericTypeDefinition()會返回MyClass<T>的類型元數據。你可以調用IsGenericTypeDefinition來 查詢是否這是一個泛型類型(象MyClass<T>)或者是否已指定它的類型參數(象MyClass<int>)。同樣地,我查詢MyClass <double>的實例的元數據。上面的程序輸出如下:

obj1’s Type
TestApp.MyClass`1
[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, 
PublicKeyToken=b77a5c561934e089]]
TestApp.MyClass`1
obj2’s Type
TestApp.MyClass`1
[[System.Double, mscorlib, Version=2.0.0.0, Culture=neutral, 
PublicKeyToken=b77a5c561934e089]]
TestApp.MyClass`1

可以看到,MyClass<int>和MyClass<double>是屬於mscorlib配件集的類(動態創建的),而類MyClass<t>屬於我自建的配件集。

13. 泛型的侷限性

至此,我們已瞭解了泛型的強大威力。是否其也有不足呢?我發現了一處。我希望微軟能夠明確指出泛型存在的這一局制性。在表達約束的時候,我們能指定參數類型必須繼承自一個類。然而,指定參數必須是某種類的基類型該如何呢?爲什麼要那樣做呢?

在例4中,我展示了一個Copy()方法,它能夠把一個源List的內容複製到一個目標list中去。我可以象如下方式使用它:

List<Apple> appleList1 = new List<Apple>();
List<Apple> appleList2 = new List<Apple>();

Copy(appleList1, appleList2);

然而,如果我想要把apple對象從一個列表複製到另一個Fruit列表(Apple繼承自Fruit),情況會如何呢?當然,一個Fruit列表可以容納Apple對象。所以我要這樣編寫代碼:

List<Apple> appleList1 = new List<Apple>();
List<Fruit> fruitsList2 = new List<Fruit>();

Copy(appleList1, fruitsList2);

這不會成功編譯。你將得到一個錯誤:

Error 1 The type arguments for method 
’TestApp.Program.Copy<t>(System.Collections.Generic.List<t>, 
System.Collections.Generic.List<t>)’ cannot be inferred from the usage.

編譯器基於調用參數並不能決定T應該是什麼。其實我想說,Copy方法應該接受一個某種數據類型的List作爲第一個參數,一個相同類型的List或者它的基類型的List作爲第二個參數。

儘管無法說明一種類型必須是另外一種類型的基類型,但是你可以通過仍舊使用約束機制來克服這一限制。下面是這種方法的實現:

public static void Copy<T, E>(List<t> source, 
List<e> destination) where T : E

在此,我已指定類型T必須和E屬同一種類型或者是E的子類型。我們很幸運。爲什麼?T和E在這裏都定義了!我們能夠指定這種約束(然而,C#中並不鼓勵當E也被定義的時候使用E來定義對T的約束)。

然而,請考慮下列的代碼:

public class MyList<t>
{
public void CopyTo(MyList<t> destination)
{
//…
}
}

我應該能夠調用CopyTo:

MyList<apple> appleList = new MyList<apple>();
MyList<apple> appleList2 = new MyList<apple>();
//…
appleList.CopyTo(appleList2);

我也必須這樣做:

MyList<apple> appleList = new MyList<apple>();
MyList<fruit> fruitList2 = new MyList<fruit>();
//…
appleList.CopyTo(fruitList2);

這當然不會成功。如何修改呢?我們說,CopyTo()的參數可以是某種類型的MyList或者是這種類型的基類型的MyList。然而,約束機制不允許我們指定一個基類型。下面情況又該如何呢?

public void CopyTo<e>(MyList<e> destination) where T : E

抱歉,這並不工作。它將給出一個編譯錯誤:

Error 1 ’TestApp.MyList<t>.CopyTo<e>()’ does not define type 
parameter ’T’

當然,你可以把代碼寫成接收任意類型的MyList,然後在代碼中,校驗該類型是可以接收的類型。然而,這把檢查工作推到了運行時刻,丟掉了編譯時類型安全的優點。

14. 結論

.NET 2.0中的泛型是強有力的,你寫的代碼不必限定於一特定類型,然而你的代碼卻能具有類型安全性。泛型的實現目標是既提高程序的性能又不造成代碼的臃腫。然 而,在它的約束機制存在不足(無法指定一類型必須是另外一種類型的基類型)的同時,該約束機制也給你書寫代碼帶來很大的靈活性,因爲你不必拘泥於各種類型 的"最小公分母"能力。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章