歡迎光臨
每天分享高質量文章

equals() 和 hashCode() 實現有什麼問題?有什麼解決辦法?

(給ImportNew加星標,提高Java技能)

編譯:ImportNew/唐尤華

cr.openjdk.java.net/~cushon/amber/equivalence.html

 

本文介紹了 `equals()` 和 `hashCode()` 實現的常見問題,並提出了 Equivalence API 作為一種解決辦法。

 

背景

 

要正確實現 `equals()` 和 `hashCode()` 需要太多繁文縟節。

 

不僅實現起來費時費力,維護成本也很高。雖然 IDE 可以幫助生成初始代碼,但是當類發生變化時,還是需要閱讀、除錯這些代碼。隨著時間推移,這些方法會成為隱蔽的 bug(詳見附錄 bug 串列)。

 

以下麵這個普通的 `Point` 類為例,展示瞭如何正確實現 `equals()` 和 `hashCode()`:

 

```java
class Point {
  final int x;
  final int y;

  Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  @Override public boolean equals(Object other) {
    if (!(other instanceof Point)) {
      return false;
    }
    Point that = (Point) other;
    return x == that.x && y == that.y;
  }

  @Override public int hashCode() {
    return Objects.hash(x, y);
  }
}
```

 

標的

 

本文中的提案旨在創建一個可讀性強、功能正確、高效的 `equals()` 和 `hashCode()` 開發庫。

 

次要標的,為已定義型別提供一種新的 `equals()` 和 `hashCode()` 等價(equivalence)定義。API 接口中的方法用來進行等價性測試並計算每個實體的 hashCode。

 

警告:示例 API 的所有細節都非最終版本,只為展示提案的核心功能。”

 

```java
interface Equivalence {
  boolean equivalent(T self, Object other);
  int hash(T self);
}
```

 

使用這個“假想” API 後 `Point` 代碼會變成下麵這樣:

 

```java
class Point {
  int x;
  int y;

  private static final Equivalence EQ =
      Equivalence.of(Point.class, p -> p.x, p -> p.y);

  @Override public boolean equals(Object other) {
    return EQ.equivalent(this, other);
  }

  @Override public int hashCode() {
    return EQ.hash(this);
  }
}
```

 

未來,類似 `Point` 這樣值類(value class)有希望成為 [record][1] 這樣的資料類(data class)。但總會有一些情況會要求實現 `equals` 和 `hashCode`,無法轉化為 record。本文的提案是對 `record` 一種友好的補充,有助於避免手工實現 `equals` 和 `hashCode`。

 

> 譯註:`record` 是 Brian Goetz 在 2019.2 提出的一種資料類 Data Class。類似 Kotlin 中的 data class。

 

[1]:https://cr.openjdk.java.net/~briangoetz/amber/datum.html

 

哪些不是本文的標的

 

要達成標的還可以增加語言擴展或編譯器支持,也許性能上會更好,但這些不屬於本文的討論範圍。本文的標的是通過開發庫來解決並達到最佳效果。

 

Java 未來可能支持“欄位取用(field reference)”,比如 `Foo::x` 這裡 `x` 表示一個欄位。Equivalence API 很好地契合了這個新特性並提供支持。但是新特性的細節不在本文的討論範圍內。

 

需求

 

API 是否應該同時支持 equals() 和 hashCode(),還是只支持 equals()?

同時支持 `equals()` 和 `hashCode()` 的優點在於可以避免開發中經常遇到的 bug。那種認為 `hashCode` 的實現比 `equals` 更可靠的觀點是不正確的。在 `equals()` 和 `hashCode()` 實現中採取單一規範的狀態串列不僅能減少樣板代碼,更是關乎代碼的正確性。

 

(與 `Comparator` 共享 `equals()` 和 `hashCode()` 的狀態是很有意思的一件事情。相關內容參見下文“與 Comparator 的關係”)

 

> 譯註:這裡的狀態 state,可簡單理解為物件中的屬性。

 

API 是否應該支持自定義比較函式?

 

API 可以一直使用 `Object.equals` 和 `Object.hashCode`,也可以採用與狀態相關的自定義 `comparator` 實現。例如,在比較 `String` 欄位時要求不區分大小寫。

 

```java
private static final Equivalence EQ =
    Equivalence.forClass(Person.class)
        .andThen(Person::age)
        .andThen(Person::name, CASE_INSENSITIVE_EQ); // 也是 Equivalence 型別
```

 

(使用自定義 `comparator` 的另一個例子是陣列。通常會用 `Arrays.deepEquals` 和 `Object.deepHashCode` 替換 `Object.equals` 和 `Object.hashCode`。由於陣列是一種常見資料結構,在 API 中優先考慮陣列是很自然的事情。下麵會對此進行詳細討論)

 

