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

«Переопределили equals(), но забыли hashCode() — что сломается?» Это вопрос, который отделяет тех, кто просто пользуется HashMap, от тех, кто понимает, как она устроена внутри.

Контракт hashCode() и equals() — это правило, которое обязаны соблюдать вместе оба метода: если два объекта равны по equals(), у них ОБЯЗАН быть одинаковый hashCode(). Нарушишь — HashMap и HashSet начнут вести себя непредсказуемо.

Вопрос-крючок

class Point {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }
    // hashCode() не переопределён!
}

Set<Point> visited = new HashSet<>();
visited.add(new Point(1, 1));
System.out.println(visited.contains(new Point(1, 1)));   // ?

Вывод:

false

Логически кажется, что должно быть true — ведь equals() явно говорит, что точки с одинаковыми x и y равны. Но HashSet не вызывает equals() у всех подряд элементов. Сначала он вычисляет hashCode() нового объекта, чтобы понять, в какую «корзину» (bucket) заглянуть, и сравнивает объекты только внутри этой же корзины. Раз hashCode() не переопределён, используется версия из Object по умолчанию — она обычно основана на адресе объекта в памяти и у двух разных объектов Point(1, 1) почти наверняка окажется разной. Значит, новый Point(1, 1) попадёт в другую корзину, где старого объекта просто нет — и contains() вернёт false, даже несмотря на то, что equals() для этих объектов дал бы true.

Как HashMap использует хеш и equals вместе

Внутри HashMapHashSet, который на HashMap и построен) хранится массив «корзин». Когда ты добавляешь элемент, Java сначала вызывает его hashCode() и по этому числу быстро вычисляет номер корзины — это позволяет находить нужный элемент почти мгновенно, а не перебирать все элементы подряд. Но разные объекты иногда могут дать одинаковый хеш (это называется коллизией) — тогда внутри одной корзины может лежать несколько элементов, и вот тут уже вступает в дело equals(): чтобы понять, какой из нескольких элементов в корзине — тот самый, Java сравнивает их через equals() один за другим.

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

Формулировка контракта

Контракт, описанный в документации класса Object, звучит так:

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

Если переопределяешь equals() — переопределяй и hashCode() так, чтобы он использовал те же поля, что и сравнение в equals(). Для класса Point из примера правильный hashCode() выглядит так:

@Override
public int hashCode() {
    return Objects.hash(x, y);
}

Метод Objects.hash() — стандартный помощник из java.util.Objects, который аккуратно комбинирует хеши нескольких полей в одно число. После добавления такого hashCode() пример из начала урока начнёт возвращать true — потому что оба объекта Point(1, 1) теперь попадают в одну и ту же корзину и сравниваются через корректный equals().

Частые ошибки на собеседовании

Переопределяют только equals(), забывая hashCode(). Код компилируется без единого предупреждения, тесты «в лоб» через equals() тоже проходят — но объект молча ломается внутри HashMap/HashSet, и баг всплывает только там.

Используют разные поля в equals() и hashCode() — например, equals() сравнивает только id, а hashCode() учитывает ещё и name. Формально оба метода «работают», но не согласованы друг с другом, и контракт нарушается.

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

Итоги-шпаргалка

  • Равные объекты (по equals()) обязаны иметь одинаковый hashCode() — это и есть контракт.
  • HashMap/HashSet сначала используют hashCode(), чтобы найти нужную «корзину», и только потом equals(), чтобы сравнить объекты внутри неё.
  • Переопределил equals() — обязательно переопредели и hashCode(), используя те же поля.
  • Нарушение контракта не даёт ошибку компиляции — объект просто «теряется» в хеш-коллекциях, и это трудно отладить.
  • Objects.hash(поле1, поле2, ...) — стандартный и надёжный способ написать корректный hashCode().
Проверьте себя
1. Переопределили equals(), но не переопределили hashCode(). Что произойдёт при поиске «равного» объекта в HashSet?
AОшибка компиляции
BПоиск отработает правильно — Java сама вызовет equals() для всех элементов
CОбъект может не найтись, даже если equals() для него вернул бы true — из-за разных hashCode() он попадёт в другую корзину
DHashSet автоматически сгенерирует hashCode() на основе equals()
2. Что гласит контракт hashCode() и equals()?
AУ объектов с одинаковым hashCode() обязательно equals() тоже true
BЕсли equals() вернул true, hashCode() у объектов обязан совпадать
ChashCode() и equals() никак не связаны и переопределяются независимо
DhashCode() должен возвращать уникальное число для каждого объекта в программе