.NET C#雜談(1):變體 - 協變、逆變與不變

HiroMuraki發表於2022-06-08

0. 文章目的:

  介紹變體的概念,並介紹其對C#的意義

 

1. 閱讀基礎

  瞭解C#進階語言功能的使用(尤其是泛型、委託、介面)

 

2. 從示例入手,理解變體

  變體這一概念用於描述存在繼承關係的型別間的轉化,這一概念並非只適用於C#,在許多其他的OOP語言中也都有變體概念。變體一共有三種:協變、逆變與不變。其中協變與逆變這兩個詞來自數學領域,但是其含義和數學中的含義幾乎沒有關係(就像程式語言的反射和光的反射之間的關係)。從字面上來看這三種變體的名字多少有點唬人,但其實際意思並不難理解。廣泛來說,三種變體的意思如下:

  • 協變(Covariance):允許使用派生程度更大的型別
  • 逆變(Contravariance):允許使用派生程度更小的型別
  • 不變(Invariance):只允許目標型別

或者換一種更具體的說法:

  • 協變(Covariance):若型別A為協變數,則需要使用型別A的地方可以使用A的某個子類型別。
  • 逆變(Contravariance):若型別A為逆變數,則需要使用型別A的地方可以使用A的某個基類型別。
  • 不變(Invariance):若型別A為不變數,則需要使用型別A的地方只能使用A型別。

(注意是‘協變/量’而不是‘協/變數’)

  為了方便說明三者的含義,先定義兩個類:

class Cat { }
class SuperCat : Cat { }

  上述程式碼定義了一個Cat類,並從Cat類派生出一個SupreCat類,如無特殊說明,後文的所有程式碼都會假設這兩個類存在。下面利用這兩個類逐一說明三種變體的含義。

2.1 協變:在一個需要Cat的場合,可以使用SuperCat

  例如,對於下列程式碼:

Cat cat = new SuperCat();

  cat是一個引用Cat物件的變數,從型別安全的角度來說,它應該只能引用Cat物件,但是由於通常子類總是可以安全地轉化為其某一基類,因此你也可以讓其引用一個SuperCat物件。要實現這種用子類代替基類的操作就需要支援協變,由於OOP語言基本都支援子類向基類安全轉化,所以協變在很多人看來是很十分自然的,也容易理解。

2.2 逆變:在一個需要SuperCat的場合,可以使用Cat

  逆變有時也被稱為抗變,你可能會覺得逆變的含義非常讓人迷惑,因為通常來說基類是不能安全轉化為其子類的,從型別安全的角度來看,這一概念應該似乎沒有實際的應用場合,尤其是對於靜態型別的語言。然而,考慮以下程式碼:

delegate void Action<T>();

void Feed(Cat cat)
{
    ...
}

Action<SuperCat> f = Feed;

  Feed是一個‘引數為Cat物件的方法’,而f是一個引用‘引數為SuperCat物件的方法’的委託。從型別安全的角度來說,委託f應該只能引用引數為SuperCat物件的方法。然而如果你仔細思考上述程式碼,就會意識到既然委託f在呼叫時需要傳入的是一個SuperCat物件,那麼可以處理Cat型別的Feed方法顯然也可以處理SuperCat(因為SuperCat可以安全轉化為Cat),因此上面的程式碼從邏輯上來說是可以正常執行的。那麼也就是說,本來需要SuperCat型別的地方(這裡是委託的引數型別)現在實際給的卻是Cat型別,要實現這種用基類代替子類的操作就需要逆變。

  不過,結合上述,你會發現所謂逆變實際還是依靠‘子類可以向基類安全轉化’這一原則,只是因為我們是從委託f的角度去考慮而已。

2.3 不變:在一個需要Cat的場合,只能使用Cat

  相比逆變和協變,不變更容易理解:只接受指定型別,不接受其基類或者子類。比如如果Cat型別具有不變性,那麼下述程式碼將無法通過編譯:

Cat cat = new SuperCat(); // 錯誤,cat只能引用Cat型別

  顯然不變從表現上來說是理所當然與符合常識的,故本文主要闡述協變與抗變。

 

3. C#中的變體

3.1 C#中的變體

  同大多數語言一樣,C#同樣遵循‘基類引用可以指向子類’這一基本原則,因此對C#來說協變是普遍存在的:

Feed(Cat cat)
{
    ...    
}

Cat cat = new SuperCat();           // 本來需要指向Cat物件的變數cat被指向了SuperCat物件,利用了協變性
SuperCat superCat = new SuperCat(); 
Feed(superCat);                     // 同理,Feed方法需要Cat物件但是傳入的是SuperCat物件,利用了協變性

  C#中的不變體現在值型別上,這是因為值型別都不允許繼承與被繼承,自然也不存在基類或子類的概念,也不存在型別間通過繼承轉化的情況。

  C#中的逆變在一般情況下沒有體現,因為將基類轉化為派生類是不安全的,C#不支援這種操作。所以逆變對C#來說很多時候其實只是概念上的認識,真正讓逆變對C#有意義的情況是使用泛型的場合,這在接下來就會提到。

  從學習語言語法的角度來說,瞭解變體對學習C#的幫助其實不大,但如果想更進一步理解C#中泛型的設計原理,就有必要理解變體了。

