第 53 講:類型良構規(guī)范(三):比較相關接口
今天我們來說一些跟比較有關的接口類型。
Part 1 IComparable
接口:用于大小比較
這個接口里帶有一個 CompareTo
的方法,返回一個 int
類型的結果。這個方法是用一個整數(shù)表示兩個對象哪個更大。這個所謂的大小,是我們通過定義得到的。比如說 int
類型里自帶 CompareTo
方法表示哪個整數(shù)數(shù)值比較大,而 string
類型里的 CompareTo
方法則表示哪個字符串的字典序更大。
那么,大小是通過人為定義得到的,所以如果這個類型需要比較大小,那么就有必要實現(xiàn)此接口類型。假設我現(xiàn)在有一個學生類型 Student
,它使用學號比較大小。那么我們可以這么去設計數(shù)據(jù)結構。
通過如上的代碼我們可以看到,基本的調(diào)用方法多出來了 CompareTo
方法(一個是 object
參數(shù)的方法,一個則是 Student
本類型的參數(shù)的方法)。因為實現(xiàn)和前面 Equals
的模式是差不多的,所以這里也是兩個同名的重載方法放在一起。
不過,因為這個方法是以 _id
字段作為比較手段的,別的成員我們并未參與比較,因此我們完全不必管它。因為,既然學號都一樣了,那么這兩個對象肯定是同一個人才對。如果別的數(shù)據(jù)不同了,那就成了靈異事件了不是。所以,只需要比較學號即可。
然后,我們又多了四個運算符的重載。運算符重載前面講得很水,因為看懂的話確實不太難。這里多出來的是 >
、<
、>=
和 <=
四個運算符的使用。如果學號較小的,就說明這個學生“較小”;如果學號較大,那么我們就認為學生“較大”。
這個學生“較小”和“較大”一般用在哪里呢?排序啊。要是我要對一個學生進行排序,我們自然肯定會考慮到使用排序算法,對吧。還是為了簡單,我們就使用冒泡排序法就可以了:
這里我們可能稍微啰嗦一點,說明一下這里調(diào)用復制構造器的拷貝原則,以及原來這個數(shù)據(jù)類型里這個字符串拷貝的原則。
引用類型所有的類型都是拷貝引用(說白了底層就是拷貝的地址,所以兩個對象的引用相同就意味著兩個對象指向同一塊內(nèi)存空間)。但是字符串是這里的唯一一個引用類型對象,所以復制的是引用。為了避免這一點,我們這里可能需要拷貝字符串的內(nèi)容,使得兩個字符串完全獨立開來。
然后懂了這一點之后,我們再來看復制構造器就輕松多了。之前有簡單說過一點,只要一個構造器的參數(shù)就是這個類型本身的話,那么這個構造器我們稱為復制構造器或者復制構造函數(shù),它的實現(xiàn)必須遵守復制副本的原則來完成,比如我們剛才實現(xiàn)的 Student
的這個復制構造器這樣:
可以從代碼看出,它實際上就是把里面的四個字段的數(shù)據(jù)全提出來,然后按次序傳遞過去,調(diào)用了同類型的另外一個構造器(帶四個參數(shù)的那個),構成了構造器串聯(lián)的效果。所以它實際上就相當于四個字段挨個用賦值運算符賦值的過程。不過其中 Gender
是結構(值類型)、int
也是值類型,而 _id
和 _name
都是 string
類型的,它們是引用類型。但剛才說過,字符串是唯一一個以值傳遞形式賦值的引用類型,因此它的賦值和值類型賦值副本一點區(qū)別都沒有。正是因為如此,最終兩個 Student
對象里的所有成員的地址都不相同,但數(shù)據(jù)是一致的,構成了克隆出來的副本的效果。在排序操作里,我們使用到了這個行為,是為了保證里面的信息完全克隆,不受影響。
順帶一提。請自行思考,如果我把內(nèi)部交換行為改成不要
new
調(diào)用構造器的普通引用傳遞的賦值過程,那結果會如何呢?
好了。這個復制副本的內(nèi)容我們就說到這里。我們回到原來的內(nèi)容里。在這個數(shù)據(jù)類型里,我們使用到了一個語法:students[j] > students[j + 1]
。這里我們知道,運算符 >
的左右兩側都是 Student
類型的對象,這正好對應上了我們自己重載的運算符。這就是重載運算符的好事:讓代碼更具可讀性;當然,因為這種調(diào)用機制的關系,你也可以使用 CompareTo
方法來完成這個行為:
如果我們實現(xiàn)了 CompareTo
方法(參數(shù)是 object
類型的,返回 int
Part 3 IEqualityComparer
接口:泛化相等性比較
泛化任何數(shù)據(jù)類型的校驗和操作。
舉個例子,我們想要設計一個 Compare
方法來比較兩個任何數(shù)據(jù)類型的對象的大小關系。但問題在于,有些類型是系統(tǒng)自己規(guī)定好的類型,但它們有些時候不一定滿足我們的不等性和相等性比較,比如 object
。object
這個類型可以給所有類型提供派生,那么它作為方法的參數(shù)的類型使用的話,自然就表示“任何數(shù)據(jù)類型都可以傳入”,那么泛化的目標就算完成了一小半了。剩下,我們需要完成比較行為。假設我要完成對任何一個數(shù)據(jù)類型都可傳入,并完成自定義去重的操作,那么顯然必須有一個參數(shù)就得是一個 object[]
類型的對象。
假設我現(xiàn)在有一個數(shù)組,每個元素都是 object
類型的,構成了一個數(shù)組?,F(xiàn)在我們無法從方法內(nèi)部知道這個數(shù)組的每個元素是什么類型,但我知道的是它們每個元素都是同一個數(shù)據(jù)類型。現(xiàn)在我們要對這個數(shù)組去重,把重復的元素給擇(zhai)出去,那么這個類型不知道有沒有重寫 Equals
和 GetHashCode
的話,那么排序直接使用這個方法就會有 bug。因此,我們可能會自己實現(xiàn)一個機制,讓用戶傳入一個自定義的比較相等性的機制來保證兩個對象是不是一樣的數(shù)據(jù)。這樣的話,不管什么類型,只要我們傳入這個用戶自己給的比較機制,那就完全不擔心這個類型本身有沒有實現(xiàn)這些 object
里的方法了。
那么,怎么設計數(shù)據(jù)類型,來讓用戶”自己給出比較方式“還能參與程序使用呢?這就需要借助接口 IEqualityComparer
接口了。我們把這個接口的實例對象當成一個現(xiàn)實生活中的餐館服務生,而相等性比較操作就好比點菜。要參與點菜必須要服務生在前面看著你點,然后幫助你完成點菜的任務。我們這么去完成去重的方法的代碼書寫:
第一個參數(shù)是給的 object[]
類型的數(shù)組對象。用來去重。第二個參數(shù)是 IEqualityComparer
我們在代碼里用到兩層循環(huán),用于去重比較。正常的去重邏輯是,我們遍歷整個序列(指這里的 elements
數(shù)組),然后逐個追加到一個新的列表(指這里的 arrayList
對象)。每次遍歷 elements
的下一個元素的時候,都去比較這個元素是不是已經(jīng)保存進了 arrayList
結果列表里。如果存過一次了,就跳過它,以此達到去重的效果。最終,我們調(diào)用 arrayList.ToArray()
即可直接把列表用 object[]
表達出來,返回此結果即可。
但問題就出在”都去比較這個元素是不是已經(jīng)保存進了 arrayList
結果列表里“這一步。因為你沒辦法知道 object
的具體類型,我們就不可能使用一個合理的比較辦法來表達如何兩個對象保存的數(shù)值是相等的。剛才也說過,Equals
和 GetHashCode
方法因為不知道傳入的類型有沒有重寫,所以可能會導致 bug 產(chǎn)生,所以我們不能用這兩個方法。
這怎么辦呢?用這里傳入的第二個參數(shù)即可。我們把 equalityComparer
參數(shù)當成實例,然后調(diào)用里面帶的這個 GetHashCode
方法。注意這里的 GetHashCode
是一個方法的約束,跟 object.GetHashCode
里的這個 GetHashCode
是兩回事;況且 equalityComparer
實例里帶有的 GetHashCode
方法還得傳入一個參數(shù)。
IEqualityComparer
這個接口類型帶有 GetHashCode
和 Equals
兩個方法。雖然取名和 object
方法里的這兩個方法名是一樣的,但因為需要獨立傳參,所以是無關 object
里的這倆對應方法的。其中:
GetHashCode
方法需要傳入對象本身,這表示調(diào)用這個自己實現(xiàn)的方法來對一個臨時的這個數(shù)據(jù)計算哈希碼;Equals
方法需要傳入兩個同類型的對象,表示兩個對象是否存儲的是同樣的數(shù)據(jù)。
可以從這個介紹文字看出來,它們的具體實現(xiàn)邏輯應該大概如何如何。下面我們假設用一個所謂的 Student
類型的對象來進行排序操作。假設 Student
類型的實現(xiàn)是這樣的:
實現(xiàn)相當簡單。接下來我們給出數(shù)據(jù)進行去重:
請看 Main
方法里的這個數(shù)組。數(shù)組的每一個元素都是 Student
類型,但是整個數(shù)組用的是 new object[]
表示的,這樣是允許的,原因是因為這里是之前說過的對象的多態(tài)。
這 10 個學生從上到下分別是小明、小紅、小明(重復項)、湯姆、杰瑞、托爾、康納、杰瑞(重復項)、平澤唯和中野梓。
下面,我們通過第 18 行的代碼進行數(shù)組去重。第一個參數(shù)傳入的就是這個數(shù)組 elements
,第二個參數(shù)因為要傳入一個 IEqualityComparer
接口類型的對象,但我們這里沒有什么類型實現(xiàn)了這個接口,這怎么辦呢?
自己在外面定義一個類型從這個接口派生即可。這個類型可以是嵌套類型,也可以是放在最外層的類型,只要它從接口派生,就具有了對象從接口多態(tài)轉換類型的效果。
這樣類型就有了。注意它們的實現(xiàn)。這里我們使用了接口的顯式實現(xiàn),這是因為接口的顯式實現(xiàn)可以有效防止別的人使用這個類型進行實例化后直接訪問這兩個”在別的地方?jīng)]有任何用,只用來去重“的方法。
代碼的邏輯相信你應該自己可以看懂。Equals
方法直接是判斷兩個對象 x
和 y
是不是同類型,并且學號是不是一樣就可以了;而 GetHashCode
方法則需要麻煩一點:先判斷對象是不是 Student
類型的,如果是的話,則直接把對象的 Id
屬性(即底層的 _id
字段)的哈希碼作為結果返回即可,因為 string
類型是自己實現(xiàn)了一個 GetHashCode
方法的重寫的,所以我們可以直接使用,而不必去自己寫字符串的哈希碼計算和處理。
這個從接口派生的類型我們就設計好了。下面我們只需要做的一點是,實例化這個類型,傳進去即可。
此時,我們就可以直接把實例化語句寫進 Distinct
這個去重方法的第二個參數(shù)上了。至此,整個去重的操作我們就全部完成了,來看看去重后的結果吧!