在 hashCode 實現中忽略一些狀態?

 

`hashCode` 實現中的狀態必須是 `equals` 狀態的子集。在 `hashCode` 中使用合適的子集能夠更快更好地生成哈希值。看起來像下麵這樣:

 

```java
private static final Equivalence EQ =
    Equivalence.forClass(Point.class)
        .andThen(Point::x)
        .andThen(Point::y, Equivalence.using(Objects::equals, x -> 0));
```

 

可以考慮為這種用法增加 API 支持,例如,`Equivalence.forClass(Point.class).andThen(Point::x).butNotHashing(Point::y)`,但沒有必要支持到這種程度。這種用法並不常見,而且 hash 函式的最佳實踐已經可以避免細小的碰撞。即使不增加 API 也已經可以實現。

 

是否應該支持自定義 hash reduce 函式?

 

傳統的 `hashCode()` 實現會採用 `(x, y) -> 31 * x + y` 組合每個狀態。通常這是一種不錯的選擇,目前沒有看到令人信服的定製理由。無論採用哪種實現方式,都絕不應當給 hash reduce 函式指定預設實現,準備在將來對其改進。

 

(一種較為激進的方法是每次 JVM 呼叫都可以指定 hash 種子,以便進行測試。最近幾年,Google 已經在我們的測試中對 hash 迭代順序進行隨機化並且取得了不錯的效果)

 

在 equals 中使用 instanceof 還是 getClass()?

 

實現 `equals` 時,可以選擇 `instanceof` 或者 `getClass()`,也可以交由實現者決定。這裡不討論哪種實現更正確。

 

幸運的是,有一種簡單的方法有助於選擇。`instanceof` 作為預設值會更靈活,因為這樣用戶可以在 `Equivalence` 鏈式檢查中呼叫 `getClass()`,或者作為 `Equivalence.equals` 呼叫前的守護條件,例如:

 

```java
this.getClass() == o.getClass() && EQ.equivalent(this, o);
```

 

反過來用 `getClass()` 無法做到這點。

 

如何處理 null?

 

為了確保對稱性,實現 `Object.equals()` 時,`equivalent(nonNull, null)` 必須安全地傳回 `false`。`equivalent(null, null)` 應該傳回 `true` 而不是丟擲異常,這樣可以盡可能確保一致性,不出現意料之外的結果。

 

與 Comparator 的關係?

 

Comparator 和 Equivalence 有一些明顯的相似之處:都支持從物件實體中提取狀態,分別用於 `compareTo` 和 `equals/hashCode`。

 

還有一些原因可以解釋為什麼必須把它們作為完全獨立的 API 處理,而不是作為泛化(generalization)處理。

 

`Comparator` 可以通過 `x.compareTo(y) == 0` 實現 `Equivalence` 中的部分等價功能,但不能實現 `hashCode`。如果讓 `Comparator` 繼承 `Equivalence`,在呼叫 `hashCode` 時將丟擲 `UnsupportedOperationException`。

 

也可以讓 `Equivalence` 實現 `Comparator`,可以在比較函式里測試相關的狀態。然而,這裡的問題在於 `Equivalence` 中的比較函式會與 `Comparator` 功能重疊,而且想要比較的內容也許只是 `equals` 與 `hashCode` 中狀態的子集。

 

第三種辦法,同時創建 `Equivalence`、`Comparator` 以及一個狀態串列,需要一個公用父類。這樣不但增加了代碼的複雜度,而且很可能對概念產生混淆。

 

設計相關問題

 

API 應該如何命名?

 

目前的兩個備選方案:

 

  1. `Equalator`:參考 `Comparator`;
  2. `Equivalence`:型別的實體是等價關係。

 

我們的觀點是,數學中有已經有了一個眾所周知的定義,沒必要再造一個新詞。

 

陣列應該比較內容相等還是取用相等?

 

註意:“這個問題實際上討論的是預設實現。由於 `equals` 和 `hashCode` 可以根據具體欄位定製實現,因此可以自由選擇。

 

這裡至少有兩派意見,本文只提供選項並不打算解決爭論。

 

在陣列上呼叫 `Object.{equals,hashCode}` 實際上是一個 bug,因此增加一個引數檢查陣列並自動呼叫 `Arrays.{deepEquals,deepHashCode}` 能夠幫助用戶避免 bug(通過靜態分析檢查避免在陣列上呼叫 `Object.{hashCode,equals}` 是用戶期待的結果)。

 

反對者認為,這麼做會讓陣列使用更複雜。無論如何,使用者需要瞭解陣列應當判斷取用相等。如果只在這一個地方幫助用戶,那麼可能會顧此失彼,給他們帶來麻煩。值得註意的是,Kotlin [採用了這種方法][2]。

 

