1. 程式人生 > 實用技巧 >C#泛型簡介

C#泛型簡介

很多時候,我們想把類的行為提取出來或者重構,使其不僅能應用於當前編碼的型別上,還能應用於其它型別上。

在C#裡面,實現跨型別的程式碼複用,有兩種方式:繼承 和 泛型。

  • 繼承 -> 繼承的複用性來自基類
  • 泛型 -> 泛型的複用性是通過帶有“(型別)佔位符”的“模板”實現的

泛型型別(Generic Types)
泛型允許我們宣告型別引數化(Type Parameterized)的程式碼 - 泛型的消費者需要提供型別引數(argument)來把佔位符型別填充上。

我們可以把泛型理解為原有的需要的具體型別的一個抽象,泛型型別不是型別,而是型別的“模板”。具體的資料型別不再硬編碼,而是用一個佔位符代替,按照預設約定一般用T佔位,也可給一個有意義的名稱,例如:TKey,表示一個泛型鍵。

首先我們來看一個存放型別T例項的泛型堆疊型別Stack<T>的例子:

public class Stack<T>
{
    int position;
    T[] data = new T[100];
    public void Push(T obj) => data[position++] = obj;
    public T Pop() => data[--position];
}

下面是一個呼叫泛型類的例子:

var stack = new Stack<int>();
stack.Push(5);
stack.Push(10);
Console.WriteLine(stack.Pop()); 
// 10 Console.WriteLine(stack.Pop()); // 5

Stack<int>用型別引數int填充T,這會在執行時隱式建立一個型別:Stack<int>。若試圖將一個字串加入Stack<int>中則會產生一個編譯時錯誤。

Stack<int>相當於如下的定義:

public class ###
{
    int position;
    int[] data = new int[100];
    public void Push(int obj) => data[position++] = obj;
    public
int Pop() => data[--position]; }

我們把Stack<T> 叫做開放型別(Open Type),Stack<int> 叫做封閉型別(Closed Type)。在執行時,所有的泛型型別例項都是封閉的(佔位符型別已經被填充了)。

例如,var stack = new Stack<T>();這種就是不合法的,只有在泛型類或者泛型方法的內部是可以這麼用的,在使用泛型類時就必須明確地填充佔位符型別。

泛型為什麼會出現?

為了實現程式碼複用,泛型是為了程式碼能夠跨型別複用而設計的,還是上面的例子,假如我們需要一個整數棧,但是沒有泛型的支援,我們有兩種方式,一種是針對特定的型別都寫一個類或方法,針對int,我們寫一套程式碼,針對string我們也寫一套程式碼。顯然,這樣會導致大量的重複程式碼。另一種就是使用object物件,但是物件在使用時會有裝箱和向下型別轉換的問題,而這在編譯時無法進行檢查。

看個例子:

public class ObjectStack
{
    int position;
    object[] data = new object[100];
    public void Push(object obj) => data[position++] = obj;
    public object Pop() => data[--position];
}
var stack = new ObjectStack(); //假如我們需要一個int
stack.Push("str"); // 這裡給了一個錯誤的型別,但是不會報錯,因為它們都是object子類
int myInt = (int)stack.Pop(); //我實際要的是一個int,這裡就會Downcast 發生一個執行時錯誤

我們需要的棧既需要支援各種不同型別的元素,又要有一種方法容易地將棧的元素型別限定為特定型別,以提高型別安全性,減少型別轉換和裝箱。泛型就是通過引數化元素型別提供了這些功能。

泛型方法(Generic Methods)

使用泛型方法很多基本的一些演算法和邏輯就可以變得更加通用,共用性就更強了。

泛型方法在方法的簽名中宣告型別引數

下面是一個交換兩個任意型別T的變數值的泛型方法:

static void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

那麼呼叫泛型方法可以這麼呼叫:

int x = 5, y = 10;
Swap(ref x, ref y);

通常情況下呼叫泛型方法不需要提供型別引數,編譯器可以隱式的推斷出這個T的具體型別,如果編譯器不能推斷出型別或者發生歧義,也可以這麼寫:

Swap<int>(ref x, ref y);

在泛型型別裡面的方法,除非也引入了型別引數(type parameters),否則這個方法是不會歸為泛型方法的。

比如上面的Stack<T>泛型類裡面的Pop方法,它用到了T,但是這個T是在型別上宣告的,而不是這個Pop方法引入的(Pop<T>),所以說這個Pop方法不是泛型方法,它只是這個泛型類裡的一個普通方法。

只有型別和方法才可以引入型別引數,而屬性、事件、索引器、欄位、構造器、運算子等等都不可以宣告型別引數。但是他們可以使用他們所在的泛型型別的型別引數,而不引人新的型別引數。

宣告型別引數

