這個做法討論了 C# 中物件相等性的問題,以及兩個物件是怎麼判定為相等背後的原理。

當你建立了一個新的型別同時也要想清楚對於這個型別來說什麼是相等的,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);

上面三種是定義在 System.Object 內部的,第一與第二種方法不建議重新實作,第三種你應該常常在新的型別 override Equals 方法 以符合型別的需求,第四種則是常常在 value type 中常常見到,因為效能的問題如果不改寫則會導致 boxing 產生把物件轉換成參考型別來比較。

當然第三種 Equals 方法常常會跟 IEquatable 同時實作這樣既可以保持兼容性也能使用泛型版本維持效能,其實還有 IStructuralEquatable 介面可以用來確認兩者結構是否相同,這個能在 Tuple 類別中看到,所以實際上有六種方法可以用來表達相等。

會這麼複雜的原因是 C# 允許建立 value typereference type, 如果說兩個 reference type 物件是相同的,那它背後應該要指向同一個物件參考並且擁有相同的值,也能稱為它們共同擁有相同 object identity, 然而要決定兩個 value type 是否相等則要根據它們類型是否相等與是否擁有相同的內容才能決定。


接下來討論為什們不應該重新實作第一與第二種方法,ReferenceEquals 從名字可以看出它是用來比較兩個物件的 reference 是否相等, 如果相等的話就會回傳 true,同時也代表這兩個物件擁有相同的 object identity,所以說這個方法不管是 value typereference type, 都應該是用來檢測是否有相同的 object identity,而不是用來檢測是否有相同的 object contents

所以就會產生一個有趣的現象,兩個相同的 value type 不會是相等的,並且 value type 自己跟自己比較也不會是相等的, 這是因為 ReferenceEquals 需要傳入的是 object 類型,所以要比較之前需要先進行 boxing 把 value type 轉換成 reference type, 也就是說 ReferenceEquals(i, i) 第一與第二個參數分別進行了兩次 boxing 代表產生了兩個不同的參考物件,由此可知它們不可能會回傳 true。

void Main()
{
	int i = 5;
	int j = 5;

	Console.WriteLine(object.ReferenceEquals(i, i)); //false
	Console.WriteLine(object.ReferenceEquals(i, j)); //false
}

實際轉換的程式碼大概會像這樣產生了兩個不同的參考物件。

int i = 5;
int j = 5;
Console.WriteLine ((object)i == (object)i);
Console.WriteLine ((object)i == (object)j);

所以這個 ReferenceEquals 方法目的性非常明確也符合它當初設計的意義,關鍵就是在檢查是否有相同的 object identity,所以沒有必要對它重新改寫。


第二種方法,static Object.Equals() 當你傳入兩個不知道執行階段型別的參數時,這個方法能檢查這兩個參數的參考是否相等, 並且在 object 類型中 ==ReferenceEquals 的行為是一致的。

public static bool Equals(object? objA, object? objB)
{
    if (objA == objB)
    {
        return true;
    }
    if (objA == null || objB == null)
    {
        return false;
    }
    return objA.Equals(objB);
}

所以效果等同於下面這樣的寫法。

public static bool Equals(object? objA, object? objB)
{
    if (object.ReferenceEquals(objA, objB))
    {
        return true;
    }
    if (object.ReferenceEquals(objA, null) ||
        object.ReferenceEquals(objB, null))
    {
        return false;
    }
    return objA.Equals(objB);
}

由於在 C# 中不管 value typereference type,它們實際最基底的類別都是 System.Object,但我們也知道對於這兩種型別來說 相等的定義是不一樣的,因此 static Object.Equals() 方法首先是檢查兩個物件的 reference 是否相同,接下來檢查是否為 null, 基本上 reference type 在第一個 if 檢查就能確認物件是否相同,但是 value type 則需要使用實例化中的自定義檢查也就是最一開始提到的第三種方法 virtual bool Equals ,以 Decimal 為例,它會 override virtual bool Equals,所以當使用 static Object.Equals() 方法運行到最後一行 objA.Equals(objB) 會轉跳到 Decimal 自定義 override bool Equals

//Object.cs
public virtual bool Equals(object? obj)
{
    return this == obj;
}

//Decimal.cs
 public override bool Equals([NotNullWhen(true)] object? value) =>
     value is decimal other &&
     DecCalc.VarDecCmp(in this, in other) == 0;

static Object.Equals() 方法做的事情也很明確符合它當初設計的意義,所以跟 ReferenceEquals 方法一樣沒有必要重新定義它。


討論完兩個你不應該重新定義的方法,接下來討論你應該 override 的方法。 首先需要了解數值相等性的定義,一個相等的數值應該有 reflexive、 symmetric、 transitive 這三種特性。

  1. reflexive: 物件要和自身相等,a == a 應該永遠為 true。
  2. symmetric: 次序不應該影響物件相等性, a == b 為 true 則 b == a 也要為 true。
  3. transitive: a == b 並且 b == c 均為 true,則 a == c 也要為 true。

當我們建立一個型別的時候,預設都會有從 Object 類型繼承過來的幾個 Equal 方法可以用,由上一段的內容可以得知如果接下來要建立的型別 對於相等性的定義與 reference type 的定義不一樣,則我們就有義務重新定義 virtual bool Equals 方法,因為裡面預設的內容就是背後 的行為就跟 ReferenceEquals 一樣。

這也是為什麼 System.ValueType 需要 override bool Equals 方法的原因,但是 System.ValueType 內部實做的檢查方式很沒有效率, 因為它需要在不知道實際型別的情況下對兩個物件的所有欄位的值進行比較,這就要用到 C# 中的反射,因此這也是為什麼幾乎每個使用 struct 定義的型別 都會override bool Equals 方法的原因,沒必要比較兩個 Int32 就用到反射功能吧。

當然還是要按照實際需求來定義,也有可能 reference type 需要 override bool Equals 方法,例如你需要將兩個擁有同樣內容 string 判斷為相等 就可以 override bool Equals 方法, 因為你需要把檢查 object identity 改成檢查 object contents

在一開始有提到 override bool Equals 方法也同時會順便處理 IEquatable,因為這個泛型版本能夠有更快的運行速度, 例如下面這個類型就是常見的處理模式。

public class Foo : IEquatable<Foo>
 {
    public override bool Equals(object right)
    {
        if (object.ReferenceEquals(right, null))
            return false;
        if (object.ReferenceEquals(this, right))
            return true;
        if (this.GetType() != right.GetType())
            return false;
        return this.Equals(right as Foo);
    }

    public bool Equals(Foo other)
    {
        // elided.
        return true;
    }
 }

第一個檢查邏輯判斷 right 是否為 null,為什麼不需要檢查 ReferenceEquals(this, null) 是因為在 C# this 關鍵字是永遠不可能為 null 的, 所以這個判斷可以跳過,會需要檢查 right 則是因為沒有這行執行到 right.GetType() 這行就會拋出 NullReferenceException。

第二個檢查是用來判斷兩個物件是否有相同的參考,這個在之前的段落有提到過。

第三個檢查傳入的 object 實際的型別,並且還需要跟第二個物件型別進行比較,會需要這個精確檢查是因為最後一行的轉型可能會產生 Bug。

以下面這個繼承的例子來說,baseObject 與它衍生的 derivedObject 類別進行比較,照這個例子來看 derivedObject 是可以轉型成 baseObject, 但 derivedObject 是不可能轉型成 baseObject 的,但實際運行的結果為 Equals 與 Not Equal,這就是沒有精確檢查可能會帶來的問題。

void Main()
{
	object baseObject = new B();
	object derivedObject = new D();
	if (baseObject.Equals(derivedObject))
		Console.WriteLine("Equals");
	else
		Console.WriteLine("Not Equal");

	if (derivedObject.Equals(baseObject))
		Console.WriteLine("Equals");
	else
		Console.WriteLine("Not Equal");
}

public class B : IEquatable<B>
{
	public override bool Equals(object right)
	{
		if (object.ReferenceEquals(right, null))
			return false;
		if (object.ReferenceEquals(this, right))
			return true;
		B rightAsB = right as B;
		if (rightAsB == null)
			return false;
		return this.Equals(rightAsB);
	}
	public bool Equals(B other)
	{
		return true;
	}
}
public class D : B, IEquatable<D>
{
	public override bool Equals(object right)
	{
		if (object.ReferenceEquals(right, null))
			return false;
		if (object.ReferenceEquals(this, right))
			return true;
		D rightAsD = right as D;
		if (rightAsD == null)
			return false;
		if (base.Equals(rightAsD) == false)
			return false;
		return this.Equals(rightAsD);
	}
	public bool Equals(D other)
	{
		return true;
	}
}

還有一點要注意就是上面的類別 D 呼叫了 base.Equals 方法,這個只建議在基底類別不為 System.ObjectSystem.ValueType 才這樣處理, 否你在類別 B 也呼叫 base.Equals 方法,那就會使用到 System.Object 的 Equals 方法。

還有最後一個方法是 == 運算子,跟重新定義 Equals 的原因一樣,== 運算子預設是使用效率比較差的版本,所以 value type 通常都會 重新定義 == 運算子,這個在 reference type 是不需要重新定義的。

最後是 IStructuralEquatable 介面, System.Array 以及 Tuple<> 都有實做,可以用來檢查結構與值是否都相同, List 就沒有實做 IStructuralEquatable 介面所以會返回錯誤。

var arr1 = new int[] { 1, 2, 3 };
var arr2 = new int[] { 1, 2, 3 };
Console.WriteLine(StructuralComparisons.StructuralEqualityComparer.Equals(arr1, arr2)); // true

var list1 = new List<int> { 1, 2, 3 };
var list2 = new List<int> { 1, 2, 3 };
Console.WriteLine(StructuralComparisons.StructuralEqualityComparer.Equals(list1, list2)); // false

Summary

在這個做法中建議不要去覆寫 static bool ReferenceEquals()static bool Equals 兩個方法,只建議覆寫 Equals() 方法 ,以及在 value type 中因為效能的問題建議要覆寫 Equals() 方法與 == 運算子,並且做好也同時實做 IEquatable<T> 介面。