[2]:https://blog.jetbrains.com/kotlin/2015/09/feedback-request-limitations-on-data-classes/

 

自定義比較

 

`Equivalence` 是否應當避免“裝箱和可變引數”開銷?例如,提供像 `IntEquivalence` 這樣專門的接口,多載 `andThenInt(IntFunction)` 和 `andThenInt(IntFunction, IntEquivalence)`  builder 方法。

 

在某些情況下,這麼做能夠達到預期的性能。另一方面,又大大增加了 API 的複雜性。

 

既不增加 API 複雜性,又能滿足性能要求,一種可能的方法是考慮轉換策略:

 

equivalent(T, Object) 或者 equivalent(T, T)

 

有兩種函式實現 `Equivalence` 等價:`equivalent(T, Object)` 和 `equivalent(T, T)`

 

使用 `equivalent(T, Object)` 可以在實現 `Object#equals` 時減少模板代碼。我們希望更多地使用 `Equivalence` 實現而非 `Comparators`,後者只針對特殊場合適用(配合[concise 方法][3]實現會變得更簡單)。

 

[3]:https://openjdk.java.net/jeps/8209434

 

```java
public boolean equals(Object other) {
  return EQ.equivalent(this, other);
}
```

 

或者:

 

```java
public boolean equals(Object other) = EQ::equivalent;
```

 

`equivalent(T, T)` 的優點,除 `Object#equals` 以外的方法都更簡潔,提供額外的型別安全檢查。同時,由於型別檢查與使用獨立,還避免了在 `getClass()` 與 `instanceof` 之間進行選擇。

 

```java
public boolean equals(Object other) {
  return other instanceof Foo that && EQ.equivalent(this, that);
}
```

 

或者:

 

```java
public boolean equals(Object other) ->
    other instanceof Foo that && EQ.equivalent(this, that);
```

 

另一種選擇是使用 `equivalent(T, T)`,在實現 `Object.equals` 前轉換為 `Equivalence` 避免強制轉化(尷尬的地方在於,這樣犧牲了額外的型別安全檢查)。

 

附錄

 

示例實現

 

下麵的代碼只作闡明想法使用:

 

```java
interface Equivalence {

 boolean equivalent(T self, Object other);

 int hash(T self);

 static  Equivalence of(Class clazz, Function... decomposers) {
   return new Equivalence() {
     public boolean equivalent(T self, Object other) {
       if (!clazz.isInstance(other)) {
         return false;
       }
       T that = clazz.cast(other);
       return Arrays.stream(decomposers)
           .allMatch(d -> Objects.equals(d.apply(self), d.apply(that)));
     }

     public int hash(T self) {
       return Arrays.stream(decomposers)
           .map(d -> Objects.hashCode(d.apply(self)))
           .reduce(17, (x, y) -> 31 * x + y);
     }
   };
 }
}
```

 

equals 和 hashCode 實現中的 bug

 

我們在 `equals` 和 `hashCode` 方法的實現中發現了許多 bug,通常可以通過靜態代碼分析找到這些問題。

 

其中一些 bug 事後看來是顯而易見的,不大可能發生在有經驗的 Java 開發者身上,但它們的確出現了。一個原因可能是 `equals` 和 `hashCode` 通常被當作模板檔案,因而對它們的檢查不夠仔細。隨著時間推移,類不斷修改 bug 會隨之出現。

 

  • 重寫 `Object.equals()`,但沒有重寫 `hashCode()`(`Object.hashCode` 要求,如果兩個物件相等,那麼兩個物件中任意一個物件呼叫 `hashCode()` 必須產生相同的結果,只重寫 `equals()` 顯然無法做到這點)
  • `equals` 實現無限遞迴(應該有意識地使用 `==` 而非 `this.equals(other).`)
  • 比較欄位或 getter 方法時沒有配對,例如 `a == that.a && b == that.a`
  • 傳入 `null` 作為引數時 `equals` 丟擲 `NullPointerException`(應該傳回 false)
  • 傳入錯誤型別的引數時, `equals` 丟擲 `ClassCastException`(應該傳回 false)
  • 實現 `equals` 方法時呼叫了 `hashCode()`(頻繁產生哈希衝突,導致誤報)
  • `hashCode()` 包含沒有在 `equals()` 方法中測試的狀態(物件相等 hashCode() 必須相同)
  • `equals` 和 `hashCode` 實現,對陣列成員比較取用相等或 hashCode 相等(用戶可能希望比較的是值和 hashCode 相等)
  • 其他 bug:使用錯誤,例如比較兩種不同的型別;或者定義錯誤,例如重寫 `equals` 改變了預設實現,破壞了可替代性

 

參考閱讀

 

 

赞(0)

分享創造快樂