1. 概述

在本篇文章中,我们将深入探讨两个紧密相关的 Java 方法:equals()hashCode()。重点在于它们之间的契约关系、如何正确地重写这两个方法,以及为什么我们要么同时重写,要么都不重写。

2. equals() 方法

默认情况下,Java 中的所有类都继承自 Object 类,因此每个类都隐式地拥有 equals()hashCode() 方法:

class Money {
    int amount;
    String currencyCode;
}
Money income = new Money(55, "USD");
Money expenses = new Money(55, "USD");
boolean balanced = income.equals(expenses);

我们可能会期望 income.equals(expenses) 返回 true,但以当前的实现来看,它并不会。

原因: Object 类中的 equals() 方法默认比较的是对象的引用(identity),而不是内容(value)。在这个例子中,incomeexpenses 是两个不同的对象实例,所以 equals() 返回 false

要改变这种行为,我们必须重写 equals() 方法。

2.1. 重写 equals()

让我们重写 equals() 方法,让它不仅比较对象身份,还要比较关键属性的值:

@Override
public boolean equals(Object o) {
    if (o == this)
        return true;
    if (!(o instanceof Money))
        return false;
    Money other = (Money)o;
    boolean currencyCodeEquals = (this.currencyCode == null && other.currencyCode == null)
      || (this.currencyCode != null && this.currencyCode.equals(other.currencyCode));
    return this.amount == other.amount && currencyCodeEquals;
}

✅ 上面的实现中,我们进行了三步判断:

  1. 如果是同一个对象,直接返回 true
  2. 如果不是 Money 类型的对象,返回 false
  3. 比较两个 Money 对象的 amountcurrencyCode 属性是否相等

2.2. equals() 的契约

Java SE 定义了 equals() 方法必须遵守的契约。虽然这些规则看起来符合常识,但我们仍需明确以下几点:

equals() 必须满足的条件:

  • 自反性(reflexive):对象必须等于自己
  • 对称性(symmetric)x.equals(y) 的结果必须和 y.equals(x) 相同
  • 传递性(transitive):若 x.equals(y)y.equals(z),则 x.equals(z) 也必须成立
  • 一致性(consistent):只要参与比较的属性没有改变,equals() 的结果就不能变

详细契约内容可查阅 Java SE 文档

2.3. 继承破坏 equals() 的对称性

尽管契约看起来很合理,但在使用继承时很容易违反这些规则。例如,我们定义一个继承自 MoneyWrongVoucher 类:

class WrongVoucher extends Money {

    private String store;

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof WrongVoucher))
            return false;
        WrongVoucher other = (WrongVoucher)o;
        boolean currencyCodeEquals = (this.currencyCode == null && other.currencyCode == null)
          || (this.currencyCode != null && this.currencyCode.equals(other.currencyCode));
        boolean storeEquals = (this.store == null && other.store == null)
          || (this.store != null && this.store.equals(other.store));
        return this.amount == other.amount && currencyCodeEquals && storeEquals;
    }

    // 其他方法...
}

现在来看这段代码的运行结果:

Money cash = new Money(42, "USD");
WrongVoucher voucher = new WrongVoucher(42, "USD", "Amazon");

voucher.equals(cash) => false // 正常
cash.equals(voucher) => true  // ❌ 错误!违反对称性

问题: 这违反了 equals() 的对称性契约。

2.4. 使用组合修复对称性问题

推荐做法: 避免通过继承来扩展类,优先使用组合。

我们将 Voucher 改为包含 Money 属性的类:

class Voucher {

    private Money value;
    private String store;

    Voucher(int amount, String currencyCode, String store) {
        this.value = new Money(amount, currencyCode);
        this.store = store;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Voucher))
            return false;
        Voucher other = (Voucher) o;
        boolean valueEquals = (this.value == null && other.value == null)
          || (this.value != null && this.value.equals(other.value));
        boolean storeEquals = (this.store == null && other.store == null)
          || (this.store != null && this.store.equals(other.store));
        return valueEquals && storeEquals;
    }

    // 其他方法...
}