可以在申明類、結構體、介面、委託和方法時引入型別引數。其他的結構,如屬性,雖不能引入型別引數,但是可以使用型別引數。例如,屬性Value使用T:

public struct Nullable<T>
{
    public T Value { get; }
}

泛型類或者方法可以有多個引數,例如:

class Dictionary<TKey,TValue>{…}

可以用以下方式例項化:

var myDic = new Dictionary<int,string>();

只要型別引數的數量不同,泛型型別名和泛型方法的名稱就可以進行過載。

class A{}

class A<T>{}

class A<T1,T2>{}

這三個型別名稱不會衝突。

typeof 和未繫結的泛型型別

泛型型別在執行時都是封閉的,不存在開放的泛型型別:開放泛型型別將編譯為程式的一部分而封閉,但執行時可能存在未繫結(unbound)的泛型型別,只作為Type物件存在。

但是如果作為Type物件,C#中唯一指定未繫結泛型型別的方式是使用typeof運算子來實現。

class A<T> {}
class A<T1,T2> {}
...
Type a1 = typeof (A<>); // 未繫結型別 注意沒有型別引數
Type a2 = typeof (A<,>); // 使用逗號表示多個型別引數

開放泛型型別通常用與反射API結合使用,也可以使用typeof運算子指定封閉型別:

Type a3 = typeof (A<int,int>);

或者開放型別(在執行時封閉):

class B<T>
{
void X()
{
Type t = typeof (T);
}
}

泛型的預設值

default關鍵字可用於獲取泛型型別引數的預設值。

  • 引用型別的預設值為null
  • 值型別的預設值是將值型別的所有欄位按位設定為0的值。
static void Zap<T>(T[] array)
{
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = default(T);
    }
}

泛型的約束

預設情況下,型別引數可以由任何型別來替換。

如果只允許使用特定的型別引數,就可以在型別引數上應用約束,可以將型別引數定義為指定的型別引數。

where T : base-class // 基類約束,必須是某個父類的子類
where T : interface // 介面約束,必須繼承某個介面
where T : class // 引用型別約束,必須是一個引用型別
where T : struct // 值型別約束 (不包括可空型別),必須是一個值型別
where T : new() // 無引數建構函式約束,必須有一個無參建構函式
where U : T // 裸型別約束,這個U必須繼承自這個T

下面看一個例子:

class SomeClass {}
interface Interface1 {}
class GenericClass<T,U> where T : SomeClass, Interface1
              where U : new()
{...}

這個泛型類中的T,必須是繼承自SomeClass這個類並且實現了Interface1介面,同時U必須是有一個無參的建構函式。

泛型約束不僅僅可以作用於型別,也可作用於方法的定義,下面我們看幾個例子:

實現介面約束的泛型方法:

假設我們要編寫一個通用的Max方法,它比較兩個數返回比較大的那個值。我們可以利用通用介面在名為IComparable<T>的框架中定義:

public interface IComparable<T> // 介面的簡化版本
{
int CompareTo (T other);
}

如果此值大於其他值,則CompareTo將返回一個正數。使用這個介面作為約束,我們可以編寫如下Max方法(以避免分散注意力,省略空檢查):

static T Max <T> (T a, T b) where T : IComparable<T>
{
return a.CompareTo (b) > 0 ? a : b;
}

Max泛型方法可以接受任何實現了IComparable<T>介面的型別引數(包括大多數內建型別,如int和

字串),看下呼叫的例子:

int z = Max (5, 10); // 10
string last = Max ("ant", "zoo"); // zoo

值型別約束的泛型方法:想要定義一個可空型別的泛型結構體,但是添加了值型別的約束,值型別約束是不可為空的,下面是一個錯誤的例子:

struct Nullable<T> where T : struct {...}

無參建構函式的泛型約束:

static void Initialize<T> (T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}

在Initialize泛型方法中添加了無參建構函式的約束,所以可以在方法體內部new一個T型別的物件。

泛型型別的子類

泛型型別和非泛型型別一樣都可以派生子類,在子類裡,仍可以讓父類中的型別引數保持開放,例子:

class Stack<T> {...}
class SpecialStack<T> : Stack<T> {...}

在子類裡,也可以使用具體的型別來封閉父類的型別引數

class IntStack : Stack<int> {...}

子型別也可以引入新的型別引數

class List<T> {...}
class KeyedList<T,TKey> : List<T> {...}

從技術上講,子型別上的所有型別引數都是新的:你可以認為子類是先把父類的型別引數關閉然後重新開啟父類的型別引數。

這意味著子類可以重新開啟型別引數的新名稱(可能更有意義):

class List<T> {...}
class KeyedList<TElement,TKey> : List<TElement> {...}

自引用的泛型宣告

一個型別可以使用自身型別作為具體型別來封閉型別引數,什麼意思呢,看個例子:

