第 70 講:C# 2 之泛型(四):泛型參數(shù)約束
泛型的基本語法說得差不多了。下面我們來說一個泛型的一個新語法,用來限制泛型的調(diào)用條件:泛型約束(Constraint)。
Part 1 引例
1-1 一個排序的例子
有些時候,泛型參數(shù)也不是很好寫代碼。比如我想實現(xiàn)一個泛化排序,那么我們還是使用冒泡排序法,你就會發(fā)現(xiàn),代碼寫不動。
如果我們嘗試對一個 SequenceList<>
類型的實例排序里面的元素的話,我們必須要對每一個 T
類型的元素進行比較和交換操作??蓡栴}在于,T
是泛型參數(shù),那么 T
類型的實例,我壓根就不可能好好地比較:因為它是什么類型我都不知道。
有一個方案是,傳一個參數(shù),是委托類型,用于比較兩個 T
類型的對象,然后返回 int
的結果。C# 提供了一個泛型委托類型 Comparison<T>
,它的聲明是這樣的:
按照這個思路,我們可以傳參,然后修改比較操作:
如此比較,就可以達到排序操作了。
不過,這里我們也不必傳入委托類型來比較,因為它不一定非得需要委托類型實例,我們有時候也只需要一個很簡單的比較兩個對象,類似 int
那樣,大小比較就完事了。大不了 string
類型的排序用字典序什么的??墒?,這么做我們就不得不對每一個實例都給一個委托類型的實例來參與排序。委托類型的實例一次創(chuàng)建就得開辟內(nèi)存空間,而且是堆內(nèi)存(因為是引用類型)。怎么說也會影響一定的性能。
那么,怎么樣可以解決這樣的問題呢?現(xiàn)在我們有一個新的語法:約束。
1-2 解決辦法
我們注意到,SequenceList<>
類型具有增刪改查以及排序的這些操作。那么既然有查找操作,那必然得比較數(shù)據(jù)相等性;另外排序也會比較對象。那么這兩種行為必然有所關聯(lián)。
想到什么關聯(lián)了嗎?還記得 C# 之前基本語法里介紹到了一個 IComparable
接口了嗎?這個接口自帶一個 CompareTo
方法,專門用來比較對象。C# 在泛型建立后,也新建了很多泛型版本的接口,而 IComparable
也不例外:IComparable<>
類型。來看一下這個接口的聲明:
可以看到,這個和 IEnumerable<>
還有點不同:IComparable<>
接口并沒有從非泛型版本的接口派生,因此這里直接是一個方法,并且完全不需要 new
修飾符。剛好,這個實例方法可以完全代替 if
條件。
我們使用新泛型語法:where T : IComparable<T>
追加到類型聲明頭部的末尾:
因為語句是可以換行的,寫在后面有點長,我特意換行寫下來了。這句話的意思是什么呢?這句話的意思是說,我們通過任何形式調(diào)用和使用 SequenceList<T>
類型的時候,這個泛型參數(shù) T
的實際類型必須滿足 where T : 條件
的冒號之后的條件,才可以正常使用和書寫代碼,否則將會產(chǎn)生編譯器錯誤,告訴你,這個泛型參數(shù)的實際類型并不滿足泛型參數(shù)約束。而冒號后直接跟上類型名稱,就表示泛型參數(shù)的實際類型必須從這個類型派生(或者對接口而言,就叫實現(xiàn)此接口)。
接著我們來看一下 if
條件這里。很神奇的是,但凡我為 T
設定了約束條件后,T
就可以書寫 .CompareTo
了。這是什么原理?首先 T
有了約束,就意味著 T
的實際類型必須實現(xiàn)這個接口。既然都實現(xiàn)了這個接口了,那么肯定就可以使用 CompareTo
方法了。這是泛型約束的兩個意義:
對用戶而言,限制泛型參數(shù)
T
的實際范圍;對編譯器而言,泛型參數(shù)
T
實現(xiàn)了接口了,因此你就可以使用這個接口里的成員作為T
類型的實例,作為額外的可使用項。
下面來說一下,C# 有哪些可用的泛型約束。
Part 2 列舉一下所有的泛型約束
C# 提供了如下這些泛型約束:

