JavaRush /Курси /Spring Data JPA /equals/

equals/ hashCode/ toString для сутності

Spring Data JPA
Рівень 6 , Лекція 1
Відкрита

1. Роль equals/hashCode/toString у сутності

Якщо ви тільки починаєте, дуже легко подумати так: «Ну й що, equals і hashCode… IDE ж уміє згенерувати, а toString взагалі для краси». У звичайних DTO це часто спрацьовує. У сутності — ні. Сутність живе у світі, де об’єкт у пам’яті пов’язаний із записом у БД, де ідентичність змінюється протягом життєвого циклу, а «просто вивести об’єкт у лог» може раптово призвести до неочікуваних дій. Тому сьогодні ми будемо не «генерувати як завжди», а проєктувати ці методи так, щоб вони відповідали persistence-реальності.

Почнемо з дуже короткої, але корисної таблички. Вона не замінює розуміння, але допомагає не загубитися:

Метод Що він означає в Java Чому це може зламатися у світі JPA
equals() «Ці два об’єкти вважаються рівними» У сутності є різні «ідентичності»: id може бути null, поля можуть змінюватися, можуть з’являтися проксі
hashCode() «У якій „комірці“ зберігати об’єкт у HashSet/HashMap» Якщо hashCode змінюється після add(), колекція починає поводитися так, ніби об’єкт зник
toString() «Коротке текстове представлення для людини» Занадто великий toString = шум у логах; toString по зв’язках = рекурсія; toString може ініціювати зайві завантаження даних

І так, це той рідкісний випадок, коли «просто натиснути Alt+InsertGenerate equals/hashCode» може бути не помічником, а прихованим шкідником.

2. Ідентичність: об’єкт, id і business key

Щоб не писати equals/hashCode на відчуттях, потрібно розуміти, що саме ми вважаємо «однаковим». У Java в об’єкта є об’єктна ідентичність: два різні new Product() — це два різні об’єкти, навіть якщо в них однакові поля. У базі даних ідентичність зазвичай задається первинним ключем (id). А в бізнесі часто є ще «природна» ідентичність: sku у товару, code у категорії, orderNumber у замовленні. Оця трійка і створює плутанину, якщо намагатися писати equals/hashCode «як для DTO».

Зручно тримати в голові таку схему — без фанатизму, просто як орієнтир:

