«1. Обзор

В этом руководстве мы познакомим вас с двумя тесно связанными между собой методами: equals() и hashCode(). Мы сосредоточимся на их отношениях друг с другом, на том, как правильно их переопределить и почему мы должны переопределять оба или ни то, ни другое.

2. 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)

Мы ожидаем, что yield.equals(расходы) вернет true. Но с классом Money в его нынешнем виде этого не произойдет.

Реализация equals() по умолчанию в классе Object говорит, что равенство — это то же самое, что идентификация объекта. А доходы и расходы — это две разные инстанции.

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

2.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 для класса Object.

2.3. Нарушение симметрии equals() с наследованием

Если критерии для equals() настолько здравы, как мы вообще можем их нарушать? Что ж, чаще всего нарушения случаются, если мы расширяем класс, который переопределил equals(). Давайте рассмотрим класс Voucher, расширяющий наш класс Money:

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
}

На первый взгляд, класс Voucher и его переопределение для 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.

Это нарушает критерии симметрии контракта equals().

2.4. Исправление симметрии equals() с композицией

Чтобы избежать этой ловушки, мы должны предпочесть композицию наследованию.

Вместо подкласса Money создадим класс 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;
    }

    // other methods
}

Теперь equals будет работать симметрично, как того требует контракт.

3. hashCode()

hashCode() возвращает целое число, представляющее текущий экземпляр класса. Мы должны вычислить это значение в соответствии с определением равенства для класса. Таким образом, если мы переопределим метод equals(), нам также придется переопределить hashCode().

Для получения дополнительной информации ознакомьтесь с нашим руководством по hashCode().

3.1. Контракт hashCode()

Java SE также определяет контракт для метода hashCode(). Тщательный взгляд на него показывает, насколько тесно связаны hashCode() и equals().

Все три критерия в контракте hashCode() некоторым образом упоминают метод equals():

    внутренняя согласованность: значение hashCode() может измениться только в том случае, если свойство, находящееся в equals(), изменяется на равно согласованность : объекты, которые равны друг другу, должны возвращать одинаковые коллизии hashCode: неравные объекты могут иметь одинаковый hashCode

3.2. Нарушение непротиворечивости методов hashCode() и equals()

Второй критерий контракта методов hashCode имеет важное следствие: если мы переопределяем equals(), мы также должны переопределять hashCode(). И это, безусловно, самое распространенное нарушение контрактов методов equals() и hashCode().

Давайте рассмотрим такой пример:

class Team {

    String city;
    String department;

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

Класс Team переопределяет только метод equals(), но по-прежнему неявно использует реализацию hashCode() по умолчанию, определенную в классе Object. И это возвращает другой hashCode() для каждого экземпляра класса. Это нарушает второе правило.

Теперь, если мы создадим два объекта Team, оба с городом «Нью-Йорк» и отделом «маркетинг», они будут равны, но будут возвращать разные хэш-коды.

«3.3. Ключ HashMap с несовместимым hashCode()

Но почему нарушение контракта в нашем классе Team является проблемой? Что ж, проблемы начинаются, когда задействованы некоторые коллекции на основе хэшей. Давайте попробуем использовать наш класс Team в качестве ключа 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);

Мы ожидаем, что 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», как и ожидалось.

4. Когда мы переопределяем equals() и hashCode()?

Как правило, мы хотим переопределить либо оба из них, либо ни один из них. Мы только что видели в разделе 3 нежелательные последствия, если мы игнорируем это правило.

Дизайн, ориентированный на предметную область, может помочь нам решить обстоятельства, когда мы должны оставить их в покое. Для классов сущностей — для объектов, имеющих внутреннюю идентичность — реализация по умолчанию часто имеет смысл.

Однако для объектов-значений мы обычно предпочитаем равенство на основе их свойств. Таким образом, вы хотите переопределить equals() и hashCode(). Помните наш класс Money из раздела 2: 55 долларов США равняется 55 долларам США, даже если это два разных экземпляра.

5. Помощники реализации

Обычно мы не пишем реализацию этих методов вручную. Как видно, подводных камней довольно много.

Один из распространенных способов — позволить нашей IDE сгенерировать методы equals() и hashCode().

Apache Commons Lang и Google Guava имеют вспомогательные классы для упрощения написания обоих методов.

Project Lombok также предоставляет аннотацию @EqualsAndHashCode. Обратите внимание еще раз, как equals() и hashCode() «идут вместе» и даже имеют общую аннотацию.

6. Проверка контрактов

Если мы хотим проверить, соответствуют ли наши реализации контрактам Java SE, а также некоторым рекомендациям, мы можем использовать библиотеку EqualsVerifier.

Давайте добавим тестовую зависимость EqualsVerifier Maven:

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

Давайте проверим, что наш класс Team следует контрактам equals() и hashCode():

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

Стоит отметить, что EqualsVerifier проверяет обе функции equals( ) и hashCode().

EqualsVerifier намного строже, чем контракт Java SE. Например, он гарантирует, что наши методы не могут генерировать исключение NullPointerException. Кроме того, он обеспечивает, чтобы оба метода или сам класс были окончательными.

Важно понимать, что конфигурация EqualsVerifier по умолчанию допускает только неизменяемые поля. Это более строгая проверка, чем позволяет контракт Java SE. Это соответствует рекомендации Domain-Driven Design, чтобы сделать объекты-значения неизменяемыми.

Если мы считаем, что некоторые из встроенных ограничений не нужны, мы можем добавить подавление (Warning.SPECIFIC_WARNING) к нашему вызову EqualsVerifier.

7. Заключение

В этой статье мы обсудили контракты equals() и hashCode(). Мы должны помнить:

    Всегда переопределяйте hashCode(), если мы переопределяем equals(). Переопределяйте equals() и hashCode() для объектов-значений. IDE или сторонняя библиотека для создания методов equals() и hashCode() Рассмотрите возможность использования EqualsVerifier для тестирования нашей реализации

Наконец, все примеры кода можно найти на GitHub.