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() 一樣。
GO TO FULL VERSION