跳至主要內容

19. Java equals和hashCode方法

LiuSongLing大约 7 分钟javajava

比较对象是面向对象编程语言的基本特征。

在本教程中,我们将介绍两种紧密结合的方法:.equals()和.hashCode()。我们将专注于他们彼此的关系,如何正确覆盖他们,以及为什么我们应该同时覆盖两者或两者都不覆盖。

1..equals()方法

默认情况下,顶级父类Object类定义了.equals()和.hashCode()方法。因此,每个Java类都隐式地拥有这两种方法

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,但随着Money类的当前方法,并不会。

Object类中equals()的默认实现比较了对象的身份。在我们的示例中,货币类的收入和支出实例有两种不同的身份。因此,将它们与.equals()方法进行比较,返回false。

要改变这种行为,我们必须覆盖此方法。

1.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;
}

上文,我们有三个条件来检查Money实例是否与任何其他对象相同。

首先,如果对象与自身相等,它将返回true。 其次,如果它不是金钱的实例,它将返回false。 最后,我们将它与另一个Money类实例的属性进行比较。详细地说,我们确保比较类的所有属性都与比较类的属性相匹配。

1.2 equals方法约定

Java SE定义了我们实现的equals()方法必须履行的约定。简而言之,大多数标准遵循常识,但我们可以定义equals()方法必须遵循的形式规则。它必须是:

  • 反射:一个物体必须等于自己
  • 对称:x.equals(y)必须返回与y.equals(x)相同的结果
  • 传递:如果x.equals(y)和y.equals(z),那么也x.equals(z)
  • 一致:仅当.equals()中包含的属性发生变化时,.equals()的值才应更改(不允许随机性)

我们可以在Java SE Docs中查找Object类的确切标准。







1.3 继承类可能违反equals方法的对称性

.equals()的标准是常识性的,当我们子类扩展了一个覆盖了.equals()方法的类时,有可能违反.equals()约定。让我们考虑一个扩展我们货币类的凭证类:

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;
    }

    // other methods
}

乍一看,凭证类及其对.equals()的覆盖似乎是正确的。只要我们将金钱与金钱或代金券与代金券进行比较,两种.equals()方法的行为都是正确的。但是,如果我们比较这两个物体,会发生什么:

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

voucher.equals(cash) => false // As expected.
cash.equals(voucher) => true // That's wrong.

因此,我们违反了对称性标准。

1.4 固定 equals() 与组成对称

为了避免犯错,我们应该偏爱将类组成另一个类的一部分而不是继承。

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;
    }

    // other methods
}

现在.equals()将按照约定要求对称地工作。







2..hashCode()方法

**Java SE还定义了.hashCode()方法的约定。**仔细研究这份约定会发现.hashCode()和.equals()的相关性有多密切。

.hashCode()合同中的所有三个标准都以某种方式提到了.equals()方法:

  • 内部一致性:只有当 equals()中的属性发生变化时,hashCode()的值才可能发生变化
  • 相等一致性:彼此相等的对象必须返回相同的哈希代码
  • 碰撞:不相等的对象可能具有相同的哈希代码

2.1 违反hashCode()和equals()的一致性

.hashCode()合同的第二个标准有一个重要的结果:如果我们覆盖equals(),我们也必须覆盖hashCode()。这是迄今为止对equals()和hashCode()方法约定最普遍的违反方式。

class Team {

    String city;
    String department;

    @Override
    public final boolean equals(Object o) {
        // implementation
    }
}

Team类仅覆盖等于(),但它仍然隐式使用Object类中定义的hashCode()的默认实现。因此,它将为类的每个实例返回不同的hashCode(),并违反第二条规则。

现在,如果我们创建两个团队对象,都带有城市“纽约”和部门“营销”,它们将是相等的,但它们将返回不同的哈希代码。

2.2 带有不一致的hashCode()的HashMap key

但为什么我们 Team 的约定违规是个问题?好吧,当涉及一些基于哈希的集合时,麻烦就开始了。让我们尝试使用我们的 Team类作为HashMap的key:

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);

我们希望myTeamLeader返回“Anne”,但以当前代码,它不会。

如果我们想将Team类的实例用作HashMap键,我们必须覆盖hashCode()方法,使其遵守合同;相等的对象返回相同的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”。







3.覆盖.equals()和.hashCode()?

一般来说,我们希望同时覆盖.equals()和.hashCode(),或者两者都不覆盖。

我们刚刚在第2节中看到,如果我们忽视这条规则,会带来不必要的后果。

对于实体类,对于具有内在标识的对象,默认实现通常是有意义的。

然而,对于值对象,我们通常更喜欢基于其属性的相等。因此,我们希望覆盖.equals()和.hashCode()。

记住我们第1节中的货币类:55美元等于55美元,即使它们是两个不同的实例。

4.覆盖帮助

我们通常不会手动编写这些方法的实现。正如我们所看到的,稍不注意就有相当多的陷阱。

常见的选择是让我们的IDE代码编辑器帮忙生成.equals()和.hashCode()方法。

Apache Commons LangGoogle Guava有辅助类,可以使用这两种方法简化写作。

Lombok项目还提供了 @EqualsAndHashCode 注释。

5.验证约定

如果我们想检查我们的实现是否符合Java SE合同和最佳实践,我们可以使用EqualsVerifier库。

让我们添加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同时测试equals()和hashCode()方法。

**EqualsVerifier比Java SE合同严格得多。**例如,它确保我们的方法不能抛出NullPointerException。此外,它强制要求两种方法或类本身都是最终的。

重要的是要意识到,EqualsVerifier的默认配置只允许不可变的字段。 这是比Java SE合同允许的更严格的检查。它坚持域驱动设计的建议,使价值对象不可变。

如果我们发现一些内置约束是不必要的,我们可以在EqualsVerifier调用中添加一个抑制(Warning.SPECIFIC_WARNING)。