一文搞定泛型知識

敬告:本篇文章是我原創所寫,首發於 51CTO 技術網站,未經本人授權任何網站、公衆號、App 不允許轉載,授權的網站、公衆號、App 需明確標識本篇文章首發地址。需轉載請聯繫 [email protected]

泛型是程序設計語言的一種風格,允許程序員在強類型程序設計語言中編寫代碼時使用一些以後才指定的類型,在實例化時作爲參數指明這些類型。泛型在 .NET 中應用尤其廣泛,泛型是在 .NET 2.0 CLR 中的增加的一項新功能,類似於 C++ 的模板但不如 C++ 的模板靈活,不過也有一些自己的特性。泛型爲 .NET 引入了類型參數的概念,這樣便可以把指定類型的工作推遲到客戶端代碼聲明並實例化類或方法的時候執行。下面我們就來講解一下泛型的知識。

一、當 C# 沒有泛型

在 .NET 2.0 以前沒有泛型的時候,開發人員一直在使用 System.Collections.Stack 類,它是一個棧類型的集合對象。 Stack 通過 PushPop 方法向集合中添加和刪除數據。很多開發人員通過前面的描述都會認爲使用 Stack 很簡單,但是其中存在一個重大的缺陷。 Stack 類所保存的是 object 類型,這樣就導致了 CLR 無法驗證 push 進集合中的對象是不是想要的類型。

此外當我們使用 Pop 方法是需要將它的返回值轉換爲我們需要的類型,因此這裏就存在一個問題,如果 Pop 方法的返回值不是我們需要的類型那麼就有很大可能引發異常。這裏的返回值轉換使用的是強制類型轉換,由於使用了強制類型轉換將類型檢查放在了運行時進行,因此代碼就變得更加脆弱。使用 Stack 類還存在一個性能問題,將值類型的實例傳遞給 Push 方法,運行時將會對它進行裝箱操作,頻繁的執行值類型裝箱操作系統會頻繁的分配內存、複製值已經進行垃圾回收,這樣就導致了大量的性能開銷。通過前面的描述部分讀者應該看出來了 Stack 類不是類型安全的類,因此在不使用泛型的情況下,我們如果修改 Stack 類並保證它是類型安全的,並且要求它存儲指定的類型的話,我們必須這麼做:

public class StackDemo
{
    public virtual User Pop();
    public virtual void Push(User user);
    //more code
}

上面的代碼是不是很簡單?如果你真的這麼認爲那麼你就是想多了,由於我們要求只能存儲 User 類型的隊形,因此我們需要對 Stack 的每個方法進行重寫實現,如果我們還需要一個存儲 Student 類型的 Stack ,我們就需要再重寫一次 Stack 的每個方法。這就凸顯了一個問題,代碼中產生了大量的類似的代碼和重複的代碼。

另外在沒有泛型的情況下如果聲明允許包含 Null 值的變量的時候就比較麻煩了。一般情況下我們常用的有兩種方法。

  1. 方法一
    對需要處理 null 值的每個類型都需要聲明可空數據類型,我們來看個簡單的例子:

    struct NullInt
    {
        public int Value { get; private set;}
        public bool HasValue {get; private set;}
    }
    

    上述例子很簡單,但是存在兩個問題,首先如果我們有很多可空類型的話我們就需要編寫大量的類似代碼,其次如果可空值類型發生了改變那麼我們就必須修改所有的可能類型聲明,可想而知工作量是非常巨大的,而且也很容易出現紕漏和錯誤。

  2. 方法二
    這個方法的出現就是爲了解決我們在方法一種所提到的兩個問題。我們只需要聲明一個可能類型即可,類型中包含 object 類型的 Value 屬性,同樣我們先來看一下代碼:

    struct NullType
    {
        public object Value {get; private set;}
        public bool HasValue {get; private set;}
    }
    

    這個方法雖然充分解決了方法一出現的問題,但是它並不完美。因爲運行時在設置 Value 屬性的時候總是會對值類型進行裝箱,另外通過 NullType.Value 獲取值得時候需要進行強制類型裝換,這個操作在運行時可能會報錯。

二、泛型概述

泛型類型是 C# 2.0 引入的,它的引入在一定程度上減輕了開發人員的壓力,同時也使得程序變得更加健壯和穩定。泛型類的語法也很簡單,用尖括號聲明泛型類型參數和提供泛型類型實參即可。我們來看一個定義泛型的例子:

public class DataBaseOperating<T>
{
    private T[] ModelArray{get;}
    public bool Insert(T t)
    {
        //more code
    }
    public T SelectOne()
    {
        //more code
    }
    public List<T> SelectAll()
    {
        //more code
    }
    //more code
}

前面這段代碼我們定義了操作數據庫的泛型類,這個類可以被項目中所有需要操作數據庫的類使用,我們只需將類型實參傳遞進來即可。例如我們需要向數據庫插入一條 User 數據我們可以這麼做:

//more code
DataBaseOperating<User> dbOp=new DataBaseOperating<User>();
dbOp.Insert(user);
//more code

我們看到在定義泛型類的時候我定義類型參數用的是 T ,這麼做是大部分 C# 開發人員的一個習慣,也可以說是一個大家都默認的規範,我們在開發時一般都會使用以大寫字母 T 作爲前綴來表明它是一個類型參數。泛型的定義和使用就這麼多,是不是很簡單呢?下面我們就來講解一下泛型各個方面。在學習泛型類之前我們要先來了解一下它的優點,來看看爲什麼微軟在 C# 2.0 中引入了泛型類。

  • 泛型促進了類型安全,它確保了參數化類中只有成員明確希望的數據類型纔可以使用;
  • 類型檢查會在編譯時發生進而減少了在運行時出現強制類型轉換無效的錯誤;
  • 泛型類成員使用的是值類型,因此就不會出現 object 裝箱轉換操作。並且代碼既保持具體類的優勢又避免了具體類的開銷,這樣代碼的性能得以提高內存消耗也變得很少。
  1. 構造函數

    我們在開發中經常用到構造函數,在泛型類和泛型結構中同樣也適用構造函數。泛型類/結構的構造函數和普通類/結構的構造函數是一模一樣的,不需要類型參數只需要按照普通類/結構的構造函數定義方法定義即可。

    public class Demo<T>
    {
        public T key {get;set;}
        public Demo(T t)
        {
            key=t;
        }
    }
    public struct Demo<T>
    {
        public T value {get;set;}
        public Demo(T t)
        {
            value=t;
        }
    }
    

    Tip:構造函數包含類型參數也可以

  2. 結構與接口

    在 C# 中不僅僅存在泛型類,還存在泛型接口和泛型結構。泛型接口和泛型結構的語法和泛型類相同。這裏主要講解一下在類中多次實現同一個泛型接口接口。我們先來看一下代碼:

    public interface IDemo<T>
    {
        ICollection<T> items {get;set;}
    }
    public class Demo:IDemo<User>,IDemo<Student>
    {
        ICollection<User> IDemo<User>.items {get;set;}
        ICollection<Student> IDemo<Student>.items {get;set;}
    }
    

    在上述代碼中我們在類中顯示實現了兩個不同類型實參的同一個泛型接口,一般來說在類中多次實現泛型接口並非是一個最優的選擇,因爲它會造成代碼的混淆以及在使用的過程中造成誤會。因此除非特殊情況,絕大多數情況下我們不應該在一個類中多次實現同一個接口。

  3. 默認值

    當我們需要在泛型類的構造函數中部分屬性進行初始化,而其他屬性不進行初始化,但是我們在開發中無法確定傳入泛型類中的類型參數是什麼,因此我們也無法通過具體的值設置默認值。這種情況在 C# 中可以說是非常好解決,我們可以調用 default 操作符來給傳入的任意類型參數提供默認值。例如下面這段代碼,我們只初始化 Key ,Value 的初始化則利用 default 操作符。

    public class Demo<T>
    {
        T tKey {get;set;}
        T tValue {get;set;}
        public Demo(T key)
        {
            tKey=key;
            tValue=default(T);
        }
    }
    

    Tip:default 中的參數並非是必須傳入的,在 C#7 中如果可以推斷出數據類型的話是不需要指定參數的。比如 Demo<T> demo = default(T)就可以寫成 Demo<T> demo=default

  4. 多類型參數

    前面我們所講的都是單個類型參數的泛型類,但是泛型類型不僅僅只能具有一個參數,它可以具有無限多的參數,例如我們定義一個泛型類,它的構造函數接受兩個不同類型的參數,代碼可以這麼實現。

    public class Demo<TKey,TValue>()
    {
        public TKey key {get;set;}
        public TValue value {get;set;}
        public Demo(TKey tKey,TValue tValue)
        {
            key=tKey;
            value=tValue;
        }
    }
    

    我們在使用 Demo 時只需要聲明和實例化語句尖括號中指定的多個類型參數即可。在調用時要提供和方法參數匹配的類型。

    Demo<string,int> demo=new Demo<string,int>(1,"小明");
    Console.Write($"編號 {demo.key} 是 {demo.value}")
    

    Tip:在 C# 中在同一個命名空間中可以存在多個同名但類型參數數量不同的類。在部分文章或圖書中會將類型參數數量稱爲 元數

  5. 嵌套泛型類型

    嵌套泛型類型在開發中用的比較少,但是還是有必要在這裏說一下,因爲有部分開發人員對於這一塊不甚瞭解。嵌套泛型類型的外層也是一個泛型類型,外層的這個泛型類型通常被稱爲包容泛型類型,嵌套泛型類型會自動獲得包容泛型類型的類型參數,這段話有些繞口,我詳細講解一下。例如 A 是包容泛型類型,它有一個類型參數 T,B 是嵌套泛型類型,它位於 A 中,那麼 B 也可以使用 A 的類型參數 T ,如果 B 中也包含一個類型參數 T ,那麼 B 會隱藏 A 的類型參數 T 。這裏需要提醒的是如果嵌套泛型類型的類型參數和包容泛型類型的類型參數相同,那麼開發工具將會出現編譯警告,這個警告是在告知開發人員使用了相同的類型參數,因此這裏就引出一條編碼規則:避免在嵌套泛型類型中使用同名參數隱藏外層類型的類型參數。

  6. 泛型方法
    前面我們所說的都是泛型類,在 C# 中除了有泛型類還有泛型方法,泛型方法的語法和泛型類的語法類似,並且泛型方法不僅可以出現在泛型類種也可以出現在普通類中。泛型方法和泛型類相比有一個很特別的地方,就是泛型方法可以自己推斷類型。編譯器可以根據傳給方法的實參來推斷泛型參數類型。因此如果想讓方法類型推斷成功那麼實參類型必須與泛型方法的形參相匹配。

