在 C# 裡,最容易混在一起的三件事是:

  • 兩個變數是不是指向同一個物件
  • 兩個值在語意上算不算相等
  • 這個型別的雜湊碼是否和相等規則一致

如果這三件事沒分清楚,DictionaryHashSet、自訂 value object 都很容易出問題。

同一性與相等性不是一回事

先看 System.Object 最基本的語意:

  • 同一性(identity):是不是同一個實體
  • 相等性(equality):是不是應被視為相同值

對 reference type 而言,預設的 Object.Equals(object?) 幾乎就是同一性判斷;也就是說,若型別沒有覆寫 Equals,那麼只有在兩個參考指向同一個物件時才會回傳 true

如果你要明確判斷「是不是同一個參考」,最安全的是:

bool sameReference = ReferenceEquals(x, y);

這和 == 也不能完全畫上等號,因為 == 可以被型別多載。

什麼是正確的相等性實作

如果型別代表的是值語意,例如座標、金額、身分值物件,那就應該覆寫 Equals,讓它比較內容而不是比較參考。

典型結構通常包含:

  1. 判斷 null
  2. 判斷型別是否相同
  3. 比較所有參與相等性的欄位

簡單範例:

public sealed class Point2D : IEquatable<Point2D>
{
    public int X { get; }
    public int Y { get; }

    public Point2D(int x, int y)
    {
        X = x;
        Y = y;
    }

    public bool Equals(Point2D? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return X == other.X && Y == other.Y;
    }

    public override bool Equals(object? obj) => Equals(obj as Point2D);

    public override int GetHashCode() => HashCode.Combine(X, Y);
}

相等性要遵守哪些契約

一個合理的 Equals 應該滿足幾個基本性質:

  • 反身性:x.Equals(x) 必須是 true
  • 對稱性:x.Equals(y)y.Equals(x) 必須一致
  • 傳遞性:若 x == yy == z,那 x == z
  • 一致性:比較依據不變時,結果不應忽然改變
  • 非空性:x.Equals(null) 必須是 false

這些規則不是學術要求,而是集合與框架假設你會遵守的行為契約。

ValueType 為什麼看起來比較「自帶相等」

struct 預設繼承 System.ValueType,而 ValueType 會提供基於欄位內容的相等性實作。因此很多 struct 就算你沒自己覆寫 Equals,也會得到「看起來合理」的值比較結果。

但要注意兩點:

  • 它通常比手寫版本慢,因為可能涉及反射或泛用比較流程
  • 若 struct 會被大量比較,最好仍自己實作 IEquatable<T> 並覆寫 Equals / GetHashCode

GetHashCodeEquals 必須一起設計

只要你覆寫了 Equals,就幾乎一定也要覆寫 GetHashCode

原因很直接:像 Dictionary<TKey, TValue>HashSet<T> 這類雜湊容器,先靠 hash code 找 bucket,再在 bucket 內做相等性比較。如果兩個物件被你定義成相等,卻產生不同 hash code,容器行為就會失真。

最重要的規則只有一條:

  • 如果 x.Equals(y)true,那麼 x.GetHashCode() 必須等於 y.GetHashCode()

反過來則不成立:

  • 相同 hash code 不代表兩個物件一定相等

為什麼可變物件會讓雜湊集合出事

如果一個物件被放進 DictionaryHashSet 之後,參與 Equals / GetHashCode 的欄位又被修改,就可能導致:

  • 集合找不到原本已存在的 key
  • 重複元素判斷失真
  • Remove 失敗

也就是說,拿來當 hash key 的型別,最好具備這個特性:

  • 參與相等性的欄位在生命週期內不再變動

這也是為什麼 value object、record、不可變型別很適合做 key。

IEquatable<T>==

若型別需要頻繁比較,建議一起做三件事:

  • 覆寫 Equals(object?)
  • 實作 IEquatable<T>
  • 覆寫 GetHashCode()

必要時再補上 == / !=

public static bool operator ==(Point2D? left, Point2D? right)
    => EqualityComparer<Point2D>.Default.Equals(left, right);

public static bool operator !=(Point2D? left, Point2D? right)
    => !(left == right);

關鍵不是語法完整,而是所有入口都要回到同一套相等規則,不要讓 Equals==、集合判定各講各的。

實務上該記住什麼

  • ReferenceEquals 判斷同一性,Equals 判斷語意相等
  • == 不一定等於 Equals,因為可以被多載
  • 覆寫 Equals 幾乎一定要一起覆寫 GetHashCode
  • 可變物件不適合拿來做雜湊 key
  • 對常比較的值型別或 value object,優先實作 IEquatable<T>

參考資料

  • 《CLR via C#》第四版,Jeffrey Richter
  • 《深入理解 C#》第四版,Jon Skeet