第 72 講:C# 2 之泛型(六):常見泛型數(shù)據(jù)類型的使用
前面我們介紹了眾多的泛型的相關語法和特性。那么泛型基本上就算是告一段落了。下面我們針對于 C# 2 提供的泛型語法,對 .NET 提供的一些 API 做一個簡要的介紹和使用。
Part 1 泛型類和泛型結構
下面我們來學習一下 .NET 里都有哪些好玩的泛型的 API。
本節(jié)內(nèi)容需要你至少知道或對數(shù)據(jù)結構有一定的了解。如果你對數(shù)據(jù)結構了解不多,或者是完全沒有學過的話,這段內(nèi)容可能會不知所云。
另外,本節(jié)由于篇幅的關系,也只對基本操作做一個介紹。對于復雜的操作,你……還是等著看我的視頻吧。
1-1 List<>
順序表
第一個要說的是 List<>
集合。在之前我們學習了 ArrayList
集合,它類似于一個可變長的序列,因此我們也經(jīng)常稱它為順序表??蛇@個順序表的缺陷在于,它的元素類型是 object
類型接收的,這樣會產(chǎn)生和造成裝箱行為,導致性能的損失。雖然我們也確實知道,早期 C# 沒有泛型機制,所以也不得不這么去處理。但是……自從 C# 2 誕生后,就立馬有了 List<>
集合。該集合依然是順序表,但每一個元素均為同一種數(shù)據(jù)類型,也就是泛型參數(shù)所規(guī)定的、給出的類型了。要說這個集合的好處,自然是用到的泛型機制巧妙地避開了裝箱行為。換句話說,現(xiàn)在有了 List<>
后,ArrayList
基本上可以說是基本完全可以拋棄了,因為 List<>
集合可以完成 ArrayList
所有的基本功能,而且還能做得更好、更棒。
那么,這個集合的基本用法呢?不瞞你說,這個集合的用法和 ArrayList
可以說是基本完全一樣。仍然使用 Add
方法來添加追加元素,Remove
則是刪除元素,RemoveAt
則是刪除指定索引上的元素,也可以使用索引器運算符 []
來獲取指定索引上的元素。
當然,Count
屬性確實沒有說過,不過也不復雜:它表示該集合的總元素數(shù)量。
1-2 LinkedList<>
鏈表和 LinkedListNode<>
鏈表節(jié)點
是的,學了數(shù)據(jù)結構的人,最開始就會接觸鏈表。鏈表是另外一種線性表的實現(xiàn)類型,它采用指針的形式關聯(lián)相鄰的數(shù)據(jù),而順序表則直接用的是數(shù)組。對于不關心內(nèi)存位置的存儲情況,我們可以使用鏈表來完成元素的存儲和取值,但是相對地,鏈表不如順序表搜索元素來得快:順序表用數(shù)組存儲,那么自然元素的索引取值的時間復雜度就是 ,但在鏈表里,時間復雜度則是
。
使用 AddLast
方法為集合的末尾追加一個節(jié)點。與此同時,鏈表還支持在開頭插入節(jié)點(AddFirst
方法),在指定節(jié)點的前面插入節(jié)點(AddBefore
方法)以及在指定節(jié)點的后面插入節(jié)點(AddAfter
方法)。而我們使用 First
屬性獲取鏈表的頭節(jié)點,而 Last
則是尾節(jié)點。注意,這兩個屬性返回的數(shù)據(jù)類型是 LinkedListNode<>
而不是數(shù)據(jù)本身,因此需要再次對該節(jié)點實例使用 Value
屬性才可以得到最終內(nèi)的數(shù)值。
順帶一說。.NET 里的
LinkedList<>
是雙向循環(huán)鏈表,不是單向鏈表,也不是雙向非循環(huán)鏈表。
1-3 Stack<>
棧
如果你想要完成數(shù)據(jù)結構里的棧,那么很高興的是,.NET 提供了該數(shù)據(jù)結構的泛型版本:Stack<>
。
使用 Push
和 Pop
方法可以入棧(壓棧)元素和出棧(彈棧)元素,而使用 Peek
方法則可以在不彈棧的情況下獲取棧頂?shù)脑亍?/span>
1-4 Queue<>
隊列
同理,既然都有棧了,自然少不了隊列。.NET 里也有隊列集合 Queue<>
。
我們使用 Enqueue
入隊元素,使用 Dequeue
注意隊列和棧的存儲機制的不同。隊列是先入先出表(FIFO),因此先進去的元素會先被讀取出來;那么出隊的操作和入隊操作并不在同一個方向,因此 Peek
方法的結果是 3,因為 Dequque
方法出隊的是第一個入隊元素 1,因此該集合現(xiàn)在只剩下 3、6、10 三個元素。
1-5 Dictionary<,>
哈希表
哈希表是一個極為復雜的數(shù)據(jù)結構。在學習數(shù)據(jù)結構的時候很多人就會因為這個數(shù)據(jù)結構的理論復雜性而被勸退。還記得哈希碼嗎?是的,哈希碼就是標識同一種數(shù)據(jù)類型的實例的唯一編碼。它好比身份證編號,以保證實例的不一致。
和 Equals
方法的思路一致,它們都是用來比較相等性的,可 Equals
是嚴格判斷,這導致有些時候速度會很慢。哈希碼可以通過某個稍微簡單一些的公式得到結果。那么同一個數(shù)據(jù)類型的實例自然就會使用同一套計算規(guī)則得到兩個哈希碼數(shù)值。如果哈希碼數(shù)值一致,我們就可以認為兩個實例的數(shù)值是相同的了。
雖然,有些時候哈希碼是自己實現(xiàn)的,也避免不了極端情況(哈希碼相同但實例包含了不同的數(shù)值),但按照規(guī)范去實現(xiàn),往往這樣的情況遇到的概率極低,是人類可以接受的情況。因此,哈希碼機制就保留下來了。
下面要說的哈希表,其實就是利用 GetHashCode
方法來計算對象的數(shù)值,來達到例如對象去重之類的、對對象同一性非常敏感的操作的一種數(shù)據(jù)類型。
要說 Dictionary<,>
的使用方式的話,它其實跟 List<>
也差不太多,也是使用 Add
追加元素,Remove
刪除元素。但請注意的是,哈希表是存儲的一對一對的數(shù)據(jù),而不是一個一個的數(shù)據(jù),這也是為什么,這個數(shù)據(jù)類型的英語單詞用的是 dictionary——因為我們需要按照自定義的索引規(guī)則去獲取里面存儲的元素,這個搜索的過程和查字典的操作相當類似,根據(jù)筆畫、讀音等信息找尋到對應的漢字。
比如這樣的例子。請注意,Dictionary<,>
數(shù)據(jù)類型是兩個泛型參數(shù)的數(shù)據(jù)類型,就跟你根據(jù)拼音查找漢字一樣,拼音是一個數(shù)據(jù)類型,漢字是一個數(shù)據(jù)類型,因此對應到編程的概念里可以類比成 Dictionary<拼音, 漢字>
的感覺:第一個泛型參數(shù)表示索引項目到底是什么類型,而第二個泛型參數(shù)才是對應的取值信息。另外,我們把 Add
方法的第一個參數(shù)(它的類型對應到的是第一個泛型參數(shù))我們稱為鍵(Key),而第二個參數(shù)(它的類型則對應第二個泛型參數(shù))我們則稱為值(Value)。也就是說,Add
方法在調(diào)用的時候,'c'
、'a'
和 'A'
我們都稱為鍵,而 99
、97
和 65
我們則稱為值。
不過請你注意一點。字典集合的 Add
操作,鍵是不能重復的。你想想,你查字典的時候,不論是按筆畫還是按拼音去查找,至少筆畫和拼音是不相同的吧,雖然每一個筆畫項或拼音項對應了多個漢字。是的,Dictionary<,>
的鍵也是必須保證全局唯一性:Add
方法調(diào)用多次的時候,必須保證每一次調(diào)用傳入的第一個參數(shù)都是互不相同的數(shù)據(jù)才行。那么,如果我故意或無意傳入相同的鍵呢?當然是拋異常啦。恭喜你 100% 獲得 InvalidOperationException
異常實例一枚。
而索引器,也是針對于鍵來取值的,因此索引器運算符 []
里寫的是鍵的信息。如果找不到這個鍵的話,就好比你查字典沒有對應的拼音和筆畫一樣,那么自然就會拋異常了。是的,你將 100% 獲得 KeyNotFoundException
異常實例一枚。
1-6 ArraySegment<>
數(shù)組片段
如果你想要獲取數(shù)組集合的一小段數(shù)據(jù)的話,你可以使用 ArraySegment<>
集合來完成。操作很簡單,new
一下就完事了。
我們使用 new ArraySegment<int>(數(shù)組, 從哪個索引開始, 取多少元素)
來完成。比如 arr, 2, 6
三個參數(shù)分別對應從 arr
獲取 6 個元素出來,從第三個元素開始(注意傳參的 2 是表示索引位置是 2,索引是從 0 開始的,所以是第三個元素)。
然后,我們使用 Count
屬性獲取取出來的數(shù)據(jù)片段的元素總數(shù)(6 個),然后遍歷序列。使用索引器來獲取取出來的元素。注意,這里的 ArraySegment<>
類型里的索引器,索引又從 0 開始了,即使你并非從 0 索引開始截取的數(shù)組片段。所以,這個代碼里的 i
從 0 到 5 就恰好對應了原數(shù)組的 arr[2]
到 arr[6]
這 6 個元素。
另外,ArraySegment<>
數(shù)組片段一般用于一維數(shù)組。也就是說,如果是二維數(shù)組甚至更高維度的數(shù)組,就不要用這個類型了。
1-7 PriorityQueue<,>
優(yōu)先級隊列
.NET 6 里誕生了一個新的數(shù)據(jù)類型,叫 PriorityQueue<,>
。該數(shù)據(jù)類型和隊列基本上是差不多的,因為它的底層實現(xiàn)就是一個隊列。不過,PriorityQueue<,>
用到了兩個泛型參數(shù)。其中第一個泛型參數(shù)表示隊列里的每一個元素的類型,而第二個泛型參數(shù)則表示的是優(yōu)先級。
優(yōu)先級隊列用于一些復雜的處理過程里,它將一系列的操作存儲到優(yōu)先級隊列之中,并按照指定的優(yōu)先級(第二個泛型參數(shù)給出的這個類型)來排序,并抉擇到底什么內(nèi)容應該優(yōu)先執(zhí)行。那么,優(yōu)先選擇出來的(優(yōu)先級高的)會被提出來先出隊。這個是優(yōu)先級隊列的用法。
不過,對于 C# 語法層面來說很少用到,這里就做一個科普吧,提一嘴。它一般用在很多復雜的多線程處理過程里,比如消息隊列等等。
Part 2 泛型接口
下面我們來說說泛型接口。泛型的接口一般多從普通的接口類型上進行拓展,因此多數(shù)都是我們前文講解過的知識點,只是它變?yōu)榉盒土T了。
2-1 IEquatable<>
接口
還記得之前說的東西嗎?
IEquatable
接口是不存在的。因為按照設計規(guī)則,IEquatable
非泛型接口應當包含一個這樣的方法:
但可以從這樣的設計上看出問題:這個 Equals
方法在最終基類型 object
里就已經(jīng)自帶。所以你設計的所有數(shù)據(jù)類型均全部從 object
類型里派生。不論你是不是重寫了 Equals
方法,該類型都具備 Equals
方法。因此,該接口類型存在與否都沒有任何區(qū)別,所以,.NET 體系里是沒有 IEquatable
接口的。
但是,泛型接口 IEquatable<>
是否可有可無呢?按照這樣的設計規(guī)則,我們應該是這樣的:
可以從這里看到,因為參數(shù)類型從 object
改成了 T
,而 T
類型顯然就不是 object
,因此是構成重載的。重載意味著兩個方法即使方法名相同,也可以共存。正是因為如此,這個接口就有意義了:因為你 override
掉的是 Equals(object)
這樣簽名的方法,而 Equals(T)
并不存在于你的代碼里,因此這樣的接口是具有約束性的。實際上,.NET 也確實是這樣設計 API 的。
因此,要想強約束一個類型必須包含強類型的 Equals
方法,那么就速速實現(xiàn) IEquatable<>
接口吧!
如代碼所示,這樣是一種實現(xiàn)的大概的代碼寫法。既然你都從 IEquatable<>
接口派生了,那么就相當于說明了類型肯定是具備相等判斷規(guī)則的類型。那么既然如此,你就沒有理由不一起重寫掉 Equals(object)
這個不起眼的比較方法。然后,順帶重載掉運算符 ==
和 !=
,以后寫代碼的時候就方便多了。
是的,雖然看似無用的代碼,但是這么寫有一個好處在于,兩種情況的 Equals
都有可能被調(diào)用到,一種是模糊類型校驗,一種是具體類型校驗,它們各有各的好處。
2-2 IComparable<>
接口
之前學習了 IComparable
接口,那么對應的泛型版本自然就是 IComparable<>
接口了。不過,因為原始的接口類型傳入的參數(shù)類型是 object
,因此相當不方便?,F(xiàn)在我們有了泛型接口 IComparable<>
后,就可以完美代替掉原來的這個接口類型了。
和 IEquatable<>
接口不同,由于 IEquatable<>
接口不存在非泛型版本,因此設計上完全不影響;但 IComparable
非泛型接口包含的方法 CompareTo(object)
并非 object
自帶的方法,因此這個非泛型接口類型設計起來是有必要的。emmm……起碼,在非泛型的時代,是有必要的。而在后期,泛型時代來臨,這些非泛型版本就得拋棄了。.NET 在設計 IComparable<>
接口的時候,是這么設計的:
object
參數(shù)類型改成了 T
如代碼所示,我們多寫一個 CompareTo
方法,替換掉 object
參數(shù)類型的版本即可。然后重載掉運算符 >=
、<=
、>
和 <
,這樣使用起來更方便一些。
2-3 IEqualityComparer<>
接口
還記得我們以前怎么實現(xiàn)的嗎?我們之前用的是一個方法,傳入的是 object[]
來搞定的。然后再對每一個元素比較,使用非泛型版本的那個接口類型。既然我們有了泛型版本了,那么代碼就可以改了。
object
都可以替代成 T
了。然后,我們就可以開始調(diào)用了。倘若還是用以前的 Student
IEqualityComparer<>
是的,清爽多了。這次有了具體類型,我們就不必做那些類型判斷了。
最后,我們開始調(diào)用那個方法。
是的,這次執(zhí)行結果是完全一致的,不過這次要效率高一些,因為沒有冗余的類型判斷,沒有那些莫名其妙的類型轉換機制和操作。
2-4 IComparer<>
接口
是的,這個和非泛型版本也形成了對比。因此我們還是來講一下實現(xiàn)即可。
然后,執(zhí)行排序。
是的,多簡單。
2-5 ICollection<>
和 IReadOnlyCollection<>
接口
要說接口的作用,那么……還記得接口的作用嗎?接口的作用是為了起到成員實現(xiàn)的限制作用,為了能夠讓你能實現(xiàn)這些成員,接口就把你需要實現(xiàn)的成員以名字的形式列舉出來,然后你要想加上 : 接口
的語法,就必須實現(xiàn) 接口
類型里的所有成員。從另外一個角度來說,既然你已經(jīng)實現(xiàn)了該接口,那么基本上就可以認定你能夠做到接口本應該抽象體現(xiàn)出來的事物的基本功能和作用了。
那么,要想了解 ICollection<>
接口,那么必須要看懂這個單詞。collection 這個單詞在編程里是“集合”的意思。所謂的“集合”,就是說明一個數(shù)據(jù)類型,它專門實現(xiàn)出來存儲元素的數(shù)據(jù)信息。而且存儲的數(shù)據(jù)信息還得是一系列的數(shù)據(jù),而不是一個單獨的數(shù)據(jù)。我們看一下 ICollection<>
集合的接口內(nèi)成員都有哪些。
一共 7 個成員,兩個屬性、5 個方法。它們的含義分別是這樣的:
Count
屬性:表示集合多少元素;IsReadOnly
屬性:表示集合是不是只讀的(就是說,是不是集合在初始化之后就永不改變里面的數(shù)值,只用來讀取了);Add
方法:往集合追加一個元素進去;Clear
方法:表示將集合的所有已經(jīng)存儲進去的元素全部清除掉;CopyTo
方法:表示將這個集合里的每一個元素往參數(shù)array
里拷貝,就是復制一份副本到參數(shù)這個數(shù)組里去。arrayIndex
表示從第幾個元素開始拷貝;Remove
方法:表示刪除、移除集合里指定數(shù)值的元素。
可以看出,它們都跟增刪改查相關。雖然查找集合序列在這里沒有提及,但 Remove
方法傳入的參數(shù)要一定能從集合里刪除,自然肯定要求底層實現(xiàn)得比較數(shù)據(jù)是不是一樣。那么必然會調(diào)用一些方法,例如 Equals
方法等成員來判別數(shù)據(jù)是否一致,那么自然就相當于是在查找元素了。那么,增刪改查都有了:增加元素、刪除元素、改變數(shù)據(jù)(Clear
清零)、查找數(shù)據(jù)。這就是 ICollection<>
泛型接口的基本用法。
同理,IReadOnlyCollection<>
接口的名字里帶有 read only 一詞的,因此它和 ICollection<>
接口的使用場景的不同在于是不是表示集合只讀。
再次查看 IReadOnlyCollection<>
接口的內(nèi)容,可以發(fā)現(xiàn)它只有一個成員。
Count
屬性了。如果一個集合包含簽名一致的 Count
IReadOnlyCollection<>
接口,那么就說明你該類型只讀了;當然這個是字面意思。如果你在使用的時候,因為多態(tài)性導致你該類型的接收方是用的接口來接收的,說明該集合現(xiàn)在只讀了,因此你僅能使用里面的Count
屬性,以及foreach
循環(huán)(foreach
循環(huán)綁定上的是IReadOnlyCollection<>
接口的基接口IEnumerable<>
和IEnumerable
的行為,這個我們稍后說明)。
另外稍微需要你注意的是,ICollection<>
和 IReadOnlyCollection<>
接口是不共通的,因為它們各自派生的關系上,并沒有用到“其中一個接口是另外一個接口的基接口”的情況。因此,它們倆是不共通的。也就是說,你不能把一個 ICollection<>
接口類型的對象賦值給 IReadOnlyCollection<>
接口類型作為接收類型;反之亦然。
可以看到,這兩個接口類型,全部都從 IEnumerable<>
和 IEnumerable
接口派生,但這兩個接口都是什么呢?下面我們就來討論一下。
2-6 IEnumerable<>
和 IEnumerator<>
接口
實際上,這個接口也沒什么好講的。因為在前面基本上也都說過了。在之前講解接口良構類型的時候就說過該接口的用法,并且提到過這樣的內(nèi)容:
“如果一個數(shù)據(jù)類型實現(xiàn)了該接口里面的成員信息的話,我們就可以認為這個接口是可以使用 foreach
循環(huán)的?!?/span>
不過問題在于,foreach
的迭代變量的數(shù)據(jù)類型上。由于早期的接口 IEnumerable
是不帶有泛型的,因此它迭代的每一個元素都會自動被關聯(lián)為 object
。也就是說,它基本上在用的過程都等效于這樣的語法:
然后,我們簡單還提過一句。object element
可以替換掉 object
,改成你的具體的類型。因此基本上也算是方便了,因為我們期望迭代元素也就只需要讓它能夠寫代碼看起來更加“優(yōu)雅”,而允許 object
自動換成具體的類型這一點來說,就算是比較方便了。
int
的,結果我又不想去自己實現(xiàn)一個“鴨子類型”來完成成員的具體類型的迭代過程(畢竟,太復雜了),那么我們只能接受裝箱拆箱的操作。還記得鴨子類型吧。鴨子類型說的是一個數(shù)據(jù)類型,一旦滿足一定的條件,即使它不實現(xiàn)接口,也能做一些接口才能做的事情,因為它已經(jīng)被當成能做這個事情的類型了。
C# 2 帶來了接口,就引入了 IEnumerable<>
泛型版本的該接口。于是,它實現(xiàn)起來就比起原本的 IEnumerable
要更好,因為它是泛型的,也就意味著頻繁裝箱拆箱的時代結束了。是的,它的用法基本上和 IEnumerable
沒有任何區(qū)別,唯一的、也是最方便的好處就是它避免了原來類型的 Current
屬性是 object
類型而會導致隱式的裝箱拆箱行為。同時地,在 IEnumerator
接口里,Current
屬性原本是 object
類型的,那么我們想要改成自己的一個具體類型的話,只需要加上泛型參數(shù)的實際類型,就可以了。這樣省得你自己實現(xiàn)具體類型來避免復雜的迭代內(nèi)部機制的實現(xiàn)。
2-7 IList<>
和 IReadOnlyList<>
接口
要想說清楚這個接口類型,我們必須回去看看 List<>
泛型列表類型。這個 List<>
類型包含了眾多的成員,比如 Add
方法啊、Remove
方法啊、Count
屬性之類的。但是,這些方法在實現(xiàn)期間,其實背后是有一個接口約束的。是的,這個接口就是 IList<>
。是的,list 單詞的意思是“列表”,因此實現(xiàn)了接口就等同于表示這個自定義的數(shù)據(jù)類型可以做到一個列表該做的基本功能(增刪改查什么的)。
我們來看看 IList<>
接口的基本定義吧。
是的,它里面帶有四個成員:索引器,IndexOf
方法、Insert
方法和 RemoveAt
方法。而它從 ICollection<>
、IEnumerable<>
和 IEnumerable
接口派生。其中 ICollection<>
接口有點“大”,因為它里面的成員非常多,一共有 7 個(前面介紹了),而該 IList<>
接口又從 ICollection<>
派生,就意味著你在實現(xiàn)一個集合,從 IList<>
的時候要順帶也把 ICollection<>
里的成員都給實現(xiàn)了。
同理,既然 ICollection<>
都有只讀版的接口 IReadOnlyCollection<>
接口,那么 IList<>
也有對應的只讀版本的接口類型:IReadOnlyList<>
。不過,這個接口長這樣:
是的,它走 IReadOnlyCollection<>
接口派生,然后里面包含的是索引器。這意味著,如果你使用的是 IReadOnlyList<>
作為接收類型的話,那么這個類型可以用的三個操作自然就是 foreach
循環(huán)、Count
屬性以及索引器了。
2-8 IDictionary<,>
和 IReadOnlyDictionary<>
接口
和 IList<>
以及 IReadOnlyList<>
是一樣的存在,這兩個接口是 Dictionary<,>
接口的抽象。我們來大概看看這個接口類型里都有一些什么,就可以了。
Keys
和 Values
屬性,以及 ContainsKey
和 TryGetValue
方法,是它們兩個接口都有的成員。其中:
索引器:獲取指定鍵的對應值是什么;
Keys
屬性:獲取整個字典序列里的所有已經(jīng)存儲進去的鍵,作為一個集合返回出來;Values
屬性:獲取整個字典序列里的所有已經(jīng)存儲進去的值,作為一個集合返回出來;Add
方法:追加一個鍵值對的數(shù)據(jù)進去,到集合里;ContainsKey
方法:查找字典里是不是包含指定的鍵;Remove
方法:刪除字典里指定鍵的鍵值對信息;TryGetValue
方法:嘗試去獲取指定鍵的對應數(shù)值信息。如果字典里沒有這個鍵的存儲,就返回false
;否則返回true
,并把結果從value
參數(shù)返回出來。
這個接口也沒啥好說的,因為很少我們會自己實現(xiàn)一個集合去滿足里面的成員。所以細節(jié)上就不多說了。
Part 3 泛型委托
最后,我們來說說泛型委托的內(nèi)容。是的,委托類型也有泛型的 API 提供。而且它們用得相當廣泛。
3-1 Action
和 Func
系列泛型委托
下面我們來說一下 Action
和 Func
系列委托。為什么說是系列呢?是因為 Action
和 Func
并不是單個委托類型,而是包含泛型類型的重載版本。
還記得之前學習的委托類型的用法嗎?委托類型定義了具體的類型和返回值后,只要簽名一樣, 就可以使用 new 委托
的方式把方法賦值過去。不過,如果定義的參數(shù)和返回值有泛型參數(shù)怎么辦呢?是的,這就是我們說的泛型委托的一種特殊用法。
舉個例子,假設一般的定義是這樣的:
這表示一個無參無返回值的委托類型。如果我們替換掉返回值類型:
可以看到,這次我們將返回值替換為了一個泛型參數(shù)。這個情況我們就稱為泛型委托類型。而 Action
和 Func
就是如此的泛型委托類型。
3-1-1 Action
系列委托
先來說 Action
系列委托。Action
系列委托一共是 17 個重載版本。長相是這樣的:
數(shù)數(shù)看,是不是 17 個。一個非泛型版本,16 個帶泛型參數(shù)的版本。泛型參數(shù)因為是等效的,因此泛型參數(shù)的重載只存在個數(shù)不同的重載規(guī)則。并且請注意,這 17 個委托類型都是 void
返回值,因此它們都不接受任何返回值類型。
舉個例子,假設我有一個方法 Sort
:
如果我們想要使用委托類型的話,可以這么寫代碼:
可能你很少見到,int[]
當泛型參數(shù)的實際類型的。實際上,C# 允許這么做。因為它也是 Array
的派生類型,而 int[]
也只是特殊記號罷了,所以沒有道理不允許這么寫。
可以從這里看到,這種代碼寫起來相當方便了。因為委托類型就可以省去很多次的委托類型聲明的語句。比如我們經(jīng)常定義一些奇怪的委托類型?,F(xiàn)在有了 Action
的系列委托,只要你的方法的參數(shù)少于或等于 16 個,都可以直接使用這個東西來完成,就……很方便。
用法和普通的委托類型用法是一樣的:Invoke
調(diào)用即可。
3-1-2 Func
系列委托
前面的委托類型并不能解決返回值不空的情況,因此 .NET 也提供了自定義返回值類型的委托類型,叫 Func
。它包含 16 個重載的版本,長這樣:
這樣就可以解決返回值類型的問題了。再舉個例子,加減乘除。
用法也是一樣的。
3-1-3 Action
和 Func
系列委托的缺陷
雖然它可以替代絕大多數(shù)我們用得到的委托類型的定義,但仍然有時候無法替代。比如說,參數(shù)帶有修飾符。比如說:
由于泛型參數(shù)帶有 T
修飾符,因此我們無法替換為 Action
或 Func
系列委托,因為定義里的所有帶參數(shù)的情況也都沒有 ref
修飾的情況。這種情況下,委托類型只能自己定義。
3-2 Predicate<>
謂詞
雖然,Func
和 Action
系列委托類型基本上能解決大多數(shù)時候的問題,但有些時候,我們直接使用 Action
和 Func
系列委托也不方便,因為寫起來有些長。于是,C# 派生出了兩個特殊的委托類型,這樣用起來方便。一個是 Predicate<>
,另外一個是稍后介紹的 Comparison<>
。
Predicate<T>
委托類型只帶一個泛型參數(shù) T
,而它的簽名基本等價于 Func<T, bool>
,即一個方法帶有一個參數(shù)和一個返回值,參數(shù)可替代為任何的類型,而返回值是 bool
類型。從這個簽名可以看出這個 bool
就表示一個條件結果。這就是為什么,這個委托類型被一些資料上稱為“謂詞”。“謂詞”這個說法來自于邏輯學,它指的是一個句子里的謂語動詞,并且整句話能作出判斷的情況。那么抽象為編程語言,不就是一個語句,執(zhí)行出來的結果是 bool
結果嗎?
用法很簡單,因為它和 Func<T, bool>
是一個意思,因此當它這么用就行。有些 API 就會用到這個委托類型,比如前文介紹的 List<>
列表類型,里面自帶了一個叫做 FindAll
方法,它用來找到整個列表里所有滿足指定條件的元素。既然要找到滿足條件的,那么自然就得把每一個元素挨個迭代一次,然后判斷條件,然后條件為 true
,就記錄到結果里嗎?所以,它用 Predicate<>
充當條件部分就相當合適。事實上,.NET 也確實是這么設計的:
在使用的時候,可以這么做:
可以從第 6 行代碼看到,它需要一個參數(shù),正是這里的 Predicate<>
類型。而為什么這里沒有寫泛型參數(shù)部分呢?因為 IsOdd
方法是帶有 int
參數(shù)的方法,可以從方法本身推斷和暗示 Predicate<int>
是合適的實例化情況。因此,編譯器允許我們省略 <int>
泛型參數(shù)部分。
3-3 Comparison<>
比較器
和謂詞委托相似的,還有比較器對象。Comparison<T>
委托類型的簽名基本等價于 Func<T, T, int>
,也就是傳入兩個 T
類型的參數(shù),并返回 int
結果。試想一下,什么樣的時候,會用到這個情況?
是的,CompareTo
方法的類似邏輯。要想比較兩個對象,然后比較出一個大小,是不是就得這么搞???因為部分的數(shù)據(jù)類型是不支持運算符 >=
這類重載的,因此我們并非所有時候都可以這么簡單使用比較操作。于是,我們有了比較器委托后,就可以簡略很多代碼了。
和 Predicate<>
委托類型一樣,它也在 .NET 的系統(tǒng)自帶 API 里就有所使用和體現(xiàn)。比如 Array
類型(所有數(shù)組的基類型)就包含一個方法,叫 Sort
。它的簽名是這樣的:
是的,兩個參數(shù),沒有返回值。第一個參數(shù)肯定是數(shù)組本身了,因為 Array
類型里基本帶的都是靜態(tài)的成員(當然, Length
屬性就是實例成員,但這樣的成員很少),所以要執(zhí)行操作,比如優(yōu)先考慮把數(shù)據(jù)給傳入到方法里,那么自然就需要占用一個參數(shù)的名額來完成;而第二個參數(shù)就是我們這里所說的比較器委托類型對象了。
用法也很簡單??紤]對字符串排序。我們可以定義字符串的比較方式為比較字符串的長度(為了例子簡單一些,我們這里暫時不考慮比較 ASCII 碼等內(nèi)容)。于是我們可以這么寫代碼:
注意第 11 行代碼,我們需要的兩個參數(shù)是這么寫的。其中第二個參數(shù)我們傳入委托類型的對象,所以需要實例化;帶有泛型的時候,需要同時在實例化的時候傳入合適的泛型參數(shù)。
Part 4 其它問題
下面針對于前文沒有提到的內(nèi)容進行一個問題解答,或者補充。
4-1 沒有泛型特性
是的,你壓根就沒有看錯。我們大家都知道,整個 C# 的派生體系非常龐大,里面還包含了別的數(shù)據(jù)類型,比如特性。雖說特性寫法跟類也沒啥區(qū)別,但是,特性奇怪的點在于,它雖然是個普通的類的實現(xiàn),從 Attribute
抽象類派生,但它并不能是泛型的。
這挺奇怪的。既然是一個普通的類類型,那么為啥它不能是泛型的呢?原因在于,特性在運行期間是作為元數(shù)據(jù)存儲的。還記得元數(shù)據(jù)的基本概念吧。元數(shù)據(jù)指的是一種構建整個程序運行的基本數(shù)據(jù)信息。它們被放在一個特殊的地方,受程序初始化的時候自動初始化,并且永不可修改。
是的,這些數(shù)據(jù)都是實體的數(shù)據(jù)類型(什么必須是 Type
類型啊、基本的內(nèi)置類型啊、一維數(shù)組類型之類的),設定這種限制很明顯是因為,它們是可以在運行前通過編譯器自動計算到指定的數(shù)據(jù)以及存儲的內(nèi)存空間大小,并且丟進元數(shù)據(jù)的存儲內(nèi)存區(qū)域里的。正是因為它們是預先就可以被編譯器處理掉,因此泛型是不允許的:因為泛型受到運行時管理。換句話說,泛型得等到運行時期才可以確定具體的存儲機制(比如內(nèi)存占多大啊,數(shù)值是多少什么的)。所以,泛型在特性里是不允許存在的。
不過,這一點將在 C# 10 里被打破。是的,從 C# 10 開始,你就可以使用泛型特性機制來完成一些奇特的操作了,不過,這一點得等到后面去說。而且,C# 10 的對應 .NET 運行環(huán)境比較高(.NET 6),因此如果你使用的是舊版本的 .NET 框架,說不定就不可以使用這種語法機制了:因為泛型特性除了是語法要支持以外,還得運行環(huán)境自身支持才行。這種新語法就不能隨便引用到項目里,否則你會直接預先收到一條編譯器錯誤信息,告訴你,這樣的程序無法編譯,因為運行時就不支持。
4-2 數(shù)組的接口實現(xiàn)的奇怪現(xiàn)象
IList<>
、IReadOnlyList<>
、ICollection<>
和 IReadOnlyCollection<>
接口是用來表示一個集合的,只是細節(jié)不同。比如說 IList<>
和 IReadOnlyList<>
是表示集合的可列舉性,而 ICollection<>
和 IReadOnlyCollection<>
則更側重于集合的基本實現(xiàn)標準和規(guī)范。
那么,數(shù)組呢?數(shù)組難道就不是集合了?數(shù)組也是集合啊,數(shù)組也是可列舉的啊。那么自然,一個數(shù)組類型也應當實現(xiàn)這些接口類型。可問題來了。ICollection<>
接口里包含了一些比如 Add
、Remove
方法的成員,用來增刪數(shù)據(jù)。可數(shù)組呢?數(shù)組是不能增刪的,數(shù)組只能改變里面的數(shù)值,以及查找數(shù)值。那么這個實現(xiàn)機制豈不是太奇怪了?
問題很好。這個現(xiàn)象我們先給出結論吧。數(shù)組實現(xiàn)了這些接口類型,也意味著你可以直接這么寫代碼:
這里的
Array.Empty<int>()
方法是一個泛型方法。它提供一個空數(shù)組。換句話說,它調(diào)用后會產(chǎn)生一個類似于new int[0]
的數(shù)組,即沒有任何元素的數(shù)組,Length
屬性返回 0。這個方法是泛型方法,意味著你需要傳入一個泛型參數(shù)進去,比如這里的int
傳入進去,返回的就是new int[0]
類似的結果;如果是別的數(shù)據(jù)類型,例如表示為T
的話,那么結果就對應了new T[0]
里的這個T
。這個方法比
new T[0]
直接寫要高效,因此我們永遠都建議你使用Array.Empty<T>
泛型方法來代替掉new T[0]
語法。
不過,請勿調(diào)用這些接口里有關增刪數(shù)據(jù)的成員,因為它們會導致程序在運行時期拋出 NotSupportedException
異常,告訴你這個集合并不支持這個方法,畢竟,數(shù)組并不支持增刪操作。是的,僅是拋異常而已。
至此,我們就把泛型給全部說完了。當然,泛型的水很深,這一點內(nèi)容還不足以說明清楚更深層次的內(nèi)容,但它們已經(jīng)不屬于教程考慮和討論的范疇了。如果有興趣的話,可以參考《CLR Via C#》之類的書籍,來學習有關泛型的底層實現(xiàn)機制。當然,我也不是一定不考慮講這些內(nèi)容。我只是說本教程不考慮這些。說不定我以后還出一些比如專講底層機制的系列教程呢?