可以看到,重復項已經(jīng)被排除掉了。
本節(jié)內(nèi)容就告訴了你如何泛化比較兩個任何數(shù)據(jù)類型是不是一致,使用了 IEqualityComparer
接口來搞定。難度有點大,但希望你慢慢消化它們。多看幾次就明白了。
給出總的代碼。這回代碼有點長。
Part 4 IComparer
接口:泛化大小比較
說完了 IEqualityComparer
接口,我們就來說一下,大小比較的泛化接口類型:IComparer
。這個取名有點詭異,是因為?IComparable
是開頭的固定字母 I
和結尾的 -able 后綴表示“能干什么事情”;而 IEqualityComparer
和 IComparer
卻沒有參照這個取名規(guī)則來,因為假設按照這個取名規(guī)則來說的話,這個接口就改名叫 IComparisonComparer
了,首先是類型名比較長,有點麻煩;其次,名字本身也有冗余的地方,比如 comparer 本來就是比較(比較器)的意思,而 comparison 它也是比較(比較行為)的意思。對于老外來說,你看到一個類型名字叫”比較行為比較器接口“是不是覺得特詭異?如果這個類型是前面這個相等性比較的話,那么類型名叫”相等性比較器“就很合理;但是”比較行為比較器“是個什么鬼東西……
所以,這次這個接口名就沒有再使用這個取名規(guī)則了,而是直接干脆一點,就叫 IComparer
了,雖然名字扯了一堆,但實際上我是想告訴你它雖然名字沒有按”套路“出牌,但是它是泛化大小比較的接口類型。
和前面 Part 3 的內(nèi)容操作完全一致,我們需要單獨為其在外部添加一個類型,實現(xiàn)這個接口;然后在一定的方法里使用此類型作為比較手段,以達到目的。這次我們舉例是泛化 object[]
數(shù)組排序。
很高興的是,C# 直接貼心地給我們提供了排序方法,叫 Array.Sort
。這個方法是一個靜態(tài)方法,需要兩個參數(shù),第一個參數(shù)是數(shù)組本身,第二個參數(shù)則是這里所謂的 IComparer
接口類型對象,因此我們就不必自己寫類似 Distinct
的 Sort
方法了,只需要實現(xiàn)接口即可。
和剛才差不多,我們只需要改掉類型就可以了:
這里 IComparer
接口里只有一個方法需要實現(xiàn):Compare
。這里的方法傳入兩個 object
類型的參數(shù),用于比較。這里,我們直接按 Id
(即底層的 _id
字段)按字符串的默認大小比較行為(字典序)比較大小即可,我們就不必自己去寫代碼實現(xiàn)了。
最后,我們試著來排序。
這么寫就可以了。方便吧。
因為數(shù)據(jù)是一樣的,我們就不再重復寫一遍了。下面我們來看輸出結果。

