软件开发架构师

equals() 和 hashCode() 实现有什么问题?有什么解决办法?

java 101 2019-06-01 02:03

(给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<T> {
boolean equivalent(T self, Object other);
int hash(T self);
}
```


使用这个“假想” API 后 `Point` 代码会变成下面这样:


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

private static final Equivalence<Point> 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<Point> 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<Point> 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<Object>` 避免强制转化(尴尬的地方在于,这样牺牲了额外的类型安全检查)。


附录


示例实现


下面的代码只作阐明想法使用:


```java
interface Equivalence<T> {

boolean equivalent(T self, Object other);

int hash(T self);

static <T> Equivalence<T> of(Class<T> clazz, Function<T, ?>... decomposers) {
return new Equivalence<T>() {
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` 改变了默认实现,破坏了可替代性


参考阅读




推荐阅读

(点击标题可跳转阅读)

JVM 解剖公园:初始化开销

Kotlin 与 Java:哪个更合适

按 CompletableFuture 完成顺序实现 Streaming Future


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

文章评论