hashCode() и equals(): нарушить (,) нельзя (,) соблюдать

Второй по популярности (после различий между LinkedList и ArrayList) вопрос на собеседованиях – контракт между методами hashCode() и equals().

Метод hashCode() класса Object – нативный (то есть написан не на Java и вызывается с помощью JNI). Из сигнатуры следует, что хэш-код является некоторым целочисленным значением типа int.

 public native int hashCode();

Полагаю, что реализация этого метода может меняться в зависимости от используемой JVM. В некоторых источниках говорится о том, что hashCode() возвращает целочисленное представление адреса объекта в памяти. Но объект может быть перемещен в другую область, а hashCode() всегда должен возвращать одинаковое значение. Поэтому проще представить, что hashCode() при каждом вызове возвращает некоторое одинаковое (для каждого вызова метода у объекта) значение типа int. Дополнение: в одной статье на хабре говорится о том, что hash code хранится прямо в заголовке и записывается туда при первом создании объекта, поэтому перемещение объекта в другие участки памяти не влияет на это значение. Ниже – мой вольный перевод контракта hashCode():

1. Будучи многократно вызванным на одном и том же объекте во время работы программы, метод должен возвращать одно и то же целое число. Это значение не обязательно должно быть одинаковым между повторными запусками программы.
2. Если два объекта равны друг другу, то есть equals(Object) возвращает true, то вызов hashCode() у каждого из них должен возвращать одинаковые целые числа.
3. Не обязательно, что для двух не равных объектов (исходя из вызова метода equals(Object)) вызов hashCode() вернёт различные результаты. Однако следует учесть, что генерация разных целых чисел для не эквивалентных объектов может улучшить производительность хэш-таблиц.

Давайте проверим. как работает метод hashCode() для двух разных объектов класса Object:

package ru.msk.java.objects;

public class Hash {

    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(obj.hashCode());

        Object obj2 = new Object();
        System.out.println(obj2.hashCode());
    }
}

Для двух разных объектов вернулись разные значения hashCode.

Переопределение

Создадим простейший класс Box, обладающий полями height, width и depth, а также конструктором, принимающим все их значения:

package ru.msk.java.objects;

public class Box {

    private int height;
    private int width;
    private int depth;

    public Box(int height, int width, int depth) {
        this.height = height;
        this.width = width;
        this.depth = depth;
    }

    public long getVolume() {
        return height * width * depth;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getDepth() {
        return depth;
    }

    public void setDepth(int depth) {
        this.depth = depth;
    }
}

Теперь создадим два экземпляра этого класса, а затем сравним их при помощи оператора ==, используя equals, а также выведем на консоль результаты вызова метода hashCode у этих объектов:

class Main {
    public static void main(String[] args) {
        Box box1 = new Box(10, 20, 30);
        Box box2 = new Box(10, 20, 30);

        System.out.println(box1 == box2);
        System.out.println(box1.equals(box2));
        System.out.println("box1 hash: " + box1.hashCode());
        System.out.println("box2 hash: " + box2.hashCode());
    }
}

Обе коробки имеют одинаковые габариты, которые были переданы при создании объектов. Можно предположить, что коробки в сущности эквиваленты, так как все значения полей попарно равны. Объекты ссылаются на разные области в памяти, поэтому сравнение при помощи оператора == возвращает false. У коробок также различаются значения hashCode. Метод equals также возвращает false, что в нашем случае является скорее нежелательным (даже алогичным) поведением.

Так как мы не переопределили метод equals в классе Box, был вызван соответствующий метод из класса Object. Давайте посмотрим на его исходники:

 public boolean equals(Object obj) {
        return (this == obj);
    }

Реализация метода equals в классе Object по умолчанию эквивалентна оператору ==. Приведу вольный перевод контракта метода public boolean equals(Object obj):

Метод equals реализует отношение эквивалентности для ненулевых ссылок:
1. Он рефлексивен: для любой ненулевой ссылки x, x.equals(x) должен возвращать true.
2. Он симметричен: для любых ненулевых ссылок x и y, x.equals(y) должен возвращать true тогда и только тогда, когда y.equals(x) равен true.
3. Он транзитивен: для любых ненулевых ссылок x, y и z, если
x.equals(y) равен true и y.equals(z) равен true, то x.equals(z) должен возвращать true.
4. Он последователен: для любых ненулевых ссылок x и y, многократный вызов метода x.equals(y) должен всегда возвращать либо только true, либо только false, при условии, что поля, участвующие в методе equals не были изменены.
5. Для любой ненулевой ссылки x, x.equals(null) должен возвращать false.

Добавим реализацию метода equals в класс Box:

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Box box = (Box) o;
        return height == box.height &&
                width == box.width &&
                depth == box.depth;
    }

Теперь снова запустим наш метод main и посмотрим на результат:

Метод equals вернул true, то есть теперь при реализации бизнес-логики приложения мы можем смело использовать его для определения отношения эквивалентности между двумя объектами. В целом на этом можно было бы остановиться, если бы не контракт между hashCode и equals. Сейчас класс Box не содержит собственной реализации метода hashCode, поэтому используется реализация из класса Object. Переопределим метод hashCode, чтобы соблюсти контракт (для эквивалентных объектов hashCode должен возвращать одинаковые значения, но не обязательно, что hashCode различных объектов будет отличаться):

    @Override
    public int hashCode() {
        return Objects.hash(height, width, depth);
    }

Снова запустим метод main:

Теперь немного изменим параметры одного из объектов:

Box box2 = new Box(1000, 20, 30);

Как видно из вывода консоли, при изменении значения height изменился и хэш-код, а так как поле участвовало в сравнении объектов, метод equals вернул false.

Сфера применения

Методы hashCode и equals чаще всего упоминаются в контексте хранения объектов в коллекциях. Например в HashMap метод hashCode используется для определения номера корзины в массиве, а equals применяется для сравнения объектов. Правильно реализованный метод equals также требуется при использовании некоторых методов Stream API, например distinct, который использует equals для фильтрации уникальных объектов стрима. Большинство ORM фреймворков, например Hibernate, используют equals для определения идентичности объектов-сущностей.

Выводы

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