C#高級語法之泛型、泛型約束,類型安全、逆變和協變(思想原理)
- 2019 年 10 月 3 日
- 筆記
一、為什麼使用泛型?
泛型其實就是一個不確定的類型,可以用在類和方法上,泛型在聲明期間沒有明確的定義類型,編譯完成之後會生成一個佔位符,只有在調用者調用時,傳入指定的類型,才會用確切的類型將佔位符替換掉。
首先我們要明白,泛型是泛型,集合是集合,泛型集合就是帶泛型的集合。下面我們來模仿這List集合看一下下面這個例子:
我們的目的是要寫一個可以存放任何動物的集合,首先抽象出一個動物類:
//動物類 public class Animal { //隨便定義出一個屬性和方法 public String SkinColor { get; set; }//皮膚顏色 //會跑的方法 public virtual void CanRun() { Console.WriteLine("Animal Run Can"); } }
然後創建Dog類和Pig類
//動物子類 Dog public class Dog : Animal { //重寫父類方法 public override void CanRun() { Console.WriteLine("Dog Can Run"); } } //動物子類 Pig public class Pig : Animal { //重寫父類方法 public override void CanRun() { Console.WriteLine("Pig Can Run"); } }
因為我們的目的是存放所有的動物,然後我們來寫一個AnimalHouse用來存放所有動物:
//存放所有動物 public class AnimalHouse { //由於自己寫線性表需要考慮很多東西,而且我們是要講泛型的,所以內部就用List來實現 private List<Animal> animal = new List<Animal>(); //添加方法 public void AddAnimal(Animal a) { animal.Add(a); } //移除方法,並返回是否成功 public bool RemoveAnimal(Animal a) { return animal.Remove(a); } }
AnimalHouse類型可以存放所有的動物,存放起來很方便。但是每次取出的話,使用起來會很不方便,因為只能用一些動物的特徵,而無法使用子類的特徵,例如Dog子類有CanSwim()方法(會游泳的方法),而動物中是沒有這個方法的,所以就無法進行調用,必須將Animal類型轉換為Dog類型才可以使用,不僅會增加額外的開銷而且還有很大的不確定性,可能轉換失敗,因為AnimalHouse中是存放了很多種動物子類。
如果我們有方法可以做到,讓調用者來決定添加什麼類型(具體的類型,例如Dog、Pig),然後我們創建什麼類型,是不是這些問題就不存在了?泛型就可以做到。
我們看一下泛型是如何定義的:
//用在類中 public class ClassName<CName> { //用在方法中 public void Mothed<MName>() { } //泛型類中具體使用CName //返回值為CName並且接受一個類型為CName類型的對象 public CName GetC(CName c) { //default關鍵字的作用就是返回類型的默認值 return default(CName); } }
其中CName和MName是可變的類型(名字也是可變的),用法的話就和類型用法一樣,用的時候就把它當成具體的類型來用。
了解過泛型,接下來我們使用泛型把AnimalHouse類更改一下,將所有類型Animal更改為泛型,如下:
public class AnimalHouse<T> { private List<T> animal = new List<T>(); public void AddAnimal(T a) { animal.Add(a); } public bool RemoveAnimal(T a) { return animal.Remove(a); } }
AnimalHouse類型想要存儲什麼樣的動物,就可以完全交由調用者來決定:
//聲明存放所有Dog類型的集合 AnimalHouse<Dog> dog = new AnimalHouse<Dog>(); //聲明存放所有Pig類型的集合 AnimalHouse<Pig> pig = new AnimalHouse<Pig>();
調用方法的時候,原本寫的是T類型,當聲明的時候傳入具體的類型之後,類中所有的T都會變成具體的類型,例如Dog類型,Pig類型
這樣我們的問題就解決了,當調用者傳入什麼類型,我們就構造什麼類型的集合來存放動物。
但是還有一個問題,就是調用者也可以不傳入動物,調用者可以傳入一個桌子(Desk類)、電腦(Computer),但是這些都不是我們想要的。比如我們需要調用動物的CanRun方法,讓動物跑一下再放入集合里(z),因為我們知道動物都是繼承自Animal類,所有動物都會有CanRun方法,但是如果傳入過來一個飛Desk類我們還能使用CanRun方法嗎?答案是未知的,所以為了確保安全,我們需要對傳入的類型進行約束。
二、泛型約束
泛型約束就是對泛型(傳入的類型)進行約束,約束就是指定該類型必須滿足某些特定的特徵,例如:可以被實例化、比如實現Animal類等等
我們來看一下官方文檔上都有那些泛型約束:
約束 | 說明 |
where T : struct | 類型參數必須是值類型。 可以指定除 Nullable<T> 以外的任何值類型。 有關可以為 null 的類型的詳細資訊,請參閱可以為 null 的類型。 |
where T : class | 類型參數必須是引用類型。 此約束還應用於任何類、介面、委託或數組類型。 |
where T : unmanaged | 類型參數必須是非託管類型。 |
where T : new() | 類型參數必須具有公共無參數構造函數。 與其他約束一起使用時,new() 約束必須最後指定。 |
where T : <基類名> |
類型參數必須是指定的基類或派生自指定的基類。 |
where T : <介面名稱> |
類型參數必須是指定的介面或實現指定的介面。 可指定多個介面約束。 約束介面也可以是泛型。 |
where T : U | 為 T 提供的類型參數必須是為 U 提供的參數或派生自為 U 提供的參數。 |
對多個參數應用約束:
//微軟官方例子
class Base { } class Test<T, U> where U : struct where T : Base, new() { }
使用的話只需要在泛型後面添加 where 泛型 : 泛型約束1、泛型約束2….,如果有new()約束的話則必須放在最後,說明都有很詳細的介紹。
然後我們來為AnimalHouse添加泛型約束為:必須包含公共無參構造函數和基類必須是Animal
//Animal約束T必須是Animal的子類或者本身,new()約束放在最後 public class AnimalHouse<T> where T : Animal, new() { private List<T> animal = new List<T>(); public void AddAnimal(T a) { //調用CanRun方法 //如果不加Animal泛型約束是無法調用.CanRun方法的,因為類型是不確定的 a.CanRun(); //添加 animal.Add(a); } public bool RemoveAnimal(T a) { return animal.Remove(a); } }
然後調用的時候我們傳入Object試一下
提示Object類型不能傳入AnimalHouse<T>中,因為無法轉換為Animal類型。
我們在寫一個繼承Animal類的Tiger子類,然後私有化構造函數
//動物子類 Tiger public class Tiger : Animal { //私有化構造函數 private Tiger() { } public override void CanRun() { Console.WriteLine("Tiger Can Run"); } }
然後創建AnimalHouse類型對象,傳入Tiger類試一下:
提示必須是公共無參的非抽象類型構造函數。現在我們的AnimalHouse類就很完善了,可以存入所有的動物,而且只能存入動物
三、逆變和協變
先來看一個問題
Dog dog = new Dog(); Animal animal = dog;
這樣寫編譯是不會報錯的,因為Dog繼承了Animal,默認會進行一個隱式轉換,但是下面這樣寫
AnimalHouse<Dog> dogHouse = new AnimalHouse<Dog>(); AnimalHouse<Animal> animalHouse = dogHouse;
這樣寫的話會報一個無法轉換類型的錯誤。
強轉的話,會轉換失敗,我們設個斷點在後一句,然後監視一下animalHouse的值,可以看到值為null
//強轉編譯會通過,強轉的話會轉換失敗,值為null IAnimalHouse<Animal> animalHouse = dogHouse as IAnimalHouse<Animal>;
協變就是為了解決這一問題的,這樣做其實也是為了解決類型安全問題(百度百科):例如類型安全程式碼不能從其他對象的私有欄位讀取值。它只從定義完善的允許方式訪問類型才能讀取。
因為協變只能用在介面或者委託類型中,所以我們將AnimalHouse抽象抽來一個空介面IAnimalHouse,然後實現該介面:
//動物房子介面(所有動物的房子必須繼承該介面,例如紅磚動物房子,別墅動物房) public interface IAnimalHouse<T> where T : Animal,new() { } //實現IAnimalHouse介面 public class AnimalHouse<T> : IAnimalHouse<T> where T : Animal,new() { private List<T> animal = new List<T>(); public void AddAnimal(T a) { a.CanRun(); animal.Add(a); } public bool RemoveAnimal(T a) { return animal.Remove(a); } }
協變是在T泛型前使用out關鍵字,其他不需要做修改
public interface IAnimalHouse<out T> where T : Animal,new() { }
接下來我們用介面來調用一下,現在一切ok了,編譯也可以通過
IAnimalHouse<Dog> dogHouse = new AnimalHouse<Dog>(); IAnimalHouse<Animal> animalHouse = dogHouse;
協變的作用就是可以將子類泛型隱式轉換為父類泛型,而逆變就是將父類泛型隱式轉換為子類泛型
將介面類型改為使用in關鍵字
public interface IAnimalHouse<in T> where T : Animal,new() { }
逆變就完成了:
IAnimalHouse<Animal> animalHouse = new AnimalHouse<Animal>(); IAnimalHouse<Dog> dogHouse = animalHouse;
逆變和協變還有兩點:協變時泛型無法作為參數、逆變時泛型無法作為返回值。
逆變:
協變:
語法都是一些 非常粗糙的東西,重要的是思想、思想、思想。然後我們來看一下為什麼要有逆變和協變?
什麼叫做類型安全?C#中的類型安全個人理解大致就是:一個對象向父類轉換時,會隱式安全的轉換,而兩種不確定可以成功轉換的類型(父類轉子類),轉換時必須顯式轉換。解決了類型安全大致就是,這兩種類型一定可以轉換成功。(如果有錯誤,歡迎指正)。
協變的話我相信應該很好理解,將子類轉換為父類,兼容性好,解決了類型安全(因為子類轉父類是肯定可以轉換成功的);而協變作為返回值是百分百的類型安全
“逆變為什麼又是解決了類型安全呢?父類轉子類也安全嗎?不是有可能存在失敗嗎?”
其實逆變的內部也是實現子類轉換為父類,所以說也是安全的。
“可是我明明看到的是IAnimalHouse<Dog> dogHouse = animalHouse;將父類對象賦值給了子類,你還想騙人?”
這樣寫確實是將父類轉換為子類,不過逆變是用在作為參數傳遞的。這是因為寫程式碼的“視角”原因,為什麼協變這麼好理解,因為子類轉換父類很明顯可一看出來“IAnimalHouse<Animal> animalHouse = dogHouse;”,然後我們換個“視角”,將逆變作為參數傳遞一下,看這個例子:
先將IAnimalHouse介面修改一下:
public interface IAnimalHouse<in T> where T : Animal,new() { //添加方法 void AddAnimal(T a); //移除方法 bool RemoveAnimal(T a); }
然後我們在主類(Main函數所在的類)中添加一個TestIn方法來說明為什麼逆變是安全的:
//需要一個IAnimalHouse<Dog>類型的參數 public void TestIn(IAnimalHouse<Dog> dog) { }
接下來我們將“視角”切到TestIn中,作為第一視角,我們正在寫這個方法,至於其他人如何調用我們都是不得而知的
我們就隨便在當前方法中添加一個操作:為dog變數添加一個Dog對象,TestIn方法改為如下:
//需要一個IAnimalHouse<Dog>類型的參數 public static void TestIn(IAnimalHouse<Dog> dog) { Dog d = new Dog(); dog.AddAnimal(d); }
我們將“視角”調用者視角,如果我們想調用當前方法,只有兩種方法:
//第一種 AnimalHouse<Dog> dogHouse = new AnimalHouse<Dog>(); TestIn(dogHouse); //第二種 AnimalHouse<Animal> animalHouse = new AnimalHouse<Animal>(); //因為使用了in關鍵字所以可以傳入父類對象 TestIn(animalHouse);
第一種的話我們就不看了,很正常也很合理,我們主要來看第二種,那第二種類型安全又在哪兒呢?
可能有人已經反應過來了,我們再來看一下TestIn方法,有一個需要傳遞過來的IAnimalHouse<Dog>類型的dog對象,如果調用者是使用第二種方法調用的,那這個所謂的IAnimalHouse<Dog>類型的dog對象是不是其實就是AnimalHouse<Animal>類型的對象?而dog.AddAnimal(參數類型);的參數類型是不是就是需要一個Animal類型的對象?那傳入一個Dog類型的d對象是不是最終也是轉換為Animal類型放入dog對象中?所以當逆變作為參數傳遞時,類型是安全的。
思考:那麼,現在你能明白上面那個錯誤,為什麼“協變時泛型無法作為參數、逆變時泛型無法作為返回值”了嗎?

public interface IAnimalHouse<in T> where T : Animal,new() { //如果這樣寫逆變成立的話 //我們實現該介面,實現In方法,return(返回)一個默認值default(T)或者new T() //此時使用第二種方法調用TestIn,並在TestIn中調用In方法 //注意,在TestIn中In方法的顯示返回值肯定是Dog,但是實際上要返回的類型是Animal //所以就存在Animal類型轉換為Dog類型,所以就有可能失敗 //所以逆變時泛型無法作為返回值 T In(); void AddAnimal(T a); bool RemoveAnimal(T a); }
逆變思考答案,建議自己認真思考過後再看

//在主類(Main類)中添加一個out協變測試方法 public static IAnimalHouse<Animal> TestOut() { //返回一個子類 return new AnimalHouse<Dog>(); } //回到介面 public interface IAnimalHouse<out T> where T : Animal,new() { //如果這樣寫協變成立的話 //我們在Main方法中調用TestOut()方法,使用house變數接收一下 //IAnimalHouse<Animal> house = TestOut(); //然後調用house的AddAnimal()方法 //注意,此時AddAnimal方法需要的是一個Animal,但是實際類型卻是Dog類型 //因為我們的TestOut方法返回的是一個Dog類型的對象 //所以當我們在AddAnimal()中傳入new Animal()時,會存在Animal父類到Dog子類的轉換 //類型是不安全的,所以協變時泛型無法作為參數 void AddAnimal(T a); bool RemoveAnimal(T a); }
協變思考答案,建議自己認真思考過後再看
如果我哪點講的有誤或者那點不是太明白都可以留言指正或提問。