後端知識 - Equals 說相等,== 說不相等。

image

這是 backend-lab 自學紀錄的 Day 2 後段,主題是 C# record 的相等性。兩個內容一樣的物件算不算相等,record 和一般 class 給的答案不同。這篇記錄我用一排實驗拆出來的三件事:相等性其實是逐個成員交給各自的 Equals 去比、Equals 和 GetHashCode 必須綁在一起,以及手寫 Equals 之後 == 不會跟著變。

HashSet 那行印錯變數、數字卻剛好對的事,post-11 寫過了,這篇不重複。Day 2 前段的 async 在 post-13。

先分清楚兩種相等

長得一樣,跟是同一個,是兩件事

C# 的 record 是一種專門裝資料的型別,一行就能宣告:

record Person(string FirstName, int Age);

我拿它跟一個欄位完全一樣的普通 class 比,各 new 兩個內容相同的物件,看三種比法:

                Equals   ==      ReferenceEquals
record Person   True     True    False
class           False    False   False

ReferenceEquals 問的是「這兩個變數是不是指向記憶體裡同一個東西」。兩次 new 就是兩個東西,所以三種型別都是 False。

普通 class 預設的 Equals 也是在比這個,所以它也是 False。

record 不一樣。編譯器幫它生了一套比內容的 Equals,名字和年齡都一樣,就算相等。

最有說服力的是 record 那一列。Equals 是 True,ReferenceEquals 是 False。

相等,但不是同一個。 值相等跟身分相等,在這一列被拆開了。

寫 JS 的人可以這樣對照:JS 的 === 比兩個物件,比的永遠是參考,兩個 { a: 1 } 永遠不相等。C# 的 class 預設也是這樣,record 則是把「比內容」做成預設行為。

比內容,到底比多深

不是深比,是外包

知道 record 比內容之後,我馬上想到下一個問題。如果欄位本身也是物件,它會一路比進去嗎?

我做了兩個實驗:

record NumberList(List<int> Numbers);
record Line(Point Start, Point End);

兩個 NumberList 都裝 { 1, 2, 3 },比出來是 False。

兩個 Line 的起點終點都一樣,比出來是 True。

如果 record 是深比,NumberList 應該要是 True。如果它碰到參考型別就一律放棄,Line 應該要是 False。兩個猜法都被打掉了。

真正的規則是,record 不自己比,它把每個成員交給那個成員自己的 Equals。

List<int> 是普通 class,它的 Equals 比的是參考,兩個不同的 List 就是 False。Point 是 record,它的 Equals 比內容,所以 True。record 只是把這些答案收集起來,全部 True 才算 True。

這有點像一間公司的驗收流程。總經理不會自己去檢查每個零件,他問每個部門主管「你那邊 OK 嗎」,主管說 OK 他就簽。問題是倉儲部門的主管檢查的方式是「是不是同一箱」,不是「箱子裡的東西一不一樣」,那總經理也只會拿到這個答案。

Equals 根本沒被叫到

相等卻留兩份,是因為它根本沒被問

這段是整個 Day 2 我覺得最值得記下來的。

如果我不用 record,自己在 class 裡 override Equals,讓它比 X 和 Y:

class ClassOverridePoint
{
    public int X { get; }
    public int Y { get; }

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

    public override bool Equals(object? obj)
    {
        return obj is ClassOverridePoint other
               && X == other.X
               && Y == other.Y;
    }
}

Equals 確實回 True。但把兩個 (1, 2) 放進 HashSet,Count 是 2。

明明相等,卻留了兩份。

原因在 HashSet 找東西的方式。它不會拿新元素跟裡面每一個都比一次 Equals,那太慢了。它先算 GetHashCode,用這個數字決定要放進哪一格,只有落在同一格的元素才會進一步比 Equals。

我沒有 override GetHashCode,所以用的是預設版本,兩個物件算出來的數字不一樣。我實際印出來,一個是 23458411,一個是 9799115。

數字不同,放進不同的格子,Equals 從頭到尾根本沒被執行。

這就是 Equals 和 GetHashCode 之間的約定。兩個物件 Equals 相等,GetHashCode 就必須一樣。反過來不用,hash 一樣但內容不同是允許的,那只是剛好撞在同一格。

編譯器其實有提醒。build 的時候會出現 CS0659 警告,說這個型別覆寫了 Equals,卻沒有覆寫 GetHashCode。

(第一次 build 看到黃色那行,我很順手地就把終端機往上捲掉了)

補上一行就好:

public override int GetHashCode()
{
    return HashCode.Combine(X, Y);
}

Count 變回 1。

record 從來不會有這個問題,因為編譯器生 Equals 的時候,GetHashCode 是一起生的。

override Equals,== 不會跟著來

同一個物件,兩種比法給出相反的答案

換題重寫的時候,我把 Point 換成 Person,Equals 和 GetHashCode 都補齊了,HashSet 也正常是 1。

但我把 == 也印出來,看到這一行:

ClassOverridePerson Equals : True
ClassOverridePerson ==     : False

同一對物件,Equals 說相等,== 說不相等。

這個矛盾是我在實驗結果裡自己注意到的,原因也是自己推出來的。== 在 C# 裡是另一個運算子,class 沒有自訂的話,它比的還是參考。override Equals 只改了 Equals,== 完全不知道這件事。

要讓 == 也比內容,得另外寫 operator == 和 operator !=。

record 一次幫你生的是完整的一套,兩個 Equals、GetHashCode、==、!= 全部對齊。手寫 class 的時候,這五個你要自己一個一個補,漏一個就會出現上面那種矛盾。

常見應用:什麼時候會真的踩到

不是考試題,是會在正式環境出現的 bug

這三件事在實際寫後端的時候,大概會出現在這些地方:

  • 拿物件當 Dictionary 的 key 或丟進 HashSet 去重複。漏了 GetHashCode,去重複會無聲失效,不會有任何錯誤
  • record 裡面放 List,以為兩筆資料內容一樣就會相等,結果永遠是 False
  • 程式碼裡有人寫 a.Equals(b)、有人寫 a == b,對手寫 class 來說,這是兩種不同的判斷

第一個最麻煩。它不會 crash,只會讓資料多一份,而你要等到某天數字對不上才會發現。

所以 CS0659 那種警告,看到就要停下來讀。

(來自一個看到黃色警告,第一個反應是捲掉終端機的人)