Java equals() и hashCode()
Правильно переопределяйте equals() и hashCode() в классах Java для работы с коллекциями и сравнения по значению.
equals и hashCode — два метода класса Object, на которые молча опираются коллекции на основе хэширования: HashMap, HashSet, LinkedHashMap и всё остальное, построенное на хэшировании. Реализуйте их правильно — и ваши объекты будут вести себя как значения: set.contains(point) найдёт точку независимо от того, какой экземпляр new Point(3, 4) вы передадите. Реализуйте неправильно — получите дубликаты в множествах, потерянные ключи в картах и ошибки, которые проявляются только под нагрузкой.
По умолчанию, унаследованный от Object, метод сравнивает идентичность: две ссылки равны только тогда, когда указывают на один и тот же объект. Это нормально для таких вещей, как соединения с базой данных, где каждый экземпляр является отдельным ресурсом. Для классов-значений — денег, точек, имён, дат — почти всегда нужно равенство по содержимому, а значит, нужно переопределять оба метода вместе.
Контракт
equals должен удовлетворять четырём правилам:
- Рефлексивность —
x.equals(x)возвращает true. - Симметричность —
x.equals(y)тогда и только тогда, когдаy.equals(x). - Транзитивность — если
x.equals(y)иy.equals(z), тоx.equals(z). - Согласованность — повторные вызовы с неизменёнными полями дают одинаковый результат.
Плюс: x.equals(null) должен возвращать false, но никогда не бросать исключение.
Для hashCode действует одно правило, связывающее его с equals:
- Равные объекты должны иметь равные хэш-коды. Неравные объекты могут иметь одинаковый хэш-код (коллизии допустимы, но плохо влияют на производительность).
Именно это единственное правило объясняет, почему нельзя переопределить один метод без другого. Если a.equals(b), но a.hashCode() != b.hashCode(), то HashSet поместит их в разные корзины, contains найдёт не то, что нужно, — и у вас появится объект-призрак.
Смотрите, как нарушается контракт
Этот класс переопределяет equals, но забывает про hashCode, поэтому наследует хэш на основе идентичности из Object. Два объекта «равны», но попадают в разные корзины — contains не может найти только что добавленный объект:
equals говорит, что объекты одинаковы, но множество не может найти второй из них. Переопределите hashCode в соответствии с equals — и поиск будет успешным.
Анатомия корректного equals
Рабочий equals следует стандартной схеме:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}Шаг за шагом:
- Быстрая проверка идентичности.
this == oбыстро обрабатывает частый случай. - Проверка типа с привязкой.
instanceof Point pодним выражением отклоняет null и неподходящие типы, привязывая суженную ссылку. - Сравнение полей. Используйте
==для примитивов,Objects.equals(a, b)для nullable-ссылок,Float.compare/Double.compareдля чисел с плавающей точкой.
Objects.hash(...) строит хэш из списка полей. Это немного медленнее, чем написанный вручную код с XOR/умножением, но зато корректно и однозначно.
getClass или instanceof?
Два подхода:
instanceofпозволяет экземпляру подкласса быть равным экземпляру родительского класса, если набор сравниваемых полей совпадает. Чуть более гибко.getClass()требует точного совпадения типа времени выполнения. Легче обеспечить симметричность в иерархиях, но нарушает принцип подстановки.
Для большинства классов-значений простейший путь — объявить класс final и использовать instanceof. Без final смешивание двух стилей в иерархии — источник большинства ошибок равенства. Записи обходят эту проблему полностью: они неявно финальные, а сгенерированный equals использует точную проверку типа.
Поля с плавающей точкой
Не используйте == для полей double или float — значение +0.0 равно -0.0 при ==, но Double.compare различает их, а NaN == NaN — это false. Double.compare(a, b) == 0 и Float.compare дают согласованный ответ, которого требует контракт.
Массивы
Object.equals для массива сравнивает ссылки, а не содержимое. Используйте Arrays.equals(a, b) для одномерных массивов, Arrays.deepEquals — для многомерных. Аналогично используйте Arrays.hashCode / Arrays.deepHashCode в методе hashCode.
Изменяемость враждебна к коллекциям на основе хэширования
Если вы изменяете поле, участвующее в equals/hashCode, после того как объект уже помещён в HashSet, корзина, в которую множество его поместило, перестаёт соответствовать новому хэшу — и объект становится недостижимым через contains. Самое безопасное правило: поля, используемые в equals, должны быть final. Если это невозможно, никогда не помещайте объект в коллекцию на основе хэширования.
Не пишите эти методы вручную для простых классов данных
Если класс является чистым контейнером данных, предпочтите record — компилятор сгенерирует для вас корректные equals и hashCode, и они всегда будут синхронизированы при изменении полей. Если использовать записи нельзя, команда «Generate equals/hashCode» в вашей IDE — следующий лучший вариант.
Разобранный пример
Что дальше
equals позволяет объектам сравнивать себя; toString позволяет им описывать себя. Следующая глава посвящена переопределению toString для получения вывода, действительно полезного в логах, сообщениях об ошибках и отладчиках. Продолжайте с метода toString в Java.