public interface IEquatable<T>
{
bool Equals (T obj);
}
public class Balloon : IEquatable<Balloon> { public string Color { get; set; } public int CC { get; set; } public bool Equals (Balloon b) { if (b == null) return false; return b.Color == Color && b.CC == CC; } }

這個只要記住允許這麼用就行了,包括下面的兩個例子

class Foo<T> where T : IComparable<T> { ... }
class Bar<T> where T : Bar<T> { ... }

靜態資料

靜態資料對於每一個封閉的型別來說都是唯一的,看個例子:

class Bob<T> { public static int Count; }
class Test
{
static void Main()
{
Console.WriteLine (++Bob<int>.Count); // 1
Console.WriteLine (++Bob<int>.Count); // 2
Console.WriteLine (++Bob<string>.Count); // 1
Console.WriteLine (++Bob<object>.Count); // 1
}
}

這個例子說明,只要是Bob<int>都是同一個型別,Bob<string>又是一個新的型別。

型別引數的轉換

C#的型別轉換運算子可以進行多種的型別轉換,包括:

  • 數值轉換
  • 引用轉換
  • 裝箱/拆箱轉換
  • 自定義轉換(運算子過載)

根據已知運算元的型別,在編譯時就已經決定了型別轉換的方式。但在編譯時運算元的型別還並未確定,使得上述規則在泛型型別引數上會出現特殊的情形。如果導致了二義性,那麼就會產生一個編譯時錯誤。

StringBuilder Foo<T> (T arg)
{
if (arg is StringBuilder)
return (StringBuilder) arg; // 將編譯不通過
...
}

由於編譯器不知道T的具體型別,以為你要做一個自定義的型別轉換,那麼這個問題如何解決呢,就是使用as操作符:

StringBuilder Foo<T> (T arg)
{
StringBuilder sb = arg as StringBuilder;
if (sb != null) return sb;
...
}

更常見的一種做法是,先把它轉換成object型別,這樣就不是自定義轉換了,return (StringBuilder) (object) arg;

int Foo<T> (T x) => (int) x; // 編譯時錯誤

有可能是數值型別的轉換也有可能是個拆箱操作,也有可能是自定義轉換,定不下來,所以可能會發生歧義就報錯,具體的解決辦法也是先轉換成object型別,這樣肯定就是一個拆箱操作了,就沒有歧義了

int Foo<T> (T x) => (int) (object) x;

協變(Covariance)

IEnumerable<string> strings = new List<string> { "a", "b", "c" };
IEnumerable<object> objects = strings;

上面這段程式碼在C#4.0之前會報錯,在C#4.0之後可以就沒有問題了。

IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings; //會報錯

這段程式碼和上面的程式碼有什麼區別呢,把IList<string>賦給IList<object>就會報錯,為什麼呢,因為這麼做不安全。

具體可以看下IEnumerable和IList的原始碼定義就知道了。

Action<object> objectAction = obj => Console.WriteLine(obj);
Action<string> stringAction = objectAction;
stringAction("Print Message");

因為string是繼承於object的,string可以向上轉換為object。

前面這三個例子,就引出三個概念:

  • Covariance協變,當值作為返回值/out輸出
  • Contravariance逆變,當值作為輸入input
  • Invariance不變,當值既是輸入又是輸出

對應的三個例子:

public interface IEnumerable<out T> //協變
public delegate void Action<in T> //逆變
public interface IList<T>  //不變

前面這三種呢,我們都稱之為Variance,即可變性。variance只能出現在介面和委託裡。

Variance轉換

涉及到Variance的轉換就是variance轉換

Variance轉換時引用轉換的一個例子。引用轉換你無法改變其底層的值,只能改變編譯時型別。

identity conversion(本體轉換),對CLR來說,從一個型別轉化到相同的型別。

我們看幾個例子:

如果從A到B的轉換時本體轉換或者隱式引用轉換,那麼從IEnumerable<A>到IEnumerable<B>的轉換就是合理的。

IEnumerable<string> to IEnumerable<object>  // string到object隱式型別轉換
IEnumerable<string> to IEnumerable<IConvertible>  // string實現了這個介面,也是隱式型別轉換
IEnumerable<IDisposable> to IEnumerable<object> // 也是隱式型別轉換

上面這些都是合理的轉換,下面看一些不合理的轉換

IEnumerable<object> to IEnumerable<string> //object 到 string 得是顯示型別轉換才行
IEnumerable<string> to IEnumerable<Stream> //string和Stream沒有任何關係,所以也是不合理的
IEnumerable<int> to IEnumerable<IConvertible> //這確實是一個隱式轉換,但它是一個拆箱操作,而不是引用轉換
IEnumerable<int> to IEnumerable<long> //這是一個隱式轉換,但不是引用轉換