談談C#的相等性(3)
virtual bool Equals(Object object)
Equals 這個方法很有趣,和 operator == 不一樣。Equals是個在物件之母上的virtual method。意味著所有物件都可以override掉,提供型別客製的相等性比較。
上個段落有提到C#官方建議,如果 reference type 要比 value equality,則應該用 .Equals()。但有趣的是,Object 型別上的 Equals 方法,其比較的方法是「Reference相等」,和 Object.ReferenceEquals() 行為相同。但Value-Type物件,已經將.Equals方法覆寫成byte-by-byte內容相等。
Value Type 型別應該總是覆寫掉Equals
因為Value Type的 Equals 會用反射去取得所有欄位,並byte-by-byte的比較,所以效率並不好,會建議覆寫掉並提供struct自己的實做。
Reference Type請三思而後行
要不要覆寫Reference Type的Equals method,這件事情其實很微妙。
C#設計的時候,把物件分成兩大類,一類是Value Type,意思是這類型的物件,內容才是重點,物件本身不是。因此只要內容相等,就可以視為兩個物件完全相同。一般的數字、enum、複數(Complexity)、單純的座標(x,y)等,都可以看成是Value Type。
另一類的物件是 Reference Type,意思是這類型的物件,比起內容,更重要的是物件的個體。就算兩個物件內容完全相同,依然會被視為兩個不同的個體。預設 operator == 比較對於 Reference Type 物件而言,就是判斷兩個變數是否參考到同一個物件。
覆寫Reference Type的Equals意味著改變語意
當你決定要替Reference Type覆寫Equals方法時,就代表你賦予了該Reference Type值相等(Value Equality)的語意,你必須要開始小心你現在講的「相等」,到底是 Reference Equality 還是 Value Equality。
你要注意當你使用 == 時,你想表達的是哪個相等?所以你要考慮是否多載operator ==
- 如果你沒有多載==,那麼依然是Reference Equality
- 如果你有多載==,而且行為和Equals(Object)相同,那麼代表Value Equality
- 如果你有多載==,而且行為和Equals(Object)不同,Shame On You。這會造成理解上的不一致。
你要注意的事情還有很多,因為你覆寫掉Equals(Object)了,假設這個Reference Type叫做T,在使用Dictionary<T, V>
時,判斷內容是否包含T key的行為跟著被影響了。
假設T key是個Reference Type,其相等性的判斷是預設的Object.Equals(Object),預設的行為是不管內容,只管是否是同一個物件。現在這個行為被覆寫掉了,會改為根據你自訂的Equals行為判斷是否相同。
這個時候要不要改寫GetHashCode呢?如果不改寫GetHashCode,就會發生兩個Value Equality相同的物件,卻有不同的GetHashCode值,這在Dictionary的操作上會變得很怪,明明兩個相同的物件,但僅僅因為HashCode不同,導致Dictionary無法找到物件原本的位置。
public static void Main(string[] args)
{
var lookup = new Dictionary<Person, int>();
var personA = new Person("Tom");
var personB = new Person("Tom");
lookup.Add(personA, 30);
Console.WriteLine(lookup.ContainsKey(personA)); // true
Console.WriteLine(lookup.ContainsKey(personB)); // false
}
internal class Person
{
private readonly string _name;
public Person(string name)
{
_name = name;
}
public override bool Equals(object obj)
{
return Equals(obj as Person);
}
public bool Equals(Person other)
{
if (object.ReferenceEquals(other, null))
{
return false;
}
if (object.ReferenceEquals(this, other))
{
return true;
}
if (this.GetType() != other.GetType())
{
return false;
}
return _name == other._name;
}
}
加上GetHashCode後,
public override int GetHashCode()
{
return _name.GetHashCode();
}
public static void Main(string[] args)
{
var lookup = new Dictionary<Person, int>();
var personA = new Person("Tom");
var personB = new Person("Tom");
lookup.Add(personA, 30);
Console.WriteLine(lookup.ContainsKey(personA)); // true
Console.WriteLine(lookup.ContainsKey(personB)); // true
}
因此更好的作法可能是什麼都不做
如果你什麼都不做,不去多載Reference Type的Equals,因為GetHashCode和Equals必須要彼此配合,所以這兩個method都不用改寫。
你可以保持 == 的單純,代表Reference Equality。而且不管左右的型別是object還是T,結果都相同。
通常很少需要把Person這類的Reference Type放到dictioanry或hashset內,真的需要做這件事,也可以額外提供IEqualityComparer傳入,作為比較大小的規則。
public static void Main(string[] args)
{
var lookup = new Dictionary<Person, int>(new NameEqualityComparer());
var personA = new Person("Tom");
var personB = new Person("Tom");
lookup.Add(personA, 30);
Console.WriteLine(lookup.ContainsKey(personA)); // true
Console.WriteLine(lookup.ContainsKey(personB)); // true
}
internal class NameEqualityComparer : IEqualityComparer<Person>
{
public bool Equals(Person x, Person y)
{
if (x == null && y == null)
{
return true;
}
if (x == null || y == null)
{
return false;
}
return x.Name == y.Name;
}
public int GetHashCode(Person obj)
{
return obj.Name.GetHashCode();
}
}
internal class Person
{
public string Name { get; set; }
public Person(string name)
{
Name = name;
}
}