三、泛型約束

在開發中大部分情況我們不允許任何不符合我們要求的類型參數出現在我們的代碼中並引起錯誤。要杜絕這個問題就需要用到泛型約束。聲明泛型約束需要使用 where 關鍵字,後面跟一對 參數:要求 。這裏面的參數必須是泛型類型中聲明的一個參數,要求描述的是類型參數所能轉換成的類或接口等條件。泛型約束分爲:接口約束、類類型約束、class 和 struct 約束、多約束以及構造函數約束。下面我們就來一一講解一下。

  1. 接口約束

    爲規定某個數據類型必須是向某個接口,需要聲明一個 接口類型約束 ,利用這種約束可以避免需要通過轉型才能調用一個顯示接口成員的實現。下面我們通過一個代碼段來講解一下接口約束。

    public class Demo<T> where T:System.IComparable<T>
    {
        //more code
    }
    

    在上面這段代碼中我們添加了 System.IComparable 約束,也就是說所提供的類型參數都必須實現 System.IComparable 接口。那麼當我們向 Demo 傳遞 StringBuilder 作爲類型參數來創建 Demo 變量時編譯器會報告一個錯誤,這是因爲 StringBuilder 沒有實現 IComparable 接口。

  2. 類類型約束

    當我們需要將類型實參轉換爲特定的類類型時就需要用到 類類型約束。類類型約束的語法和接口約束語法相同。這裏有一點需要注意如果同時指定了多種約束,那麼類類型約束必須位於第一位(第一個出現),並且泛型約束中是不允許使用多個類類型約束的,這是因爲我們的代碼不可能從多個不想管的類中派生出來,同樣類類型約束也不能指定密封類或者不是類的類型。

  3. class、struct 約束

    class 和 struct 約束是一個很容易出錯並且也很容易讓新手程序員造成困惑的地方。首先 很多新手程序員看到 class 約束會認爲是將類型實參限制爲類類型,其實不是這樣的。class 約束是類型實參爲引用類型,因此這裏使用接口、類、委託以技術組類型都符合這個條件。struct 約束和 class 約束正好相反,它是將類型實參限制爲值類型,並且值類型還不能是可空值類型。因爲可空值類型是作爲泛型 NUllable 來實現的,並且 NUllable 中的 T 使用的是 struct 約束。如果可以使用可控制類型那麼 NUllable 就有很大的可能被用成 NUllable<NUllable> 。這麼做可以說毫無意義,並且也不符合語法和邏輯。

    Tip:因爲 class 約束要求引用類型而 struct 約束要求值類型,因此這兩種約束是不能同時出現的。

  4. 多約束

    我們可以爲任意類型的參數指定任意水昂的接口約束,所有的接口約束需要用逗號分割。如果存在多個不同類型的約束,針對每種約束都需要寫一個 where 關鍵字,不同種類約束之間不需要用任何符號分割。我們來看一下多約束的代碼段:

    public class Demo<TKey,TValue> 
                         where TKey:IA,IB
                         where TValue: ClassA
     {
         //more code
     }
    
  5. 構造函數約束

    有時我們需要在泛型類中創建類型實參的實例,這時我們可以規定傳入泛型類的類型實參必須具有構造函數,如果要實現這一點我們可以使用 new() 來作爲限制,這個約束叫做 構造函數約束 。這裏需要注意的是構造函數約束必須位於其所有其他約束的後面,並且它只能對默認構造函數進行約束,而不能對有參構造函數進行約束。

