Перейти к основному содержимому

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

· 8 мин. чтения

Задача: Наибольшая подстрока без повторений

Для заданной строки s, найдите длину наибольшей подстроки без повторяющихся символов. Подстрока — это непрерывная непустая последовательность символов внутри строки...

ANDROMEDA 42

1. Обзор

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

2. равно()

Класс 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)

Мы ожидали бы, что return.equals(расходы) вернет true, но с классом Money в его текущей форме этого не произойдет.

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

2.1. Переопределение равенства()

Давайте переопределим метод 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. равно() Контракт

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 с Money или Voucher с Voucher . Но что произойдет, если мы сравним эти два объекта:

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
}

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

3. хэш-код()

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

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

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

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

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

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

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 с несогласованным хэш- кодом ()

Но почему нарушение контракта в нашем классе 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 вернет «Энн», но с текущим кодом это не так.

Если мы хотим использовать экземпляры класса Team в качестве ключей 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) возвращает «Энн», как и ожидалось.

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() для объектов-значений
  • Помните о ловушках расширенных классов, которые переопределяют equals() и hashCode() .
  • Рассмотрите возможность использования IDE или сторонней библиотеки для создания методов equals() и hashCode() .
  • Рассмотрите возможность использования EqualsVerifier для тестирования нашей реализации.

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