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

来源:https://www.cnblogs.com/HiroMuraki/archive/2022/06/08/16355137.html
-Advertisement-
Play Games

0. 文章目的: 介紹變體的概念,並介紹其對C#的意義 1. 閱讀基礎 瞭解C#進階語言功能的使用(尤其是泛型、委托、介面) 2. 從示例入手,理解變體 變體這一概念用於描述存在繼承關係的類型間的轉化,這一概念並非只適用於C#,在許多其他的OOP語言中也都有變體概念。變體一共有三種:協變、逆變與不變 ...


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:

(紅色是方法參數類型)

  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的要求的:

  同樣以委托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類型所替代:

  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類型:

  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。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • mkcert(Windows環境) 1.下載地址:https://github.com/FiloSottile/mkcert/releases 2.選擇版本 3.以管理員身份運行`命令提示符 1) cd C:/ #進入工具存放的目錄下 2) mkcert-v1.4.4-windows-amd64.e ...
  • 在NumPy中,矩陣是 ndarray 的子類,與數學概念中的矩陣一樣,NumPy中的矩陣也是二維的,可以使用 mat 、 matrix 以及 bmat 函數來創建矩陣。 一、創建矩陣 mat 函數創建矩陣時,若輸入已為 matrix 或 ndarray 對象,則不會為它們創建副本。 因此,調用 m ...
  • 一個工作了6年的粉絲和我說, 最近面試感覺越來越難的,基本上都會問技術底層原理,甚至有些還會問到操作系統層面的知識。 我說,現在各個一線大廠有很多優秀的程式員畢業了,再加上市場大環境不好對程式員的需求量也在減少。 如果技術底子不好,確實找工作會很困難。 今天分享的問題是:”new String(“a ...
  • #所有流程 !!!不要修改為搶票,遵守法律法規是每一個中國公民應盡的責任,盜用並造成不良後果自負!!! ''' #可以實現買學生票,兒童票,殘疾票,成人票和所有能選的座位類別。 2022-05-22目前為止還是完全正常運行的,之後網頁改動就不一定了哦!!!!!!12306官網模擬買票:1.輸入賬號密 ...
  • 2 Sentinel 限流熔斷降級 Sentinel 可以簡單的分為 Sentinel 核心庫和 Dashboard。核心庫不依賴 Dashboard,但是結合 Dashboard 可以取得最好的效果。我們先來學習Sentinel 核心庫的使用,後面再學習Dashboard使用。 在我們項目中,用戶 ...
  • 題目描述 密碼要求: 1.長度超過8位 2.包括大小寫字母.數字.其它符號,以上四種至少三種 3.不能有長度大於2的包含公共元素的子串重覆 (註:其他符號不含空格或換行) 數據範圍:輸入的字元串長度滿足 1≤n≤100 輸入描述 一組字元串 輸入描述 如果符合要求輸出:OK,否則輸出NG 代碼和解題 ...
  • 前言 以下僅做相關知識的簡述,更深入的瞭解和學習,請自行查閱資料或留言。 一、Python簡介 Python請查看官網自行瞭解。 Python是一種編程語言,可以讓您更快地工作,並更有效地集成您的系統。 Python is a programming language that lets you w ...
  • Java 8是Java的一個重大版本,是目前企業中使用最廣泛的一個版本。 它支持函數式編程,新的Stream API 、新的日期 API等一系列新特性。 掌握Java8的新特性已經是java程式員的標配,掌握了它,就可以看懂公司里的代碼、高效率地處理大量集合數據以及消滅“嵌套地獄”等等。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...