[從 Effective Java 了解 Kotlin] 覆寫 equals 以及 hashCode 方法

Item 10 — Obey the general contract when overriding equals

在 Java 中,所有物件的源頭是 Object ,而 Kotlin 則是 Any,雖然這兩個的名稱不同,但是共同的點是,它們都有共同的函式: equals() , hashCode(), toString() 。(Kotlin 的 Any 只有這三個函式,而 Java 的 Object 還包含其他的函式)。

為什麼這三個函式是必要的呢? equals() 用來比對兩個物件是否相等, hashCode() 用整數來表示該物件,有身分證字號的意義,最後則是 toString() ,我們可以在任一物件使用 toString() 吐出該物件的相關資訊。所以我們可以知道,這三個函式主要是用來作物件的識別。

equals()

從前文得知,在 Java中,當我們要比對兩個物件是否相同時,我們可以使用 equals() 來比對兩個物件,除了使用 equals() 之外,也可以使用 == 運算子來替代(在沒有覆寫 eauals() 的情況下)。

而在 Kotlin 的情況與 Java 類似,有點不太一樣的是類別的 equals() 就等於 == 運算子,也就是說當我們在 Kotlin 覆寫 equals() 之後, == 運算子的行為也跟著改變

那麼, 預設的 equals() 的實際情況是如何呢,請看下面 Player 類別的比較:

  • Java 的 Player 類別
public class Player {
private final String name;
private final int number;
public Player(String name, int number) {
this.name = name;
this.number = number;
}
}
final Player jordan1 = new Player("Jordan", 23);
final Player jordan2 = new Player("Jordan", 23);
final Player jordan1_clone = jordan1;
System.out.println(jordan1.equals(jordan2));
System.out.println(jordan1.equals(jordan1_clone));

上面的結果,會是

false
true
  • Kotlin 的 Player 類別
class PlayerK(private val name: String, private val number: Int)fun main() {
val jordan1 = PlayerK("Jordan", 23)
val jordan2 = PlayerK("Jordan", 23)
val jordan1_clone = jordan1
println(jordan1 == jordan2)
println(jordan1 == jordan1_clone)
}

結果同樣是

false
true

由上方的結果我們可以得知,預設的 equals() 會比對兩個物件是否為完全一樣,除了內容完全相同外,它們的記憶體位置也需要完全相同, 所以當我們用同樣的內容建立兩個不同的物件時,用 equals() 比對則是會是 false,除非是內容與記憶體位置皆相同的物件,那才是會回傳 true。

不過,有時候這樣子會有一些困擾,因為我們有時候不一定需要兩個完完全全相同的物件,我們希望比對的的只有內容,不需要考慮記憶體位置。在這個時候,我們就必須要覆寫 equals() ,讓開發者自行定義物件的比對方式。

在 Kotlin 中,資料類別 (data class) 是我們的好朋友,當我們想要把資料儲存在類別裡,又不希望自行覆寫 equals() 時,就可以選用資料類別。

Java 14 也推出了相同作用的紀錄類別 (record class)。

那麼,如果我們想要自行覆寫 equals() 呢?我們該怎麼做呢?

其實就跟覆寫一般的方法一樣,我們可以類別中,直接在 equals() 中實作我們想要的比對方式。

筆者使用 IntelliJ IDEA 嘗試覆寫 Java 的 equals() 的函式時,發現 IntelliJ IDEA 會自動幫忙產生 equals() 以及 hashCode() ,超方便的。

  • Java

研究從 IDE 中自動產生的程式碼,可以發現在比對之前,還額外做了一些檢查:1. == 運算子比對相同時,那就代表兩個物件具有相同的內容、記憶體位置,則直接回傳 true ,不需針對裡面的內容作比對。2. 比對兩個物件的類別是否相同,若否,則不需要繼續比較,縱使兩個物件比對的內容相等。3. 則是在這個類別要比對的內容,我們可以依照優先順序來排列,排在前面的可以是最容易產生不同值的屬性。

public class Player{
...
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1
if (!(o instanceof Player)) return false; // 2
Player player = (Player) o; // 3-1
return number == player.number && Objects.equals(name, player.name); // 3-2
}
@Override
public int hashCode() {
return Objects.hash(name, number);
}
}

Item 10 提到,覆寫 equals() 看起來容易,但是很容易出錯,避免出錯的方法就是不要覆寫 equals() (笑)。而自動產生 equals() 就幫我們了一個大忙(避免出錯)。

但是假如我們真的必須要覆寫 equals() 呢?有什麼方法(規範)可以讓我們把這個方法寫好,透過遵守通用規範(general contract),我們就可以覆寫出一個正確的 equals() 函式,那麼什麼是通用規範呢?

