# 原則6:理解幾個不同相等概念的關系
**By D.S.Qiu**
尊重他人的勞動,**支持原創,轉載請注明 [出處](/blog/1980083) :[http://dsqiu.iteye.com](http://dsqiu.iteye.com)**
當你定義類型(類或結構體)時,你同時要定義類型的相等。 C# 提供四種不同的函數決定兩個不同對象是否“相等”:
```
public static bool ReferenceEquals (object left, object right);
public static bool Equals (object left, object right);
public virtual bool Equals(object right);
public static bool operator ==(MyClass left, MyClass right);
```
C# 語言運行你實現這四個函數的自己的版本。但不意味著你需要這么做。你不需要重定義前面兩個靜態函數。你經常會創建你自己實例方法 Equals() 去定義你定義類型的語義,有時也會重寫操作符==() ,尤其是值類型。此外,這四個函數是有關聯的,所以你改變其中一個,可能會影響其他函數的行為。所以你需要完全測試這個四個函數。但是不擔心,你可以簡化它。
當然,這四個方法不是判斷是否相等的唯一選擇。還可以通過類型去實現 IEquatable<T> 重寫 Equals() 。如果類型是值類型的需要實現 IStructuralEquality 接口。這就說,總共有6中不同的方法去表達相等。
和 C# 中復雜的元素一樣,這個也遵守這個事實: C# 運行你同時創建值類型和引用類型。兩個引用類型變量當它們引用相同的對象時相等。好像引用它們的ID一樣。兩個值類型的變量當它們是相同的類型而且包含相同的內容時才相等。這就是為什么對這些方法都進行相等測試。
我們先從兩個不會修改的方法開始。 Object.ReferenceEquals() 當兩個變量指向相同對象——也就是說,兩個變量含有相同對象的ID時返回 true 。是否比較值類型或引用類型,這個方法總是測試對象ID,而不是對象的內容。也就是說,當你使用測試兩個值類型變量相等時, ReferenceEquals() 會返回 false 。即使你拿一個值類型變量和自己比較, ReferenceEquals() 也會返回 fasle 。這是因為封箱操作,你可以在原則45找到相關內容。
```
int i = 5;
int j = 5;
if (Object.ReferenceEquals(i, j))
Console.WriteLine("Never happens.");
else
Console.WriteLine("Always happens.");
if (Object.ReferenceEquals(i, i))
Console.WriteLine("Never happens.");
else
Console.WriteLine("Always happens.");
```
你絕不需要重定義 Object.ReferenceEquals() ,因為它已經支持了它的功能了:測試兩個不同變量的對象ID。
第二個你不要重新定義的是靜態方法 Object.Equals() 。當你不知道兩個參數的運行時參數是,用這個方法可以測試兩個變量是否相等。記住 System.Object 是 C# 所有類型的最終基類。無論什么時候,你比較的兩個變量都是 System.Object 的實例。值類型和引用類型都是 System.Object 的實例。來看下當不知道類型是,這個方法是如何判斷兩個變量是否相等的,相等是否依賴類型?答案很簡單:這個方法即使把職責委托給其中一個正在比較的類。靜態 Object.Equals() 方法的是像下面這樣實現的:
```
public static new bool Equals(object left, object right)
{
// Check object identity
if (Object.ReferenceEquals(left, right) )
return true;
// both null references handled above
if (Object.ReferenceEquals(left, null) || Object.ReferenceEquals(right, null))
return false;
return left.Equals(right);
}
```
上面實例代碼引入一個還沒有介紹的方法:即,實例的 Equals() 方法。我將會詳細介紹,但是我還沒打算終止對靜態 Equals() 的討論。我希望你能明白靜態 Equals() 方法使用了左參數的實例 Equals() 方法來判斷兩個對象是否相等。
和 ReferenceEquals() 一樣,你不需要重載或重定義自己版本的靜態 Object.Equals() 方法因為它已經做了它需要做的事情:當我們不知道運行時類型時,決定兩個對象是否相等。因為靜態 Equals() 把比較委托給左邊參數的實例 Equals() 方法,就是利用這個規則來處理類型的。
既然你明白了為什么不需要重定義靜態 ReferenceEquals() 和靜態 Equals() 方法。接下來就討論下你需要重寫的方法。但是首先,讓我們簡要來討論相等的關系的數學特性。你需要保證你定義的和實現的方法要和其他程序員的期望是一致的。這幾意味著你需要關心數學的相等關系:相等是自反的,對稱的,可傳遞的。自反性就是說任何對象都和自身相等。無論類型是什么 a == a 總是 true 的。對稱型即與比較的次序是沒有關系的:如果 a == b 是 true ,b == a 同樣也是 true 的。如果 a == b 是 false , b == a 也是 false 。最后一個性質就是如果 a == b 而且 b == c 都是 ture ,那么 a == c 必須是 true 的。這就是傳遞性。
現在是時候討論實例的 Object.Equals() 函數了,包括什么時候和怎么樣重寫它。當你發現默認的 Equals() 的行為和你的類型不一致時,你就需要創建自己的實例 Equals() 版本。 Ojbect.Equals() 方法使用對象的ID來決定兩個變量是否相等。默認的 Object.Equals() 函數和 Object.ReferenceEquals() 的表現是一樣的。等等——值類型是不同的, System.ValueType 沒有重寫 Object.Equals() 。記住 ValueType 是所有值類型(使用 struct 關鍵字)的基類。兩個值類型變量當它們類型相同和有相同的內容時是相等的。 ValueType.Equals() 實現就是這個行為。不好的是, ValueType.Equals() 沒有一個很高效的實現。 ValueType.Equals 是所有值類型的基類。為了提供正確的行為,你必須在不知道對象的運行時類型的情況下比較子類的所有成員變量。在 C# ,會使用反射來做。你可以查看下原則43。反射有很多不足的地方,尤其當性能是目標時。 相等是在程序中會被頻繁調用的集成操作之一,所以性能是值得考慮的。在大多數情況下,你可以重寫一個更快的值類型 Equals() 。對于值類型的建議是很簡單的:當你創建一個值類型,總是重寫 ValueType.Equals() 。
只有當你想要定義引用類型的語義是,你需要重寫實例 Equals() 函數。 .NET 框架的一些類都是使用值類型而不是引用類型來判斷是否相等。兩個 string 對象相等當它們的內容是一樣的。兩個 DataRowView 對象相等當它們指向同一 DataRow 。關鍵就是要你的類型服從值語義(比較內容)而不是引用語義(比較對象的ID),你應該重寫你自己的實例 Equals() 。
既然你知道什么時候去重寫你自己的 Object.Equals() ,你需要命名怎么樣實現它。值類型的相等關系封箱有很多補充,在原則45會被討論。對于引用類型,你的實例方法需要保留之前的行為,避免給使用者驚訝。當你重寫 Equals() ,你的類型要實現 IEquatable<T> 。對這點,我會解釋的更多一點。這里標準模式只是重寫了 System.Object.Equals 。高亮的代碼是改為實現 IEquatable<T>。
```
public class Foo : IEquatable<Foo>
{
public override bool Equals(object right)
{
// check null:
// this pointer is never null in C# methods.
if (object.ReferenceEquals(right, null))
return false;
if (object.ReferenceEquals(this, right))
return true;
// Discussed below.
if (this.GetType() != right.GetType())
return false;
// Compare this type's contents here:
return this.Equals(right as Foo);
}
#region IEquatable<Foo> Members
public bool Equals(Foo other)
{
// elided.
return true;
}
#endregion
}
```
首先, Equals() 不能拋出異常——這個沒有任何意義。兩個變量比較只有相等和不相等,沒有其他結果。像 null 引用或錯誤參數類型的所有錯誤情況都應該返回 false 。現在,我們詳細分析下這個方法的代碼,命名為什么每一步的檢查和哪些檢查是可以被遺漏的。第一個檢查右邊蠶食是否為 null 。沒有任何在 this 的引用上沒有任何檢查。 C# 中,它是一定不會為 null 的。 CLR 在通過 null 引用調用實例方法會拋出異常。下一個檢查是否兩個對象的引用是否一樣,測試兩個對象的ID。這是一個非常有效的測試,內容要相同對象的ID一定要相同。
在下一個檢查要比較的兩個對象是否是同一個類型。正確的形式是非常重要的。首先,主要是不是假定這就是 類 Foo ;而是調用 this.GetType() 。實際類可能是 Foo 的子類。第二,代碼檢查被比較對象的真正類型。這還不足以保證你可以把右邊參數轉換為當前類型。這個測試會導致兩個微妙的錯。考慮下面有關繼承結構的例子:
```
public class B : IEquatable<B>
{
public override bool Equals(object right)
{
// check null:
if (object.ReferenceEquals(right, null))
return false;
// Check reference equality:
if (object.ReferenceEquals(this, right))
return true;
// Problems here, discussed below.
B rightAsB = right as B;
if (rightAsB == null)
return false;
return this.Equals(rightAsB);
}
#region IEquatable<B> Members
public bool Equals(B other)
{
// elided
return true;
}
#endregion
}
public class D : B, IEquatable<D>
{
// etc.
public override bool Equals(object right)
{
// check null:
if (object.ReferenceEquals(right, null))
return false;
if (object.ReferenceEquals(this, right))
return true;
// Problems here.
D rightAsD = right as D;
if (rightAsD == null)
return false;
if (base.Equals(rightAsD) == false)
return false;
return this.Equals(rightAsD);
}
#region IEquatable<D> Members
public bool Equals(D other)
{
// elided.
return true; // or false, based on test
}
#endregion
}
//Test:
B baseObject = new B();
D derivedObject = new D();
// Comparison 1\.
if (baseObject.Equals(derivedObject))
Console.WriteLine("Equals");
else
Console.WriteLine("Not Equal");
// Comparison 2\.
if (derivedObject.Equals(baseObject))
Console.WriteLine("Equals");
else
Console.WriteLine("Not Equal");
```
在任何可能的情況下,你都希望看到相等或不想的打印兩次。因為一些錯誤,這已經不是前面的代碼了。第二個比較不會返回 true 。基類 B 的對象,不會被轉換為 D 的對象。然后第一個比較可能會評估為 true 。子類 D 可以被隱式轉換為 B 。如果右邊參數的 B 成員可以匹配左邊參數的 B 成員, B.Equals() 認為兩個對象是相等的。即使兩個對象是不同的類型,你的方法還是認為他們是想的的。這就違背了相等的對稱性。這是因為在類的繼承結構中自動轉換的發生。
如果這樣寫:把類型 D 對象顯式轉換為 B :
```
baseObject.Equals(derived);
```
derivedObject.Equals() 方法總是返回 false 。如果你不精確檢查對象的類型,你會很容易進入這種情況,比較對象的次序會成為一個問題。
上面所有的例子中,重寫 Equals() ,還有另外一種方法。重寫 Equals() 意味著你的類型應該實現 IEquatable<T> 。IEquatable<T> 包含一個方法: Equals(T other) 。實現 IEquatable<T> 意味著你的類型要支持一個類型安全的相等比較。如果你認為 Equals() 只有左右兩個參數的類型都相等才返回 true 。 IEquatable<T> 很簡單地讓編譯器捕捉多次兩個對象不相等。
還有另外一種方法重寫 Equals() 。只有當基類的版本不是 System.Object 或 System.ValueType ,你就應該調用基類的方法。前面的例子,類 D調用 Equals() 就是在基類B中定義的。然而,類 B 調用的不是 baseObject.Equals() 。System.Object 的版本只有當兩個參數引用同一個對象才會返回 true 。這并不是你想要的,或者你應該沒有在第一個基類中重寫自己的方法。
原則就是這樣,如果你創建一個值類型你就要重寫 Equals() ,如果是引用類型你不想遵循 System.Object 的引用語義就要重寫 Equals() 。當你重寫你自己的 Equals() ,你應該遵循上面列出的要點實現。重寫 Equals() 意味著你要重寫 GetHashCode() 查看原則7。
我們幾乎完成了本原則。 操作符 ==() 是簡單的。無論什么時候你創建一個值類型,重定義操作符 ==() 。原因和實例 Equals 函數一樣。默認的版本使用反射區比較兩個值類型的內容。這是比任何你實現的都更低效的,所以你要自己重寫。遵循原則46的建議避免封箱當比較兩個值類型時。
注意的是我們沒有說當你重寫實例Equals() 你就應該重寫操作符 ==() 。我說的是你應該重寫操作符 ==() 當你創建值類型的時候。你幾乎不用重寫操作符 ==() 當你創建引用類型是。 .NET 框架期望操作符 ==() 所有引用類型遵循引用語義。
最后,我們說下 IStructuralEquality ,System.Array 和 Tuple<> 泛型實現這個接口。它讓這些類型實現值語義而不強制比較時的值類型。你會對創建一個值類型是否實現 IStructuralEquality 留有疑惑。這個只有在創建輕量級類型需要。實現 IStructuralEquality 聲明一個類是可以被組合成一個基于值語義的大對象。
C# 提供了很多方式去測試是否相等。但是你需要考慮提供自定義的它們其中的兩種,支持類似的接口。你不需要重寫靜態 Object.ReferenceEquals() 和靜態Object.Equals() 因為它們能提供正確的參數,盡管不知道它們的運行時類。你總是要重寫實例 Equals() 和操作符 ==() 對于值類型可以提供性能。當你想要引用類型相等而不是對象ID相等,你需要重寫實例 Equals() 。當你重寫 Equals() ,你就應該是實現 IEquatable<T> 。很簡單,是不是?
小結:
這篇的內容是我們最熟悉的,翻譯的有點匆忙,平時雖然用的很多,但是都沒有深入研究過,還是有很多細節很受用的,比如值類型效率問題等。一個星期翻譯六篇,從量上看,還是比較滿意的,雖然每天都折騰的很晚,今天雖然是周末,我也沒有出去過,不過今天的效率太高了。但質還是不盡如人意的,至少沒有一點原創的感覺,以后多做回顧和修改。
下周要好好工作,把手上的工作完成到100%,無可挑剔,加油,我可以做得到的!
歡迎各種不爽,各種噴,寫這個純屬個人愛好,秉持”分享“之德!
有關本書的其他章節翻譯請[點擊查看](/category/297763),轉載請注明出處,尊重原創!
如果您對D.S.Qiu有任何建議或意見可以在文章后面評論,或者發郵件(gd.s.qiu@gmail.com)交流,您的鼓勵和支持是我前進的動力,希望能有更多更好的分享。
轉載請在**文首**注明出處:[http://dsqiu.iteye.com/blog/1980083](/blog/1980083)
更多精彩請關注D.S.Qiu的博客和微博(ID:靜水逐風)
- 第一章 C# 語言習慣
- 原則1:使用 屬性(Poperty)代替可直接訪問的數據成員(Data Member)
- 原則2:偏愛 readonly 而不是 const
- 原則3:選擇 is 或 as 而不是強制類型轉換
- 原則4:使用條件特性(conditional attribute)代替 #if
- 原則5:總是提供 ToString()
- 原則6:理解幾個不同相等概念的關系
- 原則7:明白 GetHashCode() 的陷阱
- 原則8:優先考慮查詢語法(query syntax)而不是循環結構
- 原則9:在你的 API 中避免轉換操作
- 原則10:使用默認參數減少函數的重載
- 原則11:理解小函數的魅力
- 第二章 .NET 資源管理
- 原則12:選擇變量初始化語法(initializer)而不是賦值語句
- 原則13:使用恰當的方式對靜態成員進行初始化
- 原則14:減少重復的初始化邏輯
- 原則15:使用 using 和 try/finally 清理資源
- 原則16:避免創建不需要的對象
- 原則17:實現標準的 Dispose 模式
- 原則17:實現標準的 Dispose 模式
- 原則18:值類型和引用類型的區別
- 原則19:確保0是值類型的一個有效狀態
- 原則20:更傾向于使用不可變原子值類型
- 第三章 用 C# 表達設計
- 原則21:限制你的類型的可見性
- 原則22:選擇定義并實現接口,而不是基類
- 原則23:理解接口方法和虛函數的區別
- 原則24:使用委托來表達回調
- 原則25:實現通知的事件模式
- 原則26:避免返回類的內部對象的引用
- 原則27:總是使你的類型可序列化
- 原則28:創建大粒度的網絡服務 APIs
- 原則29:讓接口支持協變和逆變
- 第四章 和框架一起工作
- 原則30:選擇重載而不是事件處理器
- 原則31:用 IComparable&lt;T&gt; 和 IComparer&lt;T&gt; 實現排序關系
- 原則32:避免 ICloneable
- 原則33:只有基類更新處理才使用 new 修飾符
- 原則34:避免定義在基類的方法的重寫
- 原則35:理解 PLINQ 并行算法的實現
- 原則36:理解 I/O 受限制(Bound)操作 PLINQ 的使用
- 原則37:構造并行算法的異常考量
- 第五章 雜項討論
- 原則38:理解動態(Dynamic)的利與弊
- 原則39:使用動態對泛型類型參數的運行時類型的利用
- 原則40:使用動態接收匿名類型參數