✅ 这样修改后,equals() 方法的行为就符合契约了。

3. hashCode() 方法

hashCode() 方法返回一个整数,代表当前对象的“哈希码”。这个值应该与 equals() 的定义保持一致。

3.1. hashCode() 的契约

Java SE 同样为 hashCode() 定义了契约,且与 equals() 密切相关:

hashCode() 必须满足的条件:

  • 内部一致性(internal consistency):只要参与 equals() 判断的属性未变,hashCode() 的值就不能变
  • 相等一致性(equals consistency):如果两个对象通过 equals() 比较相等,那么它们的 hashCode() 必须相同
  • 冲突允许(collisions allowed):不相等的对象可以有相同的 hashCode()(哈希冲突)

3.2. 破坏 hashCode()equals() 一致性

最常见的问题之一是:**只重写了 equals() 而没有重写 hashCode()**。

来看一个例子:

class Team {

    String city;
    String department;

    @Override
    public final boolean equals(Object o) {
        // 实现省略
    }
}

问题: 虽然 Team 重写了 equals(),但使用的是默认的 hashCode() 实现。这意味着即使两个对象通过 equals() 判断为相等,它们也可能返回不同的哈希码。

3.3. HashMap 中的哈希码不一致问题

这在使用哈希集合(如 HashMap)时会导致严重问题。例如:

Map<Team,String> leaders = new HashMap<>();
leaders.put(new Team("New York", "development"), "Anne");
leaders.put(new Team("Boston", "development"), "Brian");
leaders.put(new Team("Boston", "marketing"), "Charlie");

Team myTeam = new Team("New York", "development");
String myTeamLeader = leaders.get(myTeam); // ❌ 返回 null

问题: 由于 Team 没有正确重写 hashCode()HashMap 无法找到对应的键值对。

解决方案: 重写 hashCode(),保证相等的对象返回相同的哈希码:

@Override
public final int hashCode() {
    int result = 17;
    if (city != null) {
        result = 31 * result + city.hashCode();
    }
    if (department != null) {
        result = 31 * result + department.hashCode();
    }
    return result;
}

✅ 现在 leaders.get(myTeam) 就能正确返回 "Anne"

4. 何时重写 equals()hashCode()

最佳实践: 要么两个方法都重写,要么都不重写。

  • 实体类(Entity):如果对象有唯一标识(如数据库主键),可以使用默认实现
  • 值对象(Value Object):如果对象的相等性由属性决定,应该重写这两个方法

还记得我们的 Money 类吗?55 USD 等于 55 USD,即使它们是两个不同的实例。

5. 实现辅助工具

手动实现这两个方法容易出错,推荐使用以下工具:

IDE 自动生成: 大多数 IDE(如 IntelliJ IDEA、Eclipse)都支持一键生成 equals()hashCode()

第三方库:

  • Apache Commons Lang
  • Google Guava
  • Project Lombok 提供的 @EqualsAndHashCode 注解
@EqualsAndHashCode
class Money {
    int amount;
    String currencyCode;
}

✅ 可以看到,equals()hashCode() 是“成对出现”的,甚至有一个共同的注解。

6. 验证契约

可以使用 EqualsVerifier 库来验证契约是否被正确实现。

✅ 添加 Maven 依赖:

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.15.3</version>
    <scope>test</scope>
</dependency>

✅ 使用方式:

@Test
public void equalsHashCodeContracts() {
    EqualsVerifier.forClass(Team.class).verify();
}

⚠️ 注意:EqualsVerifier 的默认配置比 Java SE 契约更严格,例如它要求字段不可变、方法不能抛出 NullPointerException 等。

7. 总结

本文总结了 equals()hashCode() 的契约和最佳实践:

关键要点:

  • 重写 equals() 时,必须同时重写 hashCode()
  • 对于值对象,务必重写这两个方法
  • 避免通过继承破坏 equals() 的对称性
  • 推荐使用 IDE 或第三方库自动生成
  • 使用 EqualsVerifier 验证实现是否合规

📦 所有示例代码可在 GitHub 查看。


原始标题:Java equals() and hashCode() Contracts