我們挨個說一下。
2-1 類類型約束
我要獲取一個人的身份信息,不管它是 Student
、Teacher
還是別的類型,我們使用泛型參數(shù) T
的約束 where T : Person
可以要求 T
僅可以用于一個 Person
類型,或是它的派生類型。
這種類型約束是具體的。只要不走這個 Person
類型派生的所有類型全部都會被拒之門外。
接著我們來說一個比較麻煩的問題。
這兩個方法有什么區(qū)別呢?是的,前者不轉型,后者要轉型。后者參數(shù)的類型在從模糊類型轉為具體類型的時候,是需要用戶自己強制轉換的,而前者自身就是這個類型,因為用的是泛型參數(shù) T
,具體到 Person
類型的話,T
就是 Person
;具體到 Student
的話,T
就是 Student
。
2-2 接口類型約束
接口類型約束就是最開始引例里舉的例子,這樣的行為。只要我實現(xiàn)了這個接口的類型均可作為泛型參數(shù)的實際類型使用。不過請一定要注意,接口是值類型和引用類型均可派生的,而只有類類型約束只應用于引用類型。換句話說,如果寫明接口類型約束的話,我們沒有任何別的信息可以確定到底這個 T
是一個值類型還是一個引用類型。
2-3 無參構造器約束 new()
無參構造器約束 new()
用于表示一個泛型參數(shù) T
必須包含一個 public
修飾的無參構造器,可提供給外界調(diào)用。請注意,無參構造器可以是類型里自定義的,也可以是編譯器自己生成的。就結構而言,編譯器將無條件自動生成 public
的無參構造器,因而所有結構均滿足 new()
約束;但如果是類的話,用戶可能會隱藏無參構造器(比如自己創(chuàng)建非 public
修飾的構造器)或直接創(chuàng)建帶參構造器,這樣可以禁止編譯器生成 public
的無參構造器。在這種情況下,可能一個類就不一定能滿足 new()
約束了。但始終注意,new()
約束僅能影響到引用類型。
常見情況是為了默認去實例化這個對象。
假設我自己寫了一個 List<T>
類型,并需要一個方法,創(chuàng)建一個只有一個元素的 List<T>
類型對象的話,我必須要求 T
具有無參構造器進行實例化才行。為了這里能夠實例化 T
類型對象,我設定 where T : new()
使之可以實例化,然后使用 new T()
語句對其實例化。
2-4 引用類型約束 class
引用類型約束暗示泛型參數(shù)只能是一個引用類型。比如說類、委托、接口,它們都是引用類型。而枚舉、結構都不滿足 class
約束。這種約束一般用于廣義情況,比如實現(xiàn)一些對象復制內(nèi)存內(nèi)容的時候,我們可能會約束一個類型必須包含 Clone
方法。但問題在于值類型是自動復制副本的,因此值類型不需要這個 Clone
方法來復制內(nèi),只需要一個賦值運算符就可以了;而相反地,引用類型需要它。因此,我們可以這么寫代碼:
雖然它沒有編譯器的實質(zhì)性影響(除了判斷對象滿不滿足要求要編譯器分析一下代碼外),但它限制了這個數(shù)據(jù)類型本身只用于引用類型。
最后一定要注意,這里的 class
語義不是一個類類型限制,而是所有引用類型。
2-5 值類型約束 struct
引用類型配套的另外一種情況自然就是值類型約束 struct
了。不過一般引用類型出現(xiàn)頻次較多,所以 struct
約束很少出現(xiàn)了。不過它有一種用法,是表示 Nullable<T>
的這個 T
的時候用。
在這種情況下,我們不得不限制值類型,這樣的話我們可以使用語法 T?
來表示這個返回值是包含 null
作為額外數(shù)值情況的可空值類型。這在引用類型里是沒有這一說的,因為引用類型自己就自帶 null
為默認情況,但值類型里沒有 null
一說,所以我們需要添加 ?
記號來表示值類型可空。但前提是,它是值類型,才可使用 T?
的記號(或者直接寫 Nullable<T>
),因此我們要寫約束部分 where T : struct
。
另外,我們查看 Nullable<T>
的官方 API 的聲明就可以發(fā)現(xiàn):
在類型的頭部末尾是跟著這個 where T : struct
約束的限制的。
2-5 泛型參數(shù)約束
雖然這個用得非常少,但還是要說一下。如果在某一個書寫代碼的地方可以同時看到兩個不同的泛型參數(shù)的話,要想設定其中一個是另外一個的父類型/子類型的話,就需要用到它。比如假設是 T
和 U
,那么寫法可以是 where T : U
(當然也可能是反過來的)。
2-6 混用約束
要想疊 buff 那樣給泛型參數(shù)施加多個不同的約束要求的話,我們可以使用逗號分隔每一個約束信息。不過,要注意一下的是,有些約束是包含關系,所以不要混用一些情況。比如類類型約束和 class
約束是包含關系,結構類型約束和 new()
也是包含關系。類似這樣的約束形式不要混用。
接著說一下語法的細節(jié)。類類型約束和接口類型約束自身就可以包含多個類型在內(nèi)。比如我同時想讓泛型參數(shù)實現(xiàn) IEnumerable<T>
和 ICloneable<T>
接口,那么就挨著寫就可以了:
public
注意順序。C# 強制我們先寫 struct
和 class
約束,然后是類類型約束和接口類型約束,最后是 new()
約束。寫反了的話,編譯器會給出編譯器錯誤,不過它會教我們改變一下順序,這個順序記錯了不必擔心。
混合約束很多時候都用在限定泛型參數(shù)一定是一個值類型(或引用類型)來實現(xiàn)某接口的時候。比如我要求泛型參數(shù)必須是值類型,且實現(xiàn)接口 I
的話:
這種使用情況居多。
Part 3 泛型約束的靈活使用
下面列舉一些奇妙的泛型參數(shù)約束的使用,能夠讓你對泛型約束有一個更深刻和神奇的認知。
3-1 約束多個泛型參數(shù)
如果有多個泛型參數(shù)的話(雖然之前只是簡單提過一嘴),可以使用重復的 where
部分來完成約束:
這是我換行之后的寫法。請一定要注意,where
語句之間沒有任何符號分隔,特別是不要往 where
語句的末尾加什么分號或者逗號之類的。
3-2 奇異遞歸模板模式
奇異遞歸模板模式(Curiously Repeating Template Pattern,簡稱 CRTP)是 C++ 語言里模板(Template)語言特性里的一個行為,這里 C# 因為類似,因此概念上就直接抄過來用了。
CRTP 在 C# 里是這樣的:它表示一個泛型約束。假設類型 A<T>
的 T
也必須是 A<T>
自己或它的派生類型的話,我們就稱為這個約束叫做奇異遞歸模板模式,在 C# 里語法是這樣的:
是的,where T : A<T>
。
可問題是,這樣的模式用在什么時候呢?還記得 C# 有一個 IEquatable
接口嗎?C# 有了泛型之后,所有這樣的接口也全部都有了它們的泛型版本。比如 IEquatable
的泛型版本是 IEquatable<T>
。而這里的 T
,如果要你自己思考,你認為這個 T
得滿足什么條件?
是的,是這個類型自己。考慮到 IEquatable<T>
接口的代碼是這樣的:
T
就是它自己。比如我有一個 A
類型想要實現(xiàn)接口 IEquatable<T>
了,那么這個 T
就是這里的 A
。而仔細分析一下這個泛型約束就可以發(fā)現(xiàn),T
要實現(xiàn)接口自己,那么 A
所以它是滿足這個泛型約束的寫法。
3-3 多泛型參數(shù)的交換使用模式
考慮一種情況。假設我想要將一個數(shù)據(jù)對象以 JSON 形式序列化。
序列化(Serialization)是一種行為,能讓一個任何一種數(shù)據(jù)類型的對象按照字符串或二進制的形式保存它的數(shù)據(jù)信息,以保存到本地以文件的形式存儲。相反地,把二進制或字符串形式的文件打開并解析為一個數(shù)據(jù)類型的實體對象的過程叫反序列化(Deserialization)。
為了一個數(shù)據(jù)類型能夠 JSON 序列化,我必須要求一個對象實現(xiàn)一個接口,比如長這樣:IJsonSerializable<T, TConverter>
。其中第一個參數(shù) T
是實現(xiàn) JSON 序列化的那個類型自己,而第二個泛型參數(shù) TConverter
則是你必須實現(xiàn)的一個 JSON 序列化期間需要指定轉換行為的類型。
聽著很復雜是因為各位沒有接觸過這個 API。我們來看下這個數(shù)據(jù)類型的頭部:
第一個約束 T
必須實現(xiàn)該接口,這個是前文介紹的 CRTP,表示自己就是當前類型;而第二個約束 TConverter
要求它實現(xiàn) JsonConverter<>
類型,且自帶無參構造器以用于實例化。第一個約束就不必詳細解釋了,因為前文已經(jīng)說過了。這里只是要你注意一下,這里有兩個泛型參數(shù)的時候,也是可以有 CRTP 的使用方式的。而第二個約束,這個 JsonConverter<>
類型是什么呢?在有些時候,我們?yōu)榱撕喕?JSON 序列化的過程,有一些數(shù)據(jù)類型我們不必去獲取對象的基本信息,然后挨個序列化,搞成默認的那個輸出模式。有些時候,一個普通的字符串可能更方便表達出數(shù)據(jù)類型的信息,因此我們需要借助 JsonConverter<>
類型來完成。這個 TConverter
泛型參數(shù)必須從 JsonConverter<>
類型派生,而這個 JsonConverter<>
類型的泛型參數(shù)的實際類型,則應該是這里的 T
自己,這個是第二個參數(shù)的含義。
Part 4 約束繼承
剛才我們說到一個例子。Nullable<T>
類型有一個自帶的約束:where T : struct
。而我們要使用這個作為泛型參數(shù)的約束的話,我們也必須使用此泛型約束來約束你的泛型參數(shù)。
例如這樣的例子里,T?
在索引器里作為返回值類型出現(xiàn)。而 T?
是 Nullable<T>
類型的特有記號,因此必須要求這里的 T
也得是一個值類型。而此時如果我們不在第一行加上 where T : struct
的話,可能程序就無法繼續(xù)編譯下去。
這個現(xiàn)象稱為約束繼承(Constraints Inheritance)。如果你擁有一個實際的代碼,要想使用一些已經(jīng)帶有泛型約束的泛型參數(shù),那么你這個泛型參數(shù)也必須帶有此泛型約束,否則代碼將無法通過編譯。這也是有道理的,因為你需要讓代碼能夠編譯,那么 T?
必須要求 T
至少也得是一個值類型。不管你是否對 T
有別的約束條件,但 T
必須至少應當是值類型才可以使用 T?
語法。
這樣的現(xiàn)象也發(fā)生在一些你實現(xiàn)派生類型的時候。舉個例子,如果你實現(xiàn)了一個基本的數(shù)據(jù)類型 BaseEntity<T>
,它的 T
必須可以比較大小,那么類型的頭部應當是這樣的:
可如果我們要從這個類型派生下去的話,比如這樣的代碼:
這樣的頭部是否正確呢?答案肯定是否定的。因為你的基類型要求 BaseEntity<T>
的 T
至少可以參與比較,但你的派生類型不允許這么做。假設我在一些基類型的代碼里追加使用了比如 .CompareTo
的方法調(diào)用,由于基類型限制了泛型參數(shù)可以參與比較,那么這樣的代碼是正確的;但派生類型為了執(zhí)行基類型的方法,那么泛型參數(shù)也得帶有此約束,否則這個派生類型里的泛型參數(shù)就不一定實現(xiàn)了這個接口,也不一定包含了 CompareTo
方法,于是編譯器就會在運行時期找不到方法調(diào)用。
因此,編譯器防止這樣的現(xiàn)象發(fā)生,必須要求用戶在使用之前就必須得遵循約束繼承規(guī)則。
Part 5 泛型約束的限制
雖然泛型約束對我們實現(xiàn)一些代碼有更加方便的方式,可如今的 C# 仍然對很多地方有所限制導致無法我們這么使用。下面列舉一些目前 C# 還不讓我們這么做的限制。
5-1 泛型約束的條件總是合取關系
目前來說,所有的泛型參數(shù)添加的約束,不管你寫了多少,它都是析取的:
T
必須是引用類型、必須有無參構造器,且必須實現(xiàn)了 I
接口。但可以發(fā)現(xiàn),這樣的條件是析取的,你必須全部都滿足。這么書寫的格式并非“滿足其中一個即可”,而當前 C# 環(huán)境來說,還無法做到“或者”關系的限制。
5-2 泛型約束不能以方法級別限制
到目前來說,泛型約束都無法限制方法級成員。舉個例子,我只想讓一個數(shù)據(jù)類型帶有 GetEnumerator
方法并返回一個迭代器類型就足夠我使用 foreach
循環(huán)了。可問題是我們無法使用任何一個 C# 語法來做到這一點,而目前唯一能做到的辦法只有實現(xiàn) IEnumerable
接口來約束泛型參數(shù):where T : IEnumerable<T>
。
5-3 泛型約束的條件總是針對于實例成員的
到 C# 10 之前,C# 的所有泛型約束的條件都只能設定在實例成員上。比如我實現(xiàn)了一個接口類型,接口里包含了各種各樣的成員,但它們都不能是 static
修飾的。
而目前 C# 的泛型約束來說,我們也只能通過接口來限制類型是否實現(xiàn)一些方法。而接口不讓存儲靜態(tài)成員,因此這樣的條件我們是做不到的。不過到了 C# 10 后,我們可以通過新語法 static abstract
做到這一點,但這是以后的事情了。如果你還在使用早期的 .NET 框架的話,可能你還無法使用這一語法特性。
5-4 泛型約束無法限制對象是枚舉類型或委托類型
到 C# 7.3 之前,C# 的泛型約束還無法限制類型是一個枚舉或委托類型,因為 C# 團隊尚未挖掘出真正這么限制的好處。主要原因是在于,Enum
和 Delegate
類型(所有枚舉類型和委托類型的基類型)雖然是 abstract
的,但你仍舊無法自定義類型從它們兩個類型派生。換句話說,這種數(shù)據(jù)類型設計出來只是提供操作執(zhí)行的,而用戶無法創(chuàng)建這兩個類型的派生類型。
C# 7.3 以及以后可以使用 where T : Enum
以及 where T : Delegate
來限制泛型參數(shù)必須是枚舉或委托類型。
5-5 泛型約束無法限制泛型參數(shù)可使用指針
到 C# 7.3 之前,C# 的泛型約束還無法限制類型可以使用指針,因為 C# 的所有泛型參數(shù)都和指針“絕緣”:指針類型是不能作為泛型參數(shù)的。
但在 C# 7.3 以及之后,我們可以使用新的泛型約束 unmanaged
來限制類型可以使用 sizeof(T)
以及 T*
的語法來做一些事情。但目前來說是無法做的。
C# 有一個沒有在官方文檔里寫出來的關鍵字
__makeref
,可以獲取對一個值類型對象的引用,這特別是用在一些數(shù)組成員是值類型的時候。從代碼上來看,我們可以使用
__makeref
獲取對象的引用,然后返回一個所謂的TypedReference
類型的實例。這個TypedReference
你當成一個引用就可以了,具體拿來干嘛的,這個我們不展開講解,因為這個屬于互操作性里的一種黑科技用法,而且不屬于 C# 語法。你甚至使用別的支持 C# 的 IDE(點名 JetBrains Rider)都有可能無法編譯這段代碼。這段代碼只對 Visual Studio 有效。
獲取引用后,我們可通過轉換為
IntPtr
的方式將對象轉換為有效地址信息,最后相減就可以得到相鄰兩塊內(nèi)存的地址差值。而這個差值就等于是一個T
類型對象占據(jù)的內(nèi)存大小了。
5-6 泛型約束只能限制無參構造器
泛型約束對構造器的限制有點奇怪。目前來說 C# 只能限制一個泛型參數(shù)是否自帶一個無參構造器,但別的構造器尚不支持。