3.2 泛型與變體

  理解變體對理解C#的泛型設計原理有重要意義,C#中泛型的型別引數預設為不變數,但可以是outin關鍵字來指示型別為引數為協變數或者逆變數。簡單來說,in關鍵字用於修飾輸入引數的相容性,out關鍵字用於修飾輸出引數的相容性。這一節會通過具體的泛型使用示例來解釋變體概念對C#泛型的意義。

3.2.1 泛型委託

  (1)輸入引數的相容性:逆變

  考慮下面的泛型委託宣告:

delegate void Action<T>(T arg);

  上述委託可以接受一個引數型別為T,返回型別為TReturn的委託。下面來定義一個方法:

void Feed(Cat cat)
{
    
}

  Foo是一個接受一個Cat物件,並返回一個SuperCat物件的方法。因此,下面的程式碼是理所當然的:

Action<Cat> act = Feed;

  然而,從邏輯上來講,下面的程式碼也應該是合法的:

Action<SuperCat> act = Feed;

  委託act接受的引數型別為SuperCat,也就是說當呼叫委託act的時候傳入的將會是一個SuperCat物件,顯然SuperCat物件可以安全地轉換為Foo所需要的Cat物件,因此這一轉變是安全的。我們以委託act的視角來看:本來act應該引用的是一個‘引數型別為SuperCat’的方法,然而我們卻把一個‘引數型別為Cat的’Feed方法賦值給了它,但結合上面的分析我們知道這一賦值行為是安全的。也就是說,本來此時泛型委託Action<T>中泛型型別引數T需要的型別是SuperCat,但現在實際給的型別卻是Cat:

.NET C#雜談(1):變體 - 協變、逆變與不變

(紅色是方法引數型別)

  Cat是SuperCat的基類,也就是說這時候泛型委託Action<T>的型別引數T這個位置上出現了逆變。儘管從邏輯上來說這是合理的,但是C#中泛型型別引數預設具有不變性,因此如果要使上述程式碼通過編譯,還需要將泛型委託Func的型別引數T宣告為逆變數,在C#中,可以通過在泛型型別引數前新增in關鍵字將泛型引數宣告為逆變數:

delegate void Action<in T>(T arg);

  (2):輸出引數的相容性:協變  

  另一方面,下面的程式碼從邏輯上說也應該是合法的:

delegate T Func<T>();

SuperCat GetSuperCat()
{
    ...
}

Func<Cat> func = GetSuperCat;

  委託func被呼叫時需要返回一個Cat物件,而GetSuperCat返回的是一個SuperCat物件,這顯然是滿足func的要求的:

.NET C#雜談(1):變體 - 協變、逆變與不變

  同樣以委託func的視角來看,本來需要型別Cat的地方現在實際給的型別是SuperCat,也就是說,此時出現了協變。同樣的,如果要使上述程式碼通過編譯,應該需要將Func的型別引數T宣告為協變數,可以在泛型引數前新增out關鍵字將泛型型別引數宣告為協變數:

delegate T Func<out TReturn>();

3.2.2 泛型介面

(1)輸出引數的相容性:協變

  假設現有以下用於表示集合的介面宣告與實現該介面的泛型類:

interface ICollection<T>
{ 
}

class Collection<T> : ICollection<T>
{
}

  根據上述定義,理所當然的,下面的語句是合法的:

ICollection<Cat> cats = new Collection<Cat>();

  然而,從邏輯上講,下面的語句也應該是合法的:

ICollection<Cat> cats = new Collection<SuperCat>();

  原因如下:既然SuperCat是Cat的子類,那麼Collection中的任意一個SuperCat物件都應該可以安全轉化為Cat物件,那麼SuperCat的集合也應該視為Cat的集合。從事實上講,若對任何一個需要Cat物件集合的方法,即便傳入的是一個SuperCat物件的集合也應該可以正常工作。同樣以型別為ICollection<Cat>的介面變數cats的視角來看,ICollection<Cat>型別上本來應該為Cat型別的地方現在被SuperCat型別所替代:

.NET C#雜談(1):變體 - 協變、逆變與不變

  SuperCat代替了Cat,也就是說出現了協變,那麼如果要使上述程式碼通過編譯,則需要將型別引數T宣告為協變數:

interface ICollection<out T> 
{
}

  C#中的IEnumerable介面就將其型別引數T宣告為了協變數,因此下面的程式碼可以正常執行:

IEnumerable<Cat> cats = new List<SuperCat>();

(2)輸入引數的相容性:逆變 

  接著再來考慮一個介面與實現類:

interface IHand<T>
{ 
    void Pet(T animal);
}

class Hand<T> : IHand<T> 
{
    void Pet(T animal) { ... }
}

  下面的程式碼應該是合理的:

SuperCat cat = new SuperCat();        
IHand<SuperCat> hand = new Hand<Cat>(); 
hand.Pet(cat);

  原因如下:實現IHand<Cat>介面的Hand<Cat>的Pet方法可以處理Cat型別,顯然其應該也可以處理作為Cat子類的SuperCat。同樣的,以型別為IHand<SuperCat>的介面變數hand來看,本來應該需要型別為SuperCat的地方現在實際卻是Cat型別:

.NET C#雜談(1):變體 - 協變、逆變與不變

  Cat替代了SuperCat,也就是說此時發生了逆變。同樣的,如果要讓上述程式碼通過編譯,需要將IHand<>的型別引數T宣告為逆變數:

interface IHand<in T>
{ 
    void Pet(T animal);
}

  這樣下述程式碼就可以通過編譯:

IHand<SuperCat> hand = new Hand<Cat>();

3.2.3 泛型方法

  與泛型委託和泛型介面不同的是,泛型方法不允許修改型別引數的變體型別,泛型方法的型別引數只能是不變數,因為讓泛型方法的型別引數為變體沒有意義。一方面,泛型方法的型別引數會在方法被呼叫時直接使用目標型別,因此不存在需要變體的情況:

void Pet<T>(T cat)
{
    ...
}

Pet(new Cat());      // 此時T為Cat
Pet(new SuperCat()); // 此時T為SuperCat

  另一方面,你不能給一個方法賦值。

TReturn Foo<T, TReturn>(T t) 
{
    ...
}

Foo = ...; // ???

  顯然上述程式碼是無法通過編譯的。綜上,給泛型方法的型別引數定義為協變數或者逆變數是沒有意義的,因此也沒有必要提供這一功能。

3.2.4 泛型類

  C#中的泛型類的型別引數同樣只允許為不變數,這裡以常用的泛型List<>為例,下面的程式碼是不允許的:

List<Cat> cats = new List<SuperCat>();

  哪怕從概念上說一個SuperCat的物件的集合用於需要Cat物件的集合的場景是合法的,但是這一行為確實是不允許的,原因是CLR不支援。此外,C#限制協變數只能為方法的返回型別(後文會解釋),所以下面的類定義是不可行的:

class Foo<out T>
{
    public T Get() { }              // 可以,協變數用於返回型別
    public Set(T arg) { }           // 錯誤,協變數不可用於方法引數
    public T Field;                 // 錯誤,引數型別T既不是作為方法的返回型別,也不是作為方法的引數
}

  既然連欄位的型別都不能是協變的泛型型別,那麼顯然這樣的類沒有太大的意義。由於以上原因,泛型變體對於定義泛型類的意義不大。

 

4. 變體限制

  C#對泛型中允許變體的型別引數有嚴格的使用限制,主要限制如下:

  1. 協變數只能作為輸出引數(方法的返回值,不包out引數)
  2. 逆變數只能作為輸入引數(方法的引數,不包括in、out以及ref引數)
  3. 只能是不變數、協變數或者逆變數三者之一

  上述限制也說明了為何C#選擇用out關鍵字來修飾協變數,in關鍵字來修飾逆變數。如果沒有以上限制,可能出現一些很奇怪的操作,例如:

(1)假設:協變數可用於輸入引數:

delegate void Action<out T>(T arg); // 此處協變數T作為了方法引數

void Call(SuperCat cat)
{

}

Action<Cat> f = GetCat;

  上述程式碼中當委託f被呼叫時可能會傳入一個Cat物件,然而其引用Call方法需要的是一個SuperCat物件,此時Cat型別無法安全轉化為SuperCat型別,因此會出現執行時錯誤。

(2)假設:逆變數可用於方法的輸出引數

delegate T Func<in T>(); // 此處型別引數T作為了方法返回型別

Cat GetCat()
{
    ...
}

Func<SuperCat> f = GetCat;

  上述程式碼中當委託f被呼叫後,應當返回一個SuperCat物件,然而其引用的GetCat方法返回的只是一個Cat物件,同樣,會出現執行時錯誤。

  從上述例子中可以看出,對變體的適用範圍進行限制顯然有助於提高編寫更安全的程式碼。

 

6. 變體雜談

6.1 歷史問題

  C#的陣列支援協變,也就是說下面的程式碼是允許的:

Cat[] cats = new SuperCat[10];

  咋一看沒什麼問題,SuperCat的陣列當然可以安全轉化為Cat陣列使用,然而這意味著下述程式碼也能通過編譯:

object[] objs = new Cat[10];
objs[0] = new Dog();

  但顯然這會在執行時出現錯誤。陣列協變在某些場合下可能有用,但很多時候錯誤的使用或者誤用會導致沒必要的執行時錯誤,因此應當儘可能避免使用這一特性。

6.2 缺點

  使用變體要求型別可以在引用型別的層面上進行轉換,簡單來說就是變體只作用於引用型別之間。因此儘管object是所有型別的基類,但是下述程式碼依然無法通過編譯:

IEnumerable<object> data = new List<int>();

  這是由於int為值型別,顯然值型別無法在引用型別層面轉化為object。

相關文章