flowchart TD
  A["Ідентичність об’єкта
у JVM"] -->|"equals/hashCode мають бути стабільними"| B["Колекції: Set/Map"] C["Ідентичність у БД
id у БД"] -->|"з’являється не відразу"| D["Життєвий цикл сутності"] E["Бізнес-ідентичність
sku/code..."] -->|"може бути стабільною"| F["Надійна основа для equals/hashCode"]

Тепер прив’яжемо це до станів із лекції 1. Коли Product щойно створений через new, у нього зазвичай немає id. У managed-стані він уже пов’язаний із persistence-контекстом і з рядком у БД (і тоді id вже є або з’явиться під час вставлення). У detached об’єкт і далі живе в Java, але JPA його не відстежує. Тобто якщо ви пишете equals/hashCode «по id», ви фактично прив’язуєте рівність до поля, якого в об’єкта не завжди є, і яке може з’явитися пізніше.

І тут починається «магія», але не Hibernate, а наша власна, людська: ми хочемо, щоб колекції та порівняння працювали стабільно, а основу для них обираємо нестабільну.

3. Анти-приклад №1: equals/hashCode лише по generated id

Спочатку покажемо найчастіший «дефолт з інтернету»: порівняння лише по id. Код виглядає логічно, особливо якщо ви подумки сприймаєте сутність як «рядок таблиці». Але проблема в тому, що для нового об’єкта id == null, а потім раптом стає не null. І ось тоді HashSet починає дивитися на вас із німим докором.

Ризикований варіант, який виглядає пристойно, але веде до сюрпризів:

import java.util.Objects;

@Override
public boolean equals(Object o) {
    if (this == o) return true; // швидкий шлях: один і той самий об’єкт у пам’яті
    if (!(o instanceof Product other)) return false; // instanceof безпечніший для ORM-проксі
    return Objects.equals(id, other.id); // УВАГА: у transient-об’єкта id == null
}
import java.util.Objects;

@Override
public int hashCode() {
    // УВАГА: якщо id змінюється після persist/flush, то hashCode теж змінюється
    return Objects.hashCode(id);
}

На папері все красиво. На практиці — подивімося на поведінку колекції. Уявімо, що ви десь тримаєте Set<Product> (це цілком звична історія: набір унікальних позицій, унікальних товарів у замовленні, унікальних категорій тощо). Сценарій такий: ви додали transient-об’єкт у HashSet, потім зберегли його, і id з’явився.

Мінідемо, яке показує саму ідею поломки (у реальному застосунку id з’явиться з JPA, а не «з рук»):

import java.util.HashSet;
import java.util.Set;

Set<Product> set = new HashSet<>();

Product p = new Product();
p.setSku("PHONE-1");
set.add(p); // об’єкт потрапив до "комірки" HashSet за поточним hashCode (id == null)

p.setId(1L); // уявімо, що id з’явився після persist/flush
System.out.println(set.contains(p)); // false: hashCode змінився, HashSet шукає в іншій "комірці"

Якщо ви зараз подумали «ну я ж не буду робити setId вручну», то мислите правильно. Але JPA зробить щось подібне за вас: об’єкт лежить у HashSet, а поле, яке бере участь у hashCode, змінюється через збереження. З погляду HashSet це приблизно якби ви поклали ключ до однієї шухляди, а потім непомітно змінили форму ключа, і він почав би підходити до іншої.

Проблема стає особливо підступною, коли баг проявляється не відразу. Вдень ви додали об’єкт у set, увечері зробили save, уночі пішли перевіряти contains, і раптом «товару немає». І це не баг Java — це наслідок порушеного контракту: об’єкт, який є ключем у hash-колекції, має мати стабільний hashCode, доки він там лежить.

4. Анти-приклад №2: hashCode по змінних полях

Після усвідомлення проблеми з id новачки часто роблять наступний «логічний» крок: «Гаразд, id змінюється. Тоді візьму name або price! Вони ж є відразу». І ось тут починається другий рівень болю. Бо ім’я та ціна — змінні поля. І якщо hashCode залежить від них, ви знову порушуєте контракт HashSet/HashMap, тільки тепер уже власними руками, звичайним сеттером.

Наприклад, ось так робити не треба:

import java.util.Objects;

@Override
public int hashCode() {
    // УВАГА: name — змінне поле, отже hashCode буде "плавати" після оновлень
    return Objects.hash(name);
}

І знову демо на чистій Java, щоб не ховатися за ORM:

import java.util.HashSet;
import java.util.Set;

Set<Category> set = new HashSet<>();

Category c = new Category();
c.setCode("phones");
c.setName("Phones");
set.add(c); // об’єкт додано до HashSet за hashCode, що залежить від name (якщо ви так зробили)

c.setName("Smartphones"); // звичайне бізнес-оновлення змінює hashCode
System.out.println(set.contains(c)); // false: HashSet шукає об’єкт за старим hashCode

Зверніть увагу на «психологічну пастку»: вам здається, що ви просто перейменували категорію. Але HashSet думає інакше: ви змінили «координати» об’єкта в структурі даних.

У нашому мінішопі якраз будуть сценарії зміни назви товару, зміни ціни та активації/деактивації. Це нормальні бізнес-операції. Тому робити hashCode залежним від цих полів — означає наперед закладати в проєкт «дивні баги в колекціях».

5. Здоровий базовий варіант: equals/hashCode по business key

Тепер до хорошої новини: у більшості прикладних систем є природні унікальні ознаки, які зручно брати як опору для порівняння. У нашому проєкті це sku для Product і code для Category. Такі поля зазвичай унікальні, в ідеалі закріплені UNIQUE у БД, задаються під час створення і не змінюються щохвилини. Тобто це добра кандидатура для «business identity» — тієї самої ідентичності, яку бізнес справді вважає важливою.

Але тут потрібно чесно проговорити умови, інакше ми просто замінимо одну магію на іншу. Для business key як основи equals/hashCode важливо, щоб він був стійким: ви задаєте його один раз і далі не змінюєте (або змінюєте вкрай усвідомлено і точно не тоді, коли об’єкт лежить у HashSet). У навчальному проєкті ми приймаємо просте правило: sku і code — стабільні.

Подивімося на базову реалізацію для Product. Вона спеціально «захищається» від null: два нові товари без sku не мають вважатися рівними.

import java.util.Objects;

@Override
public boolean equals(Object o) {
    if (this == o) return true; // один і той самий об’єкт у пам’яті
    if (!(o instanceof Product other)) return false; // підтримка порівняння з ORM-проксі
    return sku != null && sku.equals(other.sku); // null не вважаємо "рівним null", щоб не склеїти два transient
}
import java.util.Objects;

@Override
public int hashCode() {
    // Стабільність hashCode досягається тим, що sku вважаємо незмінним business key
    return Objects.hashCode(sku);
}

І одразу важливий момент: Objects.hashCode(sku) поверне 0, якщо sku == null. Це нормально, бо equals при sku == null поверне false (окрім випадку this == o). Тобто два різні «порожні» об’єкти не стануть рівними лише тому, що в них однаковий hashCode. Однаковий hashCode — це дозволено, Java-колекції це нормально переносять, головне — щоб equals був коректним.

Давайте на хвилинку порівняємо поведінку:

Product a = new Product();
Product b = new Product();

// Два transient-об’єкти без sku НЕ мають вважатися рівними
System.out.println(a.equals(b)); // false
Product a = new Product();
a.setSku("PHONE-1");

Product b = new Product();
b.setSku("PHONE-1");

// Два об’єкти з однаковим sku вважаються "тим самим товаром" за business key
System.out.println(a.equals(b)); // true

Другий рядок здається спірним: «як це — два різні об’єкти рівні?» А ось так: з погляду business-ідентичності це «той самий товар», бо sku — унікальний. У цьому й сенс business key.

Таблиця вибору основи

Щоб не запам’ятовувати правила як заклинання, тримаємо компактну матрицю:

Основа для equals/hashCode Переваги Недоліки Чи підходить нам зараз?
id (generated) Виглядає просто id з’являється не відразу; може ламати hash-колекції Скоріше ні, якщо це «дефолт»
Змінні поля (name, price) Є відразу Ламає hash-колекції під час змін Ні
Стабільний business key (sku, code) Стабільно, має сенс Потрібна дисципліна: не змінювати ключ Так, як baseline

instanceof vs getClass() і проксі

У звичайних Java-класах багато хто любить писати equals із перевіркою getClass(): мовляв, «порівнюємо лише об’єкти рівно одного класу, без нащадків». У світі ORM це може несподівано зіграти проти вас, бо Hibernate іноді повертає не «чистий» об’єкт класу Product, а спеціальний об’єкт-обгортку (проксі), який виглядає як Product, але клас у нього інший — технічно нащадок.

Поки ми не пішли в майбутні теми, достатньо однієї простої думки: у JPA/Hibernate об’єкт, який ви бачите, іноді може бути не «рівно ваш клас», а його проксі-версія. І тоді порівняння через getClass() ламається.

Поганий, надто суворий фрагмент, який може підставити:

@Override
public boolean equals(Object o) {
    // УВАГА: для ORM-проксі getClass() може відрізнятися, хоча за змістом це та сама сутність
    if (o == null || getClass() != o.getClass()) return false;
    // ...
    return true;
}

У межах нашого курсу більш практичний baseline — використовувати instanceof (а в Java 25 ще й із pattern matching, щоб не кастувати вручну). Так порівняння залишиться коректним, навіть якщо одна зі сторін — проксі.

@Override
public boolean equals(Object o) {
    if (this == o) return true; // швидкий шлях
    if (!(o instanceof Product other)) return false; // instanceof "дружить" з проксі-нащадками
    return sku != null && sku.equals(other.sku); // порівнюємо за стабільним business key
}

Якщо у вас зараз відчуття «я не до кінця зрозумів, що таке проксі», це нормально: нам не потрібно пірнати в байткод і внутрішні механізми. Достатньо запам’ятати інженерне правило: для сутностей частіше безпечніше instanceof, ніж getClass(), якщо ви не робите окремий курс про «ідеальну теорію equals».

toString() для сутності: коротко і безпечно

Із toString ситуація підступна по-своєму. Він здається «безпечним», бо «ну це ж просто рядок». Але toString активно викликається в найнесподіваніших місцях: коли ви логуєте об’єкт, коли IDE показує його в дебазі, коли виняток виводить контекст, коли ви випадково зробили log.info("товар={}", product). Тому поганий toString — це не просто некрасиво, це часто дорого і іноді навіть небезпечно.

Почнемо з простого правила: toString має бути коротким і не має тягнути за собою весь граф об’єктів. Навіть якщо сьогодні в нас немає зв’язків, завтра вони з’являться, і ви зовсім не хочете отримати нескінченну рекурсію «Product → Category → Products → Category → …». Так, ми ще не вивчали зв’язки — і це правильно. Але toString краще зробити акуратним одразу.

Гарний toString для Product зазвичай включає кілька діагностично корисних полів: id (якщо є), sku, name. Без описів на пів сторінки.

@Override
public String toString() {
    // УВАГА: не виводимо зв’язки (category, items тощо), щоб не отримати рекурсію та ліниві завантаження
    return "Product{id=%s, sku='%s', name='%s'}"
            .formatted(id, sku, name);
}

Якщо захочете перевірити, як це виглядає:

Product p = new Product();
p.setSku("PHONE-1");
p.setName("Phone");

// Зручна діагностика: id=null одразу показує, що об’єкт ще не збережений
System.out.println(p); // Product{id=null, sku='PHONE-1', name='Phone'}

Зверніть увагу, як гарно id=null говорить нам: «об’єкт ще не збережений» (тобто в термінах лекції 1 — він transient). Це чудовий приклад того, як toString допомагає діагностиці, не втручаючись у бізнес-логіку.

Поганий toString зазвичай «чесний»: туди включають усі поля, включно з довгими текстами, службовими прапорцями та всім підряд. На старті це здається зручним: «видно все». Через тиждень логи перетворюються на кашу, а дебаг стає болем.

6. Правила для shop-data-jpa: Category і Product

Тепер, щоб це не залишилося теорією, зафіксуємо стиль у коді наших сутностей. Ми не переписуємо весь клас одним полотном, а додаємо «правильні шматки» точково. Заодно дисциплінуємо модель: sku і code — це не «будь-які рядки», це саме наші business keys, і вони мають бути унікальними та обов’язковими (на рівні @Column ми це вже почали робити).

Product: рівність по sku, короткий toString

Фрагменти, які мають з’явитися в Product:

import java.util.Objects;

@Override
public boolean equals(Object o) {
    if (this == o) return true; // один і той самий об’єкт
    if (!(o instanceof Product other)) return false; // підтримка проксі/нащадків
    return sku != null && sku.equals(other.sku); // business key; null не вважаємо рівним null
}
import java.util.Objects;

@Override
public int hashCode() {
    // ВАЖЛИВО: sku має бути стабільним, інакше HashSet/HashMap будуть "ламатися" під час зміни sku
    return Objects.hashCode(sku);
}
@Override
public String toString() {
    // Короткий toString: допомагає в логах і дебазі та не тягне граф об’єктів
    return "Product{id=%s, sku='%s', name='%s'}"
            .formatted(id, sku, name);
}

Якщо ви поводитеся як зрілий інженер (а ми до цього прагнемо), ви ще й мінімально дисциплінуєте доступ до business key. Наприклад, можна прибрати публічний setSku або хоча б домовитися: «SKU не змінюємо після створення». У навчальному проєкті достатньо домовленості, але пам’ятати про це потрібно.

Category: рівність по code, короткий toString

Для Category — та сама ідея, тільки ключ інший:

import java.util.Objects;

@Override
public boolean equals(Object o) {
    if (this == o) return true; // швидкий шлях
    if (!(o instanceof Category other)) return false; // порівняння з урахуванням ORM-проксі
    return code != null && code.equals(other.code); // business key; null не вважаємо рівним null
}
import java.util.Objects;

@Override
public int hashCode() {
    // ВАЖЛИВО: code має бути стабільним, інакше колекції на хешах почнуть поводитися непередбачувано
    return Objects.hashCode(code);
}
@Override
public String toString() {
    // Коротко і без зв’язків: логуємо лише те, що справді допомагає діагностиці
    return "Category{id=%s, code='%s', name='%s'}"
            .formatted(id, code, name);
}

Про @Data та автогенерацію

Якщо ви десь бачили пораду «просто постав Lombok @Data і все буде класно», то для сутностей це дуже спірна рекомендація. @Data генерує equals/hashCode/toString по всіх полях. Це майже гарантовано призводить до того, що hashCode залежить від змінних полів, а toString починає друкувати все підряд. Тож у нашому курсі ми краще витратимо 10 хвилин на ручний, усвідомлений код — і заощадимо години на дебазі.

7. Типові помилки під час equals/hashCode/toString

Помилка №1: «Згенерую equals/hashCode по id, бо id — це ж ключ у БД».
Логіка зрозуміла, але в JPA id у нового об’єкта спочатку null, а потім стає реальним числом. Якщо hashCode залежить від id, то після збереження об’єкт може «зникнути» з HashSet або перестати бути ключем у HashMap. Баг виглядає як містика, але це всього лише порушений контракт hash-колекцій.

Помилка №2: «Тоді візьму name/price — вони ж є відразу».
Це ще небезпечніше, бо ім’я та ціна за бізнесом змінюються регулярно, і ви самі ж будете ламати hashCode звичайними оновленнями. У результаті колекції починають поводитися непередбачувано, а ви витрачаєте час на розслідування: «Як це set.contains повернув false для того самого об’єкта?»

Помилка №3: забути про null у business key і зробити два «порожні» об’єкти рівними.
Якщо написати return Objects.equals(sku, other.sku);, то два нові товари без sku (обидва null) виявляться рівними. Це виглядає як дрібниця, але ламає очікування: ви можете помилково подумати, що «додали два різні об’єкти», а Set залишить один.

Помилка №4: зробити toString величезним і включити туди все.
Це перетворює логи на нескінченне полотно і ускладнює дебаг. toString має допомагати швидко зрозуміти «що це за об’єкт», а не розповідати біографію товару з дитячого садка. Достатньо id, business key і кількох коротких полів.

Помилка №5: використовувати getClass() у equals і потім дивуватися, чому порівняння іноді не працює.
У світі ORM у об’єкта іноді буває «технічна оболонка» (проксі), і суворе порівняння класів починає заважати. Для нашого baseline безпечніше instanceof + business key. Це не «ідеальна теорія для всіх випадків», але практично та стабільно для навчального й звичайного комерційного коду.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