Tip 1:關於約束繼承這個問題,想必好多開發人員都是一頭霧水。在這裏我通過簡單的幾句來說一下約束繼承。首先無論是泛型類型參數還是它們的約束都不會被 派生類 繼承,這是因爲泛型類型參數和約束不是類的成員。雖然不能被派生類繼承,但是可以被從其派生的泛型類所繼承。由於派生的泛型類類型參數時泛型基類的類型實參,所以類型參數必須具有等同於或者強於泛型基類的約束條件。

Tip 2:泛型方法同樣也可以使用約束,約束條件和泛型類類似。

六、總結

句來說一下約束繼承。首先無論是泛型類型參數還是它們的約束都不會被 派生類 繼承,這是因爲泛型類型參數和約束不是類的成員。雖然不能被派生類繼承,但是可以被從其派生的泛型類所繼承。由於派生的泛型類類型參數時泛型基類的類型實參,所以類型參數必須具有等同於或者強於泛型基類的約束條件。

Tip 2:泛型方法同樣也可以使用約束,約束條件和泛型類類似。

六、總結

這篇文章我主要講解了泛型的一些知識,不能說很全面,但已經覆蓋了百分之九十的內容。泛型在開發中可以說是經常用到,良好的使用泛型可以提高代碼複用率以及程序的運行性能。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章