這次我們沒有去重,因為是直接排序的,所以去重那段代碼就沒有要了;可以看到,這次排序就是合適的,我們使用 IComparer
接口里的 Compare
方法作為比較方式,對 Student
類型的元素的數(shù)組來進行排序。
Part 5 題外話:數(shù)組協(xié)變
上面我們就把這幾個接口的用法給大家介紹了一下。下面我們來說一個剛才沒有講的 bug 語法:數(shù)組協(xié)變。
數(shù)組協(xié)變語法是這樣的:
是的,你沒有看錯。右側是 new Student[5]
,而左邊是 object[]
類型接收的。這個語法是可行的,這種語法機制稱為數(shù)組協(xié)變(Array Covariance)。數(shù)組協(xié)變會帶來很麻煩的錯誤。正是因為有這樣的錯誤,所以我在前面的代碼實現(xiàn)里,沒有使用這個語法機制。
數(shù)組協(xié)變允許我們把一個元素為某個具體類型的數(shù)組直接賦值給一個模糊類型的數(shù)組。按道理來說,這是合理的,因為顯然每個元素就是正常的賦值過去(多態(tài));但我們?nèi)绻胍淖兤渲心硞€元素的數(shù)值,并改成別的數(shù)據(jù)類型的數(shù)據(jù)的話:
比如這個語法。我們實例化的是 object[]
類型的數(shù)組,但我嘗試對其中第一個元素賦值 3M
這個 decimal
類型的字面量。顯然,因為元素是 object
類型的話,接收 decimal
類型就應該是可以成功的。但是,這改變了數(shù)組原始的數(shù)據(jù)類型:因為原始數(shù)組是 int[]
類型的,你賦值為 decimal
類型進去就導致了數(shù)據(jù)類型的 bug。因此,C# 選擇以拋出異常中斷程序來告訴用戶這么賦值是失敗的。這個異常類型是 ArrayDismatchException
。如果你看到這個異常被拋出了的話,你必須要知道,有一種可能就是你使用了數(shù)組協(xié)變的機制,但不小心賦值了一個別的數(shù)據(jù)類型到數(shù)組里面的某個元素去。
那么,數(shù)組協(xié)變什么時候是安全的呢?只讀。如果你僅讀取里面的數(shù)據(jù)的話,如果你能保證下面執(zhí)行的所有代碼都是只讀數(shù)據(jù),不往里面寫數(shù)據(jù)修改數(shù)據(jù)的話,那么數(shù)組協(xié)變就是安全的。
這段代碼有沒有改變數(shù)組的元素?沒有,因為下面只有 foreach
循環(huán)輸出結果,并沒有改掉數(shù)組里面的元素。所以不會出錯。
那么,下面這個 Part 3 里提及的 Distinct
方法呢?
說不大上來了吧。實際上是沒有的,因為我們僅僅是在讀取里面的數(shù)據(jù),如果不重復就把元素存到新的集合 ArrayList
里,而不是在原地改變數(shù)組。所以,數(shù)組本身是沒有改變的,因此這里的數(shù)組協(xié)變也是安全的。
但是,排序方法會變動數(shù)組內(nèi)部的元素,因此數(shù)組的排序方法就不能使用數(shù)組協(xié)變,因為它明顯要改變數(shù)組里的元素。
這就帶來了一個很麻煩的問題:我如果不知道底層的代碼,我怎么可能知道這個方法是否改變了數(shù)組的元素?正是因為如此,數(shù)組協(xié)變是無法百分之百保證的。所以,我們不能隨便使用數(shù)組協(xié)變機制,除非你可以百分之百保證數(shù)組里的元素一定是不會發(fā)生變動的。
所以,數(shù)組協(xié)變是一個方便的語法特性,它允許數(shù)組為單位把元素進行協(xié)變操作(即我們通常說的多態(tài)),但數(shù)組協(xié)變賦值后,數(shù)組就不能改變里面的數(shù)值了(除非你知道原始數(shù)組的數(shù)據(jù)類型);否則必然會產(chǎn)生 ArrayDismatchException
異常。