Java equals () и hashCode () контракты

Контракты Java equals () и hashCode ()

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)

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

Реализация по умолчаниюequals() в классеObject говорит, что равенство совпадает с идентичностью объекта. Иincome иexpenses - два разных экземпляра.

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(). Most of the criteria are common sense. Методequals() должен быть:

  • reflexive: объект должен быть самим собой

  • symmetric:x.equals(y) must return the same result as y.equals(x)

  • transitive: еслиx.equals(y) иy.equals(z), то такжеx.equals(z)

  • consistent: значениеequals() должно изменяться только в случае изменения свойства, содержащегося вequals() (случайность не допускается)

Мы можем найти точные критерии вJava SE Docs for the Object class.

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

Если критерийequals() настолько здравый, как мы вообще можем его нарушить? Итак,violations happen most often, if we extend a class that has overridden 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 сMoney илиVoucher сVoucher. But what happens, if we compare these two objects?с

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() с помощью композиции

Чтобы избежать этой ловушки, мы должны favor composition over inheritance.

Вместо создания подкласса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 will будет работать симметрично, как того требует договор.

3. hashCode()с

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

Для получения дополнительных сведений ознакомьтесь с нашимguide to hashCode().

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

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

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

  • internal consistency: значениеhashCode() может измениться только при изменении свойства, которое находится вequals()

  • equals consistency: * объекты, которые равны друг другу, должны возвращать один и тот же hashCode *

  • collisions:unequal objects may have the same hashCode

3.2. Нарушение согласованностиhashCode() иequals()

Второй критерий контракта методов hashCode имеет важное последствие:If we override equals(), we must also override hashCode().. И это, безусловно, наиболее распространенное нарушение в отношении контрактов методовequals() иhashCode().

Посмотрим на такой пример:

class Team {

    String city;
    String department;

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

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

Теперь, если мы создадим два объектаTeam, как с городом «Нью-Йорк», так и с отделом «маркетинг», они будут равны,but they will return different hashCodes.

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

Но почему нарушение контракта в нашем классеTeam является проблемой? Ну, проблема начинается, когда участвуют некоторые коллекции на основе хеша. Давайте попробуем использовать наш классTeam в качестве ключаHashMap:

Map 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 вернет «Энн». But with the current code, it doesn’t.

Если мы хотим использовать экземпляры классаTeam в качестве ключейHashMap, мы должны переопределить методhashCode(), чтобы он придерживался контракта:Equal objects return the same 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()?

Generally, we want to override either both of them or neither of them. В разделе 3 мы только что видели нежелательные последствия, если мы проигнорируем это правило.

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

Однакоfor value objects, we usually prefer equality based on their properties. Таким образом, вы хотите переопределитьequals() иhashCode(). Помните, что наш классMoney из раздела 2: 55 долларов США равняются 55 долларам США, даже если это два отдельных экземпляра.

5. Помощники по внедрению

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

Один из распространенных способов -let our IDE сгенерировать методыequals() иhashCode().

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

Project Lombok также предоставляет аннотацию@EqualsAndHashCode. Note again how equals() and hashCode() “go together” and even have a common annotation.

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

Если мы хотим проверить, соответствуют ли наши реализации контрактам Java SE, а также некоторым лучшим практикам,we can use the EqualsVerifier library.

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


    nl.jqno.equalsverifier
    equalsverifier
    3.0.3
    test

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

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

Стоит отметить, чтоEqualsVerifier тестирует как методыequals(), так иhashCode().

EqualsVerifier is much stricter than the Java SE contract. Например, он гарантирует, что наши методы не могут генерироватьNullPointerException.. Кроме того, он обеспечивает, чтобы оба метода или сам класс были окончательными.

Важно понимать, чтоthe default configuration of EqualsVerifier allows only immutable fields. Это более строгая проверка, чем допускает контракт Java SE. Это соответствует рекомендации доменно-управляемого дизайна, чтобы сделать объекты значения неизменяемыми.

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

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

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

  • Всегда отменятьhashCode(), если мы отменяемequals()

  • Переопределитьequals() иhashCode()  для объектов значений

  • Помните о ловушках расширения классов, которые переопределилиequals() иhashCode()

  • Рассмотрите возможность использования IDE или сторонней библиотеки для создания методовequals() иhashCode()

  • Попробуйте использовать EqualsVerifier для тестирования нашей реализации

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