CodeGym /課程 /C# SELF /C# 中的物件比較: Equals 和 ...

C# 中的物件比較: Equals 和 GetHashCode()

C# SELF
等級 34, 課堂 3
開放

1. 前言

寫程式時我們一直在比較東西:數字、字串、物件。但在 C# 裡「物件相等」到底是什麼意思?其實沒那麼直觀。今天我們要聊兩個關鍵方法,決定物件怎麼比較:Equals() 和 GetHashCode()。搞懂這兩個方法對你的程式正確運作超級重要,尤其是用到像 Dictionary 或 HashSet 這種集合時。

在 C# 裡有兩種主要的相等:

參考相等 (Reference Equality):

意思是兩個參考型別的變數指向記憶體裡同一個物件。這用 == 運算子來檢查。

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;

Console.WriteLine(obj1 == obj2); // false(記憶體裡是不同物件)
Console.WriteLine(obj1 == obj3); // true(兩個參考都指向同一個物件)

值相等 (Value Equality):

意思是兩個不同的物件(或兩個值型別)有一樣的內容(欄位/屬性的值都一樣)。這通常才是我們比較物件時想要的效果。這時就要用 Equals() 方法。

// 假設我們有個 Point 類別
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 跟 p2 是不同物件,但我們希望它們「值相等」
Console.WriteLine(p1.Equals(p2)); // ? 取決於 Equals() 的實作
Console.WriteLine(p1.Equals(p3)); // ?

2. Equals() 方法

Equals() 方法定義在基底類別 System.Object,所有 C# 型別都繼承它。它的主要用途就是判斷兩個物件值是否相等。

Equals() 的預設行為

對值型別(struct、int、bool 等): 預設的 Equals()(繼承自 System.ValueType)會做逐欄位位元比較。只要所有欄位都一樣,物件就算相等。這通常就是我們想要的。

對參考型別(class、string、array 等): 預設的 Equals()(繼承自 System.Object)只會檢查 參考相等。也就是 obj1.Equals(obj2) 預設只會在 obj1 跟 obj2 指向 同一個記憶體物件時才回傳 true。
class Person // 參考型別
{
    public string Name { get; set; }
    public int Age { get; set; }
}

Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // 另一個物件,但內容一樣

Console.WriteLine(person1.Equals(person2)); // false(預設只比參考)

你看,參考型別的預設行為常常不是我們要的!我們會希望兩個「Alice」30歲的人就算是不同物件,也要算相等。

自訂類別要重寫 Equals()

如果你想讓自己的類別能「值相等」,一定要自己重寫 Equals() 方法。

重寫 Equals() 的規則(契約):

  • 自反性: x.Equals(x) 一定要 true。
  • 對稱性: 如果 x.Equals(y) 是 true,那 y.Equals(x) 也要是 true。
  • 傳遞性: 如果 x.Equals(y) 跟 y.Equals(z) 都是 true,那 x.Equals(z) 也要 true。
  • 一致性: 多次呼叫 x.Equals(y),只要物件沒變,結果都要一樣。
  • Null 相容: x.Equals(null) 一定要 false。

範例:為 Person 類別重寫 Equals()

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // 重寫 Equals()
    public override bool Equals(object? obj)
    {
        // 1. 檢查 null
        if (obj == null) return false;

        // 2. 檢查型別
        if (obj.GetType() != this.GetType()) return false; 

        // 3. 型別轉換
        Person other = (Person)obj;

        // 4. 比較欄位值
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }
}

// 用法:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);

Console.WriteLine(person1.Equals(person2)); // true(現在比值了!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null));    // false

3. GetHashCode() 方法

GetHashCode() 方法也定義在 System.Object。它會回傳一個整數(hash code),這個值要能盡量快又唯一地(盡可能啦)識別物件。

GetHashCode() 有什麼用?

Hash code 主要是用來優化基於 hash table 的集合。像這些集合:

  • Dictionary<TKey, TValue>(hash code 用來快速找 key)
  • HashSet<T>(hash code 用來檢查元素唯一性)
  • Hashtable

當你把物件加進 HashSet 或當作 Dictionary 的 key,用集合時會先算物件的 hash code。這樣它可以直接「跳」到某個「桶子」或一群 hash code 一樣的元素裡,而不用全部都比一遍。然後在那個「桶子」裡才會用 Equals() 做精確比較。

重寫 GetHashCode() 的規則(契約):

如果你重寫了 Equals(),你一定也要重寫 GetHashCode()! 這是 C# 超重要的規則。

  • 一致性: 如果 Equals() 對兩個物件回傳 true,那 GetHashCode() 也一定要回傳一樣的值。(反過來不一定:不同物件可以有一樣的 hash code,這叫「碰撞」。)
  • 穩定性: GetHashCode() 只要物件沒變,回傳值就要一樣。
  • 速度: GetHashCode() 要很快,不能太耗時。

為什麼這麼重要? 如果你只重寫了 Equals(),沒重寫 GetHashCode(),你的集合(尤其 hash 集合)會壞掉:

  • Dictionary 找不到你的 key。
  • HashSet 會加進重複的元素,因為它以為它們不一樣。

這是因為預設的 GetHashCode() 只根據物件參考(對參考型別)產生 hash。如果 Equals() 已經改成比值,那值一樣但參考不同的物件 hash code 會不一樣,集合就不會把它們當成一樣。

為 Person 類別重寫 GetHashCode()

好習慣是用跟 Equals() 一樣的欄位來產生 hash code。.NET 有個靜態方法 HashCode.Combine() 超好用。

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public override bool Equals(object? obj)
    {
        if (obj == null || obj.GetType() != this.GetType()) return false;
        Person other = (Person)obj;
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }

    // 重寫 GetHashCode()
    public override int GetHashCode()
    {
        // 用 HashCode.Combine 把欄位 hash 合起來
        return HashCode.Combine(Name.ToLowerInvariant(), Age); 
    }
}

// 在集合裡用法:
public class Program
{
    public static void Main(string[] args)
    {
        Person p1 = new Person("Alice", 30);
        Person p2 = new Person("Alice", 30);
        Person p3 = new Person("Bob", 25);

        HashSet
  
    uniquePeople = new HashSet
   
    (); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 跟 p1 值一樣,不會再加進去 Console.WriteLine($"唯一人數:{uniquePeople.Count}"); // 輸出:1 uniquePeople.Add(p3); Console.WriteLine($"唯一人數:{uniquePeople.Count}"); // 輸出:2 } } 
   
  

重點: 在 GetHashCode() 裡,如果字串欄位在 Equals 裡是用 StringComparison.OrdinalIgnoreCase(不分大小寫)比較,你也要在算 hash 前先轉小寫(像 Name.ToLowerInvariant())。不然 Equals() 會回傳 true(Alice == alice),但 GetHashCode() 會給不同值,這樣就違反契約了。

4. 運算子 == 和 != 的多載

對類別(參考型別)來說,== 預設只比參考相等。你可以多載它,讓它比值相等,就像 Equals() 一樣。

== 多載規則:

  • 如果你多載了 ==,一定也要多載 !=。
  • 建議多載的 == 行為要跟 Equals() 一樣。
  • 多載 == 時,也要重寫 GetHashCode() 和 Equals()。
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    // 建構子、Equals、GetHashCode 同上

    // 多載 ==
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // 檢查 left 是否為 null
        {
            return ReferenceEquals(right, null); // 兩個都 null 就相等
        }
        return left.Equals(right); // 不然就用我們重寫的 Equals()
    }

    // 多載 !=(多載 == 時一定要有)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

// 用法:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;

Console.WriteLine(p1 == p2); // true(現在用多載的 ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record — 自動等值

從 C# 9 開始,有個新型別 record。它是參考型別,但會自動實作值相等(自動重寫 Equals()、GetHashCode()、ToString() 和 ==/!= 運算子),根據所有欄位/屬性。這讓 record 超適合拿來做不可變的資料物件。

public record PersonRecord(string Name, int Age);

// 用法:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);

Console.WriteLine(r1 == r2); // true(自動值相等!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record 讓你在需要參考型別但又想要值型別行為時超方便。

6. 建議

如果你重寫 Equals(),一定要一起重寫 GetHashCode()! 不然 hash 集合會出現很難 debug 的怪問題。

Equals() 和 GetHashCode() 要用一樣的欄位。 會讓物件「值相等」的欄位,也要拿來算 hash。

小心可變(mutable)型別。 如果 Equals() 和 GetHashCode() 用到的欄位在物件建立後還會變,hash code 也會變。這對 hash 集合超危險,物件會「消失」(因為 hash code 變了,集合找不到它)。hash 集合最好用不可變(immutable)型別當 key 或元素。

如果是不可變資料物件,考慮用 record。 這樣值相等的實作就超簡單。

只有在參考型別有需要時才多載 == 和 !=。 值型別的 == 已經是比值了。如果你要多載,記得行為要跟 Equals() 一樣。

2
任務
C# SELF, 等級 34, 課堂 3
上鎖
使用 `Equals` 方法來比較物件
使用 `Equals` 方法來比較物件
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION