(給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 應該如何命名?
目前的兩個備選方案:
- `Equalator`:參考 `Comparator`;
- `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` 改變了預設實現,破壞了可替代性
參考閱讀
朋友會在“發現-看一看”看到你“在看”的內容