C# - 為引用類型重定義相等性


通常情況下引用類型的相等性是不應該被重定義/重寫的。

例如兩個引用類型的變量 x 和 y,如果這樣寫:if(x == y) {...},那么大家都明白,這個比較的是引用的相等性。

但是有少數情況下,也可以為引用類型重寫相等性。

例如這個類:

這個類里面只有兩個string類型的屬性和字段,那么對它的相等性來說,更合理的是去比較值,而不是引用。

 

還有一種情況,就是表示數學的引用類型。

例如有一個類表示矩陣 Matrix,那么這樣寫 if(matrix1 == matrix2) {...} 更適合表示它們兩個的值相等。

 

上述的這兩個例子其實也不是十分的必要。所以想為引用類型重寫相等性的時候還是應該先想好,重寫后是否能夠更加的直觀,使理解便得更簡單了。

實際上如果想比較兩個應用類型里面的值是否相等,你不必非得去重寫那些相等性的方法,你可以通過實現IEqualityComparer<T>接口來寫一個單獨的相等性比較器。但是這樣的話不能使用==操作符,需要這樣寫:if(eqComparer.Equals(x, y)) {...}

 

為引用類型重寫相等性

一個類:

首先重寫object.Equals()方法:

這個邏輯比較簡單,就是判斷null,引用和類型,然后再判斷各個屬性(字段)的值是否相等。

 

然后還需要重寫object.GetHashCode()方法:

這個采用了Resharper生成的方法,以前說過,就不再介紹了。

 

最佳實踐還要求重寫C#的==操作符:

當然配套的!=也必須重寫。

 

在之前重寫值類型相等性的文章里,我還為值類型實現了IEquatable<T>接口,而對於引用類型來說,就沒有必要去實現該接口了,可以把相等性判斷邏輯放在object.Equals()方法里。

 

派生類

這是上面Citizen類的一個子類:

 

下面我重寫object.Equals() 方法:

大部分邏輯都在base.Equals()方法里了,首先如果父類的Equals()方法返回false,那么下面也就不用做啥了。但是如果父類Equals()認為這兩個實例是相等的,這就意味着父類里所有的相等性檢查都通過了,然后我們仍然需要檢查派生類里面的獨有字段(屬性),而這個例子里只有一個字段(屬性)。

然后別忘了實現GetHashCode()方法:

(resharper生成的代碼)

這個方法里使用了父類的GetHashCode()方法,把它按位異或IdCard的GetHashCode()的結果。

 

然后實現==和!=操作符:

好,現在我們來測試一下:

其結果如下:

這個結果還都是對值進行比較的,符合預期。

 

然后你可能以為這樣實現沒有問題了。。。。

陷阱 

現在我在Citizen這個父類里修改一下==的實現,我想讓它更有效率:

然后我再執行和上面同樣的測試代碼,其結果輸入是:

 

😱,全都相等了。。。。肯定不對。。

 

那在父類里的==方法設一下斷點看看:

這里面x和y其實都是BeijingCitizen的實例,但是現在所處的位置是其父類Citizen的==方法里,所以相等性檢查會在這里發生,所以這個相等性檢查只會檢查父類里面的字段,Citizen這個類無法知道其它繼承於它的類型,所以這里也無法比較派生類獨有的字段,在這里就是IdCard。而所有這些實例的不同值就去別再IdCard這個派生類的字段上面了,所以所有檢查的結果都是相等的,因為只比較了父類的那兩個字段。

為什么會調用Citizen父類的==方法呢?因為該方法是靜態的,也就不是virtual的。而我的測試代碼:

其參數類型是父類Citizen,所以a==b這句話會在編譯時就決定采取哪個版本的==實現,而編譯器在這個方法里會看到a和b的類型都是Citizen,所以它會調用Citizen版本的==實現。

 

所以這確實是一個陷阱。

 

但是為什么原來的寫法就沒有問題呢?

原來的寫法里,在Citizen這個父類里,==的實現調用了 object的靜態Equals()方法,而在這個靜態Equals方法里:

又調用了object的virtual Equals()方法,而如果實際類型是BeijingCitizen的話,那么就會調用override的Equals()方法,我們單獨看這個比較:

在BeijingCitizen里設一個斷點:

可以看到會擊中該斷點。也可以看一下CallStack:

 

現在再次運行所有測試,其結果:

就是正確的了。

 

所以說,相等性檢查的邏輯需要放在virtual的方法里

 

如果再往上一級,把參數都變成object類型:

輸出結果是:

這是因為==的實現不是virtual的,在object類型上使用==就是判斷引用的相等性。而你也無法在重載操作符來防止上述事情的發生,因為這段代碼永遠不會調用到你的操作符重載方法。

 

那么結論就是,在操作符重載方法里調用vitual的方法,就可以應付繼承相關的相等性判斷,但是至少也得輸入你定義的父類的類型(Citizen),好讓你定義的操作符重載方法可以被最先調用如果要滿足繼承、相等性這兩方面的要求,那么就需要犧牲類型安全:

所以==操作符重載,可以看作一種方便的語法糖法,同時也把類型不安全的Equals()方法包裝了起來。

 

為什么不實現IEquatable<T> 

如果我在Citizen類里面實現了該接口:

那么方法里的調用也還是調用virtual的Equals(),否則的話還是一樣的bug。那么這樣看的話,實現該接口幾乎沒有什么新鮮的作用,雖然說該方法可以做到一定程度的類型安全,但是性能上,比直接調用object.Equals()更慢了。

所以針對引用類型,不建議實現IEquatable<T>接口。

 

非得實現的話建議sealed

例如:

這樣的話,我們就可以把判斷相等的邏輯寫在該方法里了,因為這個類是sealed,所以能傳遞到這個方法里的變量一定是該類型的,沒有繼承的存在,我們就可以同時擁有類型安全和相等性了。

 

為sealed的class實現IEquatable<T>接口肯定是可行的,但是否值得呢?

優點:能得到微小的性能提升,string就是個例子。

缺點:class本身就更復雜了,你需要記住3種實現相等性判斷的方式。。。

綜上個人建議是針對引用類型不去實現IEquatable<T>接口

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM