Как стать автором
Обновить

Лучшие практики реализации equals() и hashCode() на примере JPA сущности

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров1K

Всем привет!
Рано или поздно при работе с объектами и хэш-коллекциями мы сталкиваемся с вопросами: «Где моя сущность?» или «Почему они не равны?».
В контексте работы с важными данными эти вопросы становится еще более актуальными.
А самое что страшное, что нет единого ответа на вопрос: «А как сделать мне в моём проекте?».

Если попытаться отправить такой запрос в нейросеть, нет гарантии, что в ответе будет рабочий код. Браузер же выдаст кучу статей, после прочтения которых создаётся ощущение: «Ага, сравниваем как хотим, лишь бы заработало».

Здесь я все же пытаюсь дать ответ как правильно сравнивать объекты в ООП-ориентированных языках.

Примеры реализаций методов написаны на языке Java, однако концепции описанные здесь можно применять на любом языке.

📑Оглавление

Теория сравнения

Во многих ООП-ориентированных язык реализованы методы сравнения объектов, такие как:

  • eq, hash в python;

  • equals, hashCode в java, kotlin;

  • Equals и GetHashCode в c#;

  • и т.д.

И тут я хочу описать те вопросы, что были у меня, когда я услышал про методы equals и hashCode в java:

  • Ага, equals сравнивает объекты, но есть же ==, зачем два метода сравнения?

  • Что вообще такое hashCode и как он связан с equals?

  • Почему нельзя раз написать и забыть про их существования обычному разработчику?

Крайне рекомендую попытаться узнать ответы на эти вопросы у нейронки, если еще не узнали.
Мои же блиц ответы:

  • equals в стандартной реализации Java (Object) работает аналогично == (сравнение объектов по ссылке в памяти), однако его рекомендуется переопределять для более очевидного поведения с точки зрения бизнеса;

  • hashCode это оптимизационный костыль, чтобы в хэш-структурах твой экземпляр класса не терялся и быстро находился;

  • Можно, но не для всего. И тут нужно остановиться и рассказать подробнее.

Виды сравнений

Задам простой вопрос, как можно сравнить двух собак?
Поразмыслив, можно назвать два основных способа:

  1. По признакам, например по размеру, весу, шерстке, возрасту;

  2. По ветеринарному паспорту.

В первом случае мы смотрим на все признаки собак и сравниваем их.
Во втором случае предполагаем, что собаки могут находиться в разных временных промежутках и иметь разные показатели.

И так плавно мы переходим к двум основным типам объектов: изменяемые и нет.

В качестве неизменяемых объектов зачастую выступают модели данных, которые определяют согласованные и консистентные состояния. Способ сравнения для них во многом прост, сравнить все поля друг друга (иногда нужно сравнение для полей всех вложенных объектов). Для моделей создатели языков программирования во многом позаботились, разработав такие структуры как:

  • record в java, c#;

  • датаклассы в kotlin, python;

  • любые структуры в golang;

  • др.

При создании объекта получаются готовые методы сравнения.
Подробнее про концепцию деления объектов на разные виды.

Однако с объектами, которые могут меняться во времени не так все просто. У той же собаки может не быть паспорта, и как быть? В самых стандартных вариантах в языках программирования реализовано сравнение объектов по прописке (адрес в памяти). Когда объект создается, он где-то должен храниться. Там, где он хранится - адрес, есть его уникальная неизменяемая черта.
Казалось бы, вопрос решен, разработчик языка позаботился о работягах, реализовал за нас сложный механизм сравнения ячеек памяти, гуляем.
Вычислили координаты наших пёсиков и готово.

Классика
Классика

И нет! В случае разработки код живет вне времени, наша одна и та же собака может существовать в разных уголках памяти и иметь двойников 🤯.
Мультивселенная ли, но в коде эти собаки должны быть равны!
Когда мы создадим одну собаку в одном месте, я хочу, чтобы ее копия, пусть и с потрепанной шерсткой, рожденная в параллельном мире была ей равна! Даешь равенство!

Наша собака эта сущность, которая может изменяться до неузнаваемости, создаваться в разных местах. Чтобы ее не потерять мы можем надеть на неё хотя бы ошейник. В случае человека, у нас есть ДНК. Либо если это два камня мы можем покрасить их.

Подытожив:

  1. Неизменяемые объекты (модели/дто/объекты-значения) - просто сравниваем все признаки;

  2. Изменяемые (сущности) - находим неизменяемую точку опоры (уникальный идентификатор).

Практика сравнения сущностей

Тут нюанс, дальше всё повествование будет вестись в контексте java JPA Hibernate, поскольку и без того мыслей много 🥸. Однако данные практики применимы для любых сущностей как ORM, так и обычных сущностей созданных из возвращаемого кортежа/json/любого объекта из бд.
Для наших экспериментов я написал ряд тестов. Основная логика проверок была позаимствована у Vlad Mihalcea.

Методы тестирования

    @MethodSource("getStreamRepo")
    @ParameterizedTest(name = "Имя сущности: {0}")
    @DisplayName("Нахождение сущности во множестве с прокси объектом вне транзакции")
    public void testContainsSet_withoutTransactionalAndWithProxy(Class<?> type) {
        Object userProxy = getUserReferenceById(type);

        entities.add(userProxy);
        var user = FABRIC_ENTITIES.get(type).apply(USER_UUID.equals(type) ? UUID : 1L);

        assertTrue(
                entities.contains(user),
                "The entity is not found in the Set with proxy"
        );
        assertThat(stats.getEntityLoadCount())
                .withFailMessage("The entity added in the set with query in db").isEven();
    }

    @Transactional
    @MethodSource("getStreamRepo")
    @ParameterizedTest(name = "Имя сущности: {0}")
    @DisplayName("Нахождение прокси объекта во множестве с сущностью в транзакции")
    public void testContainsSet_withTransactional(Class<?> type) {
        Object user = findUser(type);

        entities.add(user);
        var userProxy = getUserReferenceById(type);

        assertTrue(
                entities.contains(userProxy),
                "The entity proxy is not found in the Set with entity"
        );
        assertThat(stats.getEntityLoadCount())
                .withFailMessage("The entity added in the set with query in db").isOne();
    }

    @Transactional
    @MethodSource("getStreamRepo")
    @ParameterizedTest(name = "Имя сущности: {0}")
    @DisplayName("Отсутствие сущности с другим идентификатором")
    public void testContainsSet_withOtherId(Class<?> type) {
        Object user = findUser(type);

        entities.add(user);
        var userObject = FABRIC_ENTITIES.get(type).apply(USER_UUID.equals(type) ? UUID_OTHER : 2L);

        if (UserIdNatural.class.equals(userObject.getClass())) {
            ((UserIdNatural) userObject).setGen("gen2");
        }

        assertFalse(
                entities.contains(userObject),
                "The entity is found in the Set with other entity"
        );
        assertThat(stats.getEntityLoadCount()).isOne();
    }

    @Transactional
    @MethodSource("getStreamRepo")
    @ParameterizedTest(name = "Имя сущности: {0}")
    @DisplayName("Присутствие сущности во множестве после сохранения")
    public void testContainsSet_afterPersist(Class<?> type) {
        Object user = FABRIC_ENTITIES.get(type).apply(null);

        if (UserIdNatural.class.equals(user.getClass())) {
            ((UserIdNatural) user).setGen("gen2");
        }

        entities.add(user);

        entityManager.persist(user);
        entityManager.flush();

        assertTrue(
                entities.contains(user),
                "The saved entity is not found in the Set with entity"
        );
        assertThat(stats.getFlushCount()).isOne();
    }

    @Transactional
    @MethodSource("getStreamRepo")
    @ParameterizedTest(name = "Имя сущности: {0}")
    @DisplayName("Присутствие сущности во множестве после связывания с контекстом")
    public void testContainsSet_afterMerge(Class<?> type) {
        Object user = FABRIC_ENTITIES.get(type).apply(USER_UUID.equals(type) ? UUID : 1L);
        entities.add(user);

        Object savedUser = entityManager.merge(user);

        assertTrue(
                entities.contains(savedUser),
                "The merged entity is not found in the Set with entity"
        );
    }

Полный код тестов можно будет посмотреть в репозитории, ссылка на который в конце статьи.

Стандартный вариант без переопределения

Напомню, стандартная реализация опирается на ссылку объекта в памяти.

Пример:

@Entity
@Table(name = "users")
public class UserIdDefault {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

Результаты тестов

Прохождение тестов без переопределения методов equals и hashCode
Прохождение тестов без переопределения методов equals и hashCode

Не проходят тесты связанные с изменением состояния сущности контексте ORM и сравнение оригинала с копией. В случаях обычных CRUD-методов все будет хорошо. Однако при более сложных сценариях (Работа с хэш-таблицами, кэш второго уровня, работа вне транзакции) не годится.

Lombok не держит удар

Здесь разбираем сразу два варианта:

  1. @EqualsAndHashCode;

  2. @EqualsAndHashCode(onlyExplicitlyIncluded = true)c @EqualsAndHashCode.Include на @Id поле

Примеры:

@Entity
@Table(name = "users")
@EqualsAndHashCode
public class UserIdLombokBase {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}
@Entity
@Table(name = "users")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class UserIdLombokInclude {

    @Id
    @EqualsAndHashCode.Include
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

Результаты тестов

Прохождение тестов для сущностей со сгенерированными методами
Прохождение тестов для сущностей со сгенерированными методами

Оба провалились при работе с прокси объектами:

  • первый не нашел сущность во множестве с прокси объектом;

  • второй пал, сделав дополнительный запрос в базу.

Также оба не справились в нахождении сущности после сохранения. Это может показаться странным, однако посмотрев код метода hashCode() в папке target, увидим:

    @Generated
    public int hashCode() {
        int PRIME = 59;
        int result = 1;
        Object $id = this.getId();
        result = result * 59 + ($id == null ? 43 : $id.hashCode());
        return result;
    }

Как нам известно (или станет известно) при сохранении объекта в hash-структуру у нас рассчитывается его бакет хранения на основе метода hashCode. Когда мы добавляли элемент в Set у нас поле id было null. После сохранения, ORM проставил id сгенерированный в базе, тем самым переопределив hashCode. В результате произошло - мы добавили объект с одним кодом, после сохранения теперь код другой. Это чем-то похоже на кэширование. В хэш структурах применяется для ускорения поиска.

Не маловажным для сущностей является соответствие правилу null не равно null для id, которое нарушается при генерации в @EqualsAndHashCode(onlyExplicitlyIncluded = true) c @EqualsAndHashCode.Include

Плагины — наше всё

При интеграции в работу плагинов по типу JpaBuddy или Amplicode ими будет предлагаться следующий вариант:

@Entity
@Table(name = "users")
public class UserIdJpaBuddy {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer()
                .getPersistentClass() : o.getClass();
        Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this)
                .getHibernateLazyInitializer().getPersistentClass() : this.getClass();
        if (thisEffectiveClass != oEffectiveClass) return false;
        UserIdJpaBuddy that = (UserIdJpaBuddy) o;
        return getId() != null && Objects.equals(getId(), that.getId());
    }

    @Override
    public final int hashCode() {
        return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer()
                .getPersistentClass().hashCode() : getClass().hashCode();
    }
}

Результаты тестов

Реализация Jpa Buddy проходит все тесты
Реализация Jpa Buddy проходит все тесты

Вот он чемпион! Лучшая реализация! Кубок в студию!
Но посмотрев на реализацию в кучу строк, присутствие непонятно каких классов, лично мне хочется закрыть ноутбук и убежать 🫣. Знатоки, конечно, скажут, ну, во-первых, это база, это знать надо.
Во-вторых, тебе писать не нужно, попроси своего СТО, пускай он купит лицензию и будешь генерировать эти методы в два клика.
Но я скажу вам что:
Во-первых, ОРМ придумана для упрощения написания кода, а не для хитро-сделанных методов.
Во-вторых, работая в компании, которая за импортозамещение, далеко не факт, что будет сама возможность покупки лицензии.
В-третьих, идет реальная привязка к реализации и при смене той же ORM нам нужно прокликнуть по всем классам и вызвать перегенерацию.

По итогу, если первые два моих довода не столь страшны с точки зрения самого разработчика, то третий сам Робертом Мартином выведен.
Уменьшить инъекцию инфраструктурного кода возможно и почему бы этим не пользоваться.

Он настоящий

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

Пример:

@Entity
@Table(name = "users")
public class UserIdNatural {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @NaturalId
    private String gen;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserIdNatural user)) return false;
        return Objects.equals(getGen(), user.getGen());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getGen());
    }
}

Результаты тестов

Тесты с натуральным идентификатором
Тесты с натуральным идентификатором

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

Ты не настоящий астро рейнджер! Ты игрушка!

С помощью строковых идентификаторов мы можем смоделировать бизнес-ключ.
Пример:

@Entity
@Table(name = "USERS_UUID")
public class UserUuid {

    @Id
    @Column(name = "id", nullable = false)
    private UUID id = UUID.randomUUID();

    private String name;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof UserUuid user)) return false;
        return getId() == user.getId();
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

Результаты тестов

Результаты тестов с генерацией id на стороне приложения
Результаты тестов с генерацией id на стороне приложения

Такая же история, как и с натуральным идентификатором. Плюсы и минусы данного подхода хорошо описаны здесь. Если кратко, то мы получаем почти натуральный id, но получаем трудности в тестировании, проблемы с производительностью. Использовать можно, но осторожно.

Легенда при жизни

Реализация от джава чемпиона Vlad Mihalcea, описанная в его статье.

Пример:

@Entity
@Table(name = "users")
public class UserId {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof UserId that)) return false;
        return getId() != null && getId().equals(that.getId());
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

Результаты тестов

Результаты тестов версии java чемпиона
Результаты тестов версии java чемпиона

Данная реализация очень хороша, она одна из моих любых. Проста, лаконична и не проходит лишь тест с проксей. В целом могло бы и не быть данного анализа, я бы спокойно пользовался этой версией, однако у нее есть существенный недостаток. Это использование метода getClass для хэш кода и instanceOf для equals. В моем понимании это нарушение контракта между двумя этими методами.

С другой стороны, если не душиться всякими контрактами и оптимизациями (которые могут и не пригодиться) - это на мой взгляд самый простой, понятный и рабочий вариант.

Бюро находок

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

Пример:

@Entity
@Table(name = "users")
public class UserIdMyBestVar {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Override
    public final boolean equals(Object o) {
        if (!(o instanceof UserIdMyBestVar that)) return false;
        return getId() != null && getId().equals(that.getId());
    }

    @Override
    public final int hashCode() {
        return UserIdMyBestVar.class.hashCode();
    }
}

Результаты тестов

Вариант реализации с получением hashCode от текущего класса
Вариант реализации с получением hashCode от текущего класса

Все тесты прошли. Было решено сделать методы final, так же, как и в версии плагинов, чтобы прокси объект отрабатывал корректно. Для метода hashCode теперь берется hash основного класса, что при наследовании будет эквивалентно instanceOf для equals. Контракт соблюден.

Не могу не отметить нюанс касаемо использования в методах equals и hashCode конструкций instanceOf и getClass. На вкус и цвет товарищей нет, мне, например ближе подход по SOLID с instanceOf.

Общий вывод

Мой вариант оказался надёжным простым, поэтому его смело можно взять за основу! 🥹
Но если быть серьезным, каждый из вариантов имеет свои плюсы и минусы, которые я старался подчеркнуть.
Итого:

  • можно не переопределять методы, если мы точно уверены, что у нас нет всяких hash-структур в коде и не будет сложных сравнений с копиями объектов;

  • lombok для сущностей вреден. Желательно не переопределять методы equals и hashCode с помощью него;

  • плагины создают идеальную реализацию на основе getClass, но она противоречит SOLID в отношении наследования и сильно привязана к фреймворку ORM;

  • натуральный id хорош, но он не всегда есть;

  • Vlad Mihalcea описал практически идеальную реализацию, которой чуть-чуть не хватило до оптимального варианта. Ее можно смело использовать;

  • Мой вариант это лишь попытка улучшить версию Vlad Mihalcea, лишить ее каких-либо противоречий.

Ссылка на репозиторий

Также подписывайтесь на мой телеграмм канал, там будет много полезного и интересного!

Источники

Чтобы самому додуматься, как сравнивать объекты, не хватило бы и Аристотеля рядом. Но он и не нужен, когда есть поисковая строка, которая при вводе нужного запроса выдает:

  1. Про DDD

  2. Контракт equals и hashCode

  3. Эталонный вариант реализации с детальным разбором, но придется привязаться к ORM

  4. Про доверие либам в столь важных вопросах (В данном случае Lombok)

  5. И еще Lombok

  6. Baeldung и его реализация, не выдерживающая реальности

  7. Очень интересная реализация, которая выглядит довольно сложно

  8. Разбор варианта с генерацией UUID на стороне приложения

  9. Гений Hibernate'а и его взгляд на проблему

Теги:
Хабы:
0
Комментарии5

Публикации

Ближайшие события