這是 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 FalseReferenceEquals 問的是「這兩個變數是不是指向記憶體裡同一個東西」。兩次 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 那種警告,看到就要停下來讀。
(來自一個看到黃色警告,第一個反應是捲掉終端機的人)
