1.前言
往往一些剛接觸C#編程的初學者,對於泛型的認識就是直接跳到對泛型集合的使用上,雖然微軟爲我們提供了很多內置的泛型類型,但是如果我們只是片面的瞭解調用方式,這會導致我們對泛型盲目的使用。至於爲什麼要使用泛型,什麼情況下定義屬於自己的泛型,定義泛型又能爲程序帶來哪些好處。要理清這些問題,我們就必須深刻理解泛型的本質,形成泛型編程的思維方式。
接下來我將基於一個基礎示例,然後通過需求不斷的演化示例,從而讓泛型在關鍵時刻脫穎而出,以便讓我們能夠深刻體會泛型的作用。假設.NET沒有爲我們提供用於存儲數據的集合,而我們需要一個能夠用於存儲string元素的集合,基於這個情況我們自定義了一個用於存儲字符串的集合類:
class ArraryStr
{
public ArraryStr()
{
_items = new string[100]; //初始化存儲元素的容量,只是爲了演示故將容量定義爲固定值
}
private string[] _items; //存儲元素的數組
private int _count; //元素總數
public int Count
{
get { return _count; }
}
public void Add(string item) //新增元素
{
_items[_count] = item;
_count++;
}
public string this[int index] //索引
{
get { return _items[index]; }
set { _items[index] = value; }
}
} // END ArraryStr
爲了驗證自定義string集合的可行性,我們對其進行了如下的應用:
1 ArraryStr arraryStr = new ArraryStr();
2 arraryStr.Add("張三");
3 Console.WriteLine(arraryStr[0]);
2.重複
目前對於創建string類型的集合已經大功告成,而此刻我們又接到了一個新的需求,即我們需要一個集合存儲int類型的元素。基於自定義string集合的經驗來看,我們可以發現,string集合類型和我們即將要創建的int集合類型的結構和內容幾乎是一樣的。這就意味着我們可以使用江湖盛行的“複製大法”,將之前的代碼複製一遍,然後輕微修改下即可。下面是兩個集合類型代碼的對比圖。
在早年有款熱門的遊戲叫做“大家來找茬”,該遊戲主要玩法就是在兩個大致相同的圖片中,查找兩者之間的細微差異之處。我們使用的“複製大法”,促使我們編寫的代碼形成了可以用於這個遊戲遊玩的場景。“對於上面的兩個代碼截圖,你能找出圖中不同的地方嗎?”
對於軟件開發者而言,面對的最主要的敵人就是“變化”,假設後面還會出現N個類型的元素需要我們定義集合來存儲,那我們是不是要將相同的代碼無窮盡的複製下去?DRY(Don't Repeat Yourself,不要重複自己),請記住這是作爲一名軟件開發者編碼的原則,“複製大法”很明顯的違背了這個原則。
3.安全和性能
通過“複製,粘貼”的手段可以很明顯的感受到我們在做重複的事情,在重複中我們可以發現:集合存儲的類型在增加,但是集合的結構和添加元素的方法都是相同的邏輯。簡單來說就是,不同類型的處理,其處理邏輯都是類似的。基於這個特點,爲了滿足自定義集合能夠應對所有類型的存儲,我們必須使用一個通用類型來作爲代表,此時此刻我們腦海中就能浮現出一句話:object是一切類型的基類。這就意味着我們添加的所有類型,都可以隱式的轉換爲object類型,從而使得自定義集合可以添加任何類型的元素。讓我們來運用這個object類型來試試:
class ArraryList
{
public ArraryList() { _items = new object[100]; }
private object[] _items;
private int _count;
public int Count
{
get { return _count; }
}
public void Add(object item)
{
_items[_count] = item;
_count++;
}
public object this[int index]
{
get { return _items[index]; }
set { _items[index] = value; }
}
} // END ArraryStr
internal class Program
{
static void Main(string[] args)
{
ArraryList arraryList = new ArraryList();
arraryList.Add("張三");
arraryList.Add(18);
string name = (string)arraryList[0];
int age = (int)arraryList[1];
} // END Main()
}
在上面的代碼中,我們結合了object是一切類型基類的特點,對集合類型進行改造,併成功的使用該方式的集合添加了不同類型的元素。雖然在使用的角度來看已經完美無缺(可以添加任何類型),但是獲取集合元素進行賦值的時候,還使用了類型強制轉換的手段。這是因爲這種方式存在很嚴重的問題,主要包括以下兩個方面:
- 類型安全方面,如果集合的第一個元素是sting類型,但是你客觀認爲是int類型,於是你在獲取時進行了int類型的強制轉換,這個時候代碼不會提示錯誤且可以正常編譯,那麼這就意味着程序在運行時會產生一個你無法預料的類型無效轉換的異常。
- 性能方面,值類型元素添加到集合時,必然會存在裝箱操作;而在獲取元素並賦值給一個值類型變量時,又會發生相應的拆箱操作。這種拆箱和裝箱的操作,在操作大量元素時會大幅度的損失程序的性能。
到目前位置,我們還是沒有能創建一個能夠存儲任何類型的集合,但是我們可以對於上述的示例演變的過程進行一個總結:對於不同類型有相同處理邏輯的情況,如果一味的複製會導致我們出現重複代碼,如果使用object來作爲解決重複的方案,會存在類型安全和性能的問題。至於如何讓徹底解決這些問題,這就要說到了本文講解的主題——泛型。
4.代碼模板
C#中有兩種不同的機制來編寫跨類型(一個類型代替多個類型)可複用的代碼:繼承和泛型。繼承的複用性來自於基類,而泛型的複用性是通過帶有“佔位符”的代碼模板類型實現的。繼承實現複用是站在面向對象的角度思考的,而泛型的複用是站在實現特定功能上思考的。相比於繼承,泛型不用遵循里氏替換原則,並且能夠提高類型的安全性,減少類型轉換帶來的拆箱和裝箱。
怎麼樣理解泛型?泛型本質上相當於一種“代碼模板”,可以用一套代碼,爲不同類型的同一邏輯使用統一的方式實現。其中“模板”一詞的概念需要進行深刻的體會。例如,公司在招聘時會與用人方簽訂勞動合同,而這個勞動合同的主要內容對於所有人來說幾乎都是一樣的,只是在極個別的地方有所差異,如薪資、姓名等。所以公司不會爲某個人(張三或李四)去特意的制定合同,而是會統一制定一份勞動合同作爲模板,將其中針對個人存在差異的部分通過“下劃線”進行佔位預留,“下劃線”的值將在簽訂合同時由具體的聘用者根據自身情況填寫。
對於這種模板方式的使用,公司在制定合同時則不用考慮簽訂合同的人具體是誰,因爲勞動合同(模板)和使用者是分開的,所以公司只用專注於合同的主要內容即可。而我們在實際的編程運用中,使用泛型的目的,其實和公司制定通用的勞動合同模板是一個道理。假設你的公司需要僱傭100名員工時,你不希望爲每一個人都制定一個專屬的合同吧?假設你的代碼中,如果遇到10個類型,它們的操作處理邏輯都一樣時,你不希望爲這個10個類型寫10個處理方式吧?
通過上面的介紹和例子,接下來我們將泛型運用到我們的示例中來,代碼如下:
1 class ArraryList<T>
2 {
3 public ArraryList() { _items = new T[100]; }
4
5 private T[] _items;
6 private int _count;
7 public int Count
8 {
9 get { return _count; }
10 }
11
12 public void Add(T item)
13 {
14 _items[_count] = item;
15 _count++;
16 }
17
18 public T this[int index]
19 {
20 get { return _items[index]; }
21 set { _items[index] = value; }
22 }
23 } // END ArraryStr
24 internal class Program
25 {
26 static void Main(string[] args)
27 {
28 ArraryList<string> arraryStr = new ArraryList<string>();
29 arraryStr.Add("張三");
30 Console.WriteLine(arraryStr[0]);
31
32 ArraryList<int> arraryInt = new ArraryList<int>();
33 arraryInt.Add(18);
34 Console.WriteLine(arraryInt[0]);
35
36 } // END Main()
37
38 }
5.類型參數
在上面的代碼中,我們將集合類型定義爲了泛型類,該類型中出現的T屬於泛型中的類型參數(Type Parameter)。泛型爲了達到通用處理的目的,所以不能將某個具體類型作爲處理的目標類型,故而將要處理的類型用“T”作爲一個類型佔位符。
“T”並不是真正的數據類型,它更像是泛型使用的類型藍圖,所以在使用時,泛型類型的消費者必須將一個具體類型作爲“類型參數”傳遞到尖括號內,以此構造一個有明確處理類型的泛型實例。所以我們在外部使用泛型時不能以:“ArraryList<T>list =new ArraryList<T>()”、“T t=new T()”這種方式去實例化泛型類型。另外,“T”本身僅僅是類型參數的名稱,它只是代表了類型參數的標識而已,這意味着我們可以使用其他字符來爲類型參數命名。
6.替換
通過類型參數的使用我們可以得知,泛型類型代碼在靜態階段沒有明確的類型,那麼在程序運行的時候,它又是如何和使用時指定的“類型參數”進行對接的呢?爲了搞清楚這個問題,下面我們來了解下泛型運行時的本質。
我們編寫的C#程序在編譯後生成的代碼,並不是計算機可以直接執行的代碼,而是會生成CIL(通用中間語言)代碼幷包含在程序集中,如果想要生成計算機可執行的代碼,則還需要JIT(即時編譯器)對CIL代碼進行二次編譯。然而泛型類型確認其具體類型的時機,就在JIT進行二次編譯時,JIT編譯的代碼如果包含了泛型的內容,那麼它會根據泛型類型的消費者指定的類型參數,將CIL中泛型代碼中的佔位符T替換爲一個具體的類型,從而明確當前執行的泛型代碼是針對哪個類型來使用的,其中替換的過程是由CLR在運行時進行主導,JIT來實際操作完成的。這個在運行時確認了類型的泛型又被稱之爲“封閉類型”,反之在運行時確認之前的泛型稱爲“開放類型”。
泛型使用佔位符在運行時替換具體類型的機制,其實和本文中例舉勞動合同模板使用“下劃線”的方式有同樣的思想。在指定勞動合同模板時,對於聘用者的姓名並不能寫一個具體的名字,因爲模板的目的是爲了通用化,所以對於名字採用了“下劃線”的方式。當公司與某個具體的人簽訂合同的時候,勞動合同模板中的下劃線將由聘用者根據自身情況填寫。回到泛型中其使用思想也是如此,我們使用泛型的目的是爲了讓多個類型的處理通用化,所以在定義泛型代碼的時候並不能指定一個具體類型,故使用類型參數T進行代替,這個類型參數T就相當於勞動合同模板中的“下劃線”,當泛型在實際運行的時候,JIT會根據泛型消費者指定的具體類型與佔位符T進行替換。