談談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;
    }
}

Leave a Reply

avatar
  Subscribe  
Notify of