C# 覆寫 Equals 方法,為何要覆寫 GetHashCode 方法

C# 覆寫 Equals 方法,為何要覆寫 GetHashCode 方法

前置知識

在看本文之前,要先知道什麼是 Value Type (實質型別) 和 Reference Type (參考型別),不熟的朋友可以參考這篇文章

[C#][筆記] Value Type (實值型別) vs. Reference Type (參考型別)

隱含繼承 Object 類別

微軟官方教學文件有提到,所有的型別都隱含繼承自 Object 類別。

C# 中的繼承 Microsoft Docs

因此用所有型別建立出來的物件都可以使用從 Object 繼承來的方法,如下圖

隱含繼承 Object 類別的方法

Object 的 Equals 方法

宣告一個 Data 類別,並分別建立出兩個物件給 data1 和 data2 變數,這裡刻意將欄位都填入相同的值,再用繼承自 Object 的 Equals 方法比較兩個物件是否相等。

Object 的 Equals 方法

因為是 Reference Type (參考型別),雖然 data1 和 data2 所有欄位的值都相同,但兩個變數在 Stack 內儲存的記憶體位址的值是不同的,因此 Equals 回傳的結果為 false。

覆寫 Equals 方法

如果我們希望將比對條件改為物件內欄位的值,可以覆寫 Equals 方法,自訂比對的條件,例如希望這個物件的 Equals 要同時比對 Id 和 Name 兩個欄位的值,可以這樣寫:

覆寫 Equals 方法

自訂比對的條件,覆寫 Equals 方法:

  • 若傳入 Equals 的物件參數(要比對的對象)不是 Data 型別,回傳 false。
  • 若傳入的物件參數是 Data 型別,比對 Id 和 Name 兩個欄位的值是否相等,並回傳比對結果。

此時 IDE 在 Data 類別的宣告處出現綠色波浪提示訊息,提示我們覆寫 Equals 方法時,也要覆寫 GetHashCode 方法。

什麼是 GetHashCode?為什麼要覆寫這個方法?在說明前,先科普什麼是「雜湊表」。

IDE 提示要覆寫 GetHashCode 方法的訊息

關於雜湊表

資料結構中有一種組織資料的方式稱為「雜湊表」,在 C# 語言中 HashSet<T>、Dictionary<TKey, TValue>、…等集合類別就是用雜湊表實現的。

可以把雜湊表想像成很多個桶子,每個桶子都有唯一的識別編號,而且每個桶子都可放入不止一件物品

這種資料結構的優點是,當我們知道物品所在桶子的編號,只要先找到指定編號的桶子,再到這個桶子裡去找,一定會找到我們要的東西,找東西的速度會很快。比起每個桶子都打開來找,會省下不少時間。

雜湊表的概念中,把每個物品平均分配到不同編號的桶子是很重要的,如果把所有東西通通放進同一個編號的桶子,找東西時雖然只打開這一個桶子,卻還要每個物品逐一巡過比對才能找到確定要的東西,那用雜湊表就沒有意義了,不如放到陣列或 List 裡用迴圈比對。

雜湊表的概念在現實生活中的應用:

  • 圖書館的書本分類存放方式
    書籍分門別類放在不同編號的書櫃,在相同編號的書櫃裡,放著許多相同分類的書。
    找書的時候,依分類先找到該編號的書櫃,再到該編號的書櫃即可找到需要的書(不用每一櫃都找)。
  • 書本、字典的索引頁
    索引頁會列出每個關鍵字詞所在的頁數,不同字詞可能會出現在相同的頁數。
    找內容時,先找到關鍵字詞所在的頁數,直接翻到該頁數即可找到指定字詞的內文(不用從第一頁找到最後一頁)。

覆寫 GetHashCode 方法

從上面的說明可知,雜湊表會用到雜湊值,GetHashCode 就是產生雜湊值的方法,將物件放進這個雜湊值的桶子裡,需要時,先呼叫物件的 GetHashCode 得到雜湊值,在雜湊表找出這個雜湊值的桶子,從桶子裡取出這個物件,速度就加快了。

如果物件不放入雜湊表,則 GetHashCode 方法用不到,但我們無法預期別人會如何使用這個物件,所以在覆寫 Equals 方法時,先將 GetHashCode 寫好就很重要了。

GetHashCode 產生雜湊值

上圖可看到 data1 和 data2 即使欄位的值都相同,但兩個物件是不同執行個體 (不同 new Data 所產生的物件),繼承自 Object 的 GetHashCode 方法就會產生不同的雜湊值。

但前面我們覆寫 Equals 方法比較是否相等的條件,比對的是 Id 和 Name 兩個欄位的值,在 data1 和 data2 的內容都相同的情況下,我們希望他的雜湊值也要一樣,此時可以覆寫 GetHashCode 方法,用自訂的條件去產生雜湊值(如下圖)。

匿名型別的 GetHashCode 方法

上面在 GetHashCode 方法用到一個實用的技巧是大神 Joey Chen 教的,借用匿名型別實作的 GetHashCode 方法,在 new 匿名型別時,只要傳入欄位的值都一樣,產生出來的雜湊值就會相同。

C# 用雜湊表實現的集合類別

在 C# 語言中,用雜湊表實現的集合類別有很多種,例如 HashSet<T>、Dictionary<TKey, TValue>、SortedList<TKey, TValue>、HashTable、SortedList、…等。

當 GetHashCode 已存在雜湊表內,且 Equals 回傳為 true (物件比對相等),表示新加入的物件已經存在雜湊表內,不再重複加入。

  • 在 HashSet 加入重複的物件會略過,只會加入唯一不重複的物件進去。
  • 在 Dictionary 加入重複的物件會發生例外。

接下來用 Data 類別建立 data1、data2 兩個物件,並將這兩個物件加入到 HashSet<Data> 內,試試以下不同情境,看看會發生什麼事。

  • 用 Object 的 GetHashCode 方法產生雜湊碼。
    data1、data2 兩個物件的 GetHashCode 都不同,所以如預期的,兩個物件都會加入到 HashSet<Data> 中。
    用 Object 的 GetHashCode 方法加入 HashSet

  • 用自訂的 GetHashCode 方法產生雜湊碼,條件是用 Id + Name 欄位的值產生。
    data1、data2 是兩個執行個體,但我們讓兩個物件的 GetHashCode 的值相同,在加入到雜湊表集合時,會先呼叫 GetHashCode 檢查雜湊表內是否存已存在這組雜湊碼,接著:

    1. 若雜湊碼不存在,將物件加入雜湊表
      data1.GetHashCode() 不存在雜湊表,將 data1 物件加入。
    2. 若雜湊碼已存在,取出表內雜湊碼相同的所有物件,逐一呼叫物件的 Equals 方法和新加入物件相比是否相同,當回傳為 false 才加入雜湊表。
      data1.Equals(data2) == true,因此 data2 不會加入雜湊表。
      此例最終只有 data1 會加入到 hashSet 中。
      用自訂的 GetHashCode 方法加入 HashSet

同樣的情形,在 Dictionary 會出現例外
用自訂的 GetHashCode 方法加入 HashSet

什麼情況會遇到 GetHashCode 相同,但 Equals 為 false 的情況?

當 GetHashCode、Equals 兩個方法使用不同欄位條件時,就會遇到。

如下圖,GetHashCode 只用 Id 欄位去產生,但 Equals 是用 Id + Name 欄位的值比對,此時三個 Data 物件都會加入到雜湊表集合內,並且都放在同一個雜湊碼的桶子裡。

我們在 Equals 和 GetHashCode 方法內加入 Console.WriteLine() 觀察雜湊表取資料的過程,當遇到相同 GetHashCode 時,會取出同一個桶子內的所有資料,再逐一呼叫 Equals 方法比對,最後取出比對結果相符的物件。

在這個案例,沒有發揮出雜湊表的優勢。

相同的 GetHashCode 與不相等的 Equals 加入 HashSet

總結

  • 比較物件是否相等,用的是 Equals 方法,覆寫此方法可自訂比對的條件。

  • 雜湊表會用到 GetHashCode 方法,因為無法預期別人是否會將物件用於雜湊表,建議可先依據 Equals 的條件覆寫 GetHashCode 方法,避免將來在雜湊表使用此物件時遇到非預期的結果。

  • 加到雜湊表的物件不能重複。

  • 判斷雜湊表內物件是否「重複」的條件:GetHashCode 相同」 且 「Equals 為 true」。

  • 判斷雜湊表內物件是否重複的流程:

    1. 先呼叫 GetHashCode 方法,檢查雜湊表是否存在這個雜湊值,若不存在,表示新加入的物件不重複,加入至雜湊表。

    2. 若雜湊值存在,再呼叫 Equals 方法,比對物件,若不相等,表示新加入的物件不重複,加入至雜湊表。

  • 從雜湊表取出物件的流程:

    1. 先呼叫 GetHashCode 方法,從雜湊表取出這個雜湊值的所有物件,物件數量越少越好。

    2. 再呼叫 Equals 方法,逐一比對物件是否相等,若相等,回傳此物件。

參考資料