在 C# 裡,最容易混在一起的三件事是:
- 兩個變數是不是指向同一個物件
- 兩個值在語意上算不算相等
- 這個型別的雜湊碼是否和相等規則一致
如果這三件事沒分清楚,Dictionary、HashSet、自訂 value object 都很容易出問題。
同一性與相等性不是一回事
先看 System.Object 最基本的語意:
- 同一性(identity):是不是同一個實體
- 相等性(equality):是不是應被視為相同值
對 reference type 而言,預設的 Object.Equals(object?) 幾乎就是同一性判斷;也就是說,若型別沒有覆寫 Equals,那麼只有在兩個參考指向同一個物件時才會回傳 true。
如果你要明確判斷「是不是同一個參考」,最安全的是:
bool sameReference = ReferenceEquals(x, y);
這和 == 也不能完全畫上等號,因為 == 可以被型別多載。
什麼是正確的相等性實作
如果型別代表的是值語意,例如座標、金額、身分值物件,那就應該覆寫 Equals,讓它比較內容而不是比較參考。
典型結構通常包含:
- 判斷
null - 判斷型別是否相同
- 比較所有參與相等性的欄位
簡單範例:
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 == y且y == z,那x == z - 一致性:比較依據不變時,結果不應忽然改變
- 非空性:
x.Equals(null)必須是false
這些規則不是學術要求,而是集合與框架假設你會遵守的行為契約。
ValueType 為什麼看起來比較「自帶相等」
struct 預設繼承 System.ValueType,而 ValueType 會提供基於欄位內容的相等性實作。因此很多 struct 就算你沒自己覆寫 Equals,也會得到「看起來合理」的值比較結果。
但要注意兩點:
- 它通常比手寫版本慢,因為可能涉及反射或泛用比較流程
- 若 struct 會被大量比較,最好仍自己實作
IEquatable<T>並覆寫Equals/GetHashCode
GetHashCode 與 Equals 必須一起設計
只要你覆寫了 Equals,就幾乎一定也要覆寫 GetHashCode。
原因很直接:像 Dictionary<TKey, TValue>、HashSet<T> 這類雜湊容器,先靠 hash code 找 bucket,再在 bucket 內做相等性比較。如果兩個物件被你定義成相等,卻產生不同 hash code,容器行為就會失真。
最重要的規則只有一條:
- 如果
x.Equals(y)為true,那麼x.GetHashCode()必須等於y.GetHashCode()
反過來則不成立:
- 相同 hash code 不代表兩個物件一定相等
為什麼可變物件會讓雜湊集合出事
如果一個物件被放進 Dictionary 或 HashSet 之後,參與 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