通用規範 (general contract)

從官方文件我們發現 equals() 需要遵守五項規範。

  1. 反身性(Reflexive):x.equals(x) == true,自己跟自己比較,其結果一定是 true。
  2. 對稱性(Symmetric):假如有兩個物件 x, y,當 x.equals(y) == true 時,那麼 y.equals(x) 必定也要是 true。
  3. 遞移性(Transitive):假設有三個物件 x、y、z,當 x.equals(y) == true,y.equals(z) == true 時,最後 x.equals(z) 一定也要是 true。
  4. 一致性(Consistent):兩個物件x、y互相比較,如果物件裡面的屬性沒有改變其內容,那麼多次呼叫 equals() 的結果必然相同。
  5. 如果 x 不為 空,那麼 x.equals(null) 則永遠為 false。

我們再回到自動產生的 equals()

@Override
public boolean equals(Object o) {
if (this == o) return true; // 1
if (!(o instanceof Player)) return false; // 2
Player player = (Player) o; // 3-1
return number == player.number && Objects.equals(name, player.name); // 3-2
}
  • 第一行: if(this == o) return true; 如果跟自己比較,就回傳 true。這行對應到的是反身性, x.equals(x) == true。
  • 第二行: if(!(o instanceof Player)) return false; 如果比對的物件的類別與自己不相同,那麼就回傳 false。

「對稱性」提到 x.equals(y) == true,y.equals(x) == true ; 反之亦然。如果有兩個類別,一個是 Player,另一個是繼承 Player 的 BaseballPlayer,在 BaseballPlayer 類別中,比 Player 還多了一個屬性 (Position),Position 屬性是用來紀錄該 BaseballPlayer 的守位,換句話說,Player 並不包含這個屬性可以比對。

思考一下,如果子類別的屬性比父類別的屬性還要多,那麼 parent.equals(child) == ? 以及 child.equals(parent) == ?。

範例:BaseballPlayer 類別

public class BaseballPlayer extends Player{
final String position;

public BaseballPlayer(String name, int number, String position) {
super(name, number);
this.position = position;
}
}

假設不檢查是否為相同類別,那麼 parent.equals(child) 會是為 true,而 child.equals(parent) 則是會為 false。因為前者的 parent 包含 child 所有的屬性,後者的 parent 則是沒有包含所有 child 的屬性,如此一來對稱性也就不成立了。

第二行對於比對 null 這個規範也會成立,因為如果比對的是 null,null 絕對沒有任何屬性,也與目標類別不同,所以回傳 false 也是正確的。

  • 當類別相同時,剩下的就是比對物件的屬性了,當屬性相同,理所當然的就應該要回傳 true,所以遞移性、一致性也同時會成立才對。

所以,當我們需要覆寫 equals() 時,我們可以使用 IDE 提供的工具來幫忙自動產生相對應的內容,如此一來,也就不會因為漏了什麼而造成非預期的錯誤發生,如果要自己覆寫 equals() 則必須要遵守通用規範的內容。

Item 11 — Always override hashcode when you override equals

當我們覆寫 equals() 時,要記得 hashCode() 也需要被覆寫。同樣地,覆寫 hashCode() 也有其通用規範(general contract)。

那麼,既然已經有 equals() 用來比對兩個物件,為什麼還需要 hashCode() 呢?原因就在於HashMap 以及 HashSet。

通用規範

  1. 同一個物件的 hashCode() 無論呼叫多少次,皆為相同,除非該物件的屬性修改,才會更改其 hashCode()。
  2. 兩個不同的物件,但是其內容相同,其 hashCode() 也必須要相同。
  3. 如果使用 equals() 比對就不相同,hashCode() 不一定需要不同的值(因為不會被呼叫到),但是在 HashMap 上,因為 JVM 會對裡面的值做優化,所以不同的物件包含不同的 hashCode() 也是必要的。

小結

Java 在比對物件時候,時常需要根據需求來覆寫 equals(),而在本書的 Item 10 中,告訴我們如何才能覆寫出一個符合通用規範的 equals(),在 Item 11 中則是更進一步的告訴我們,覆寫 equals() 就必須要覆寫 hashCode(),如此一來比對的時候才能正確無誤。

由於覆寫 equals() 以及 hashCode() 所要注意的事項有很多,所以推薦使用 IDE 的自動產生器,這樣可以減少出錯的機會。如果是在 Kotlin 上,更是可以直接使用我們的好朋友 data class 來替代冗長的覆寫。

--

--

Android/Flutter developer, like to learn and share.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Andy Lu

Andy Lu

Android/Flutter developer, like to learn and share.