JavaRush /Курси /Kotlin SELF /Рівність, hashCode і toString у data class

Рівність, hashCode і toString у data class

Kotlin SELF
Рівень 33 , Лекція 1
Відкрита

1. Вступ

Коли ви тільки починаєте програмувати, хочеться, щоб код просто «працював»: ввели число — отримали результат. Але щойно зʼявляються власні моделі (наприклад, Expense у нашому мінітрекері витрат), ви починаєте порівнювати обʼєкти, зберігати їх у колекціях і виводити для налагодження. І тут раптом зʼясовується: без нормальних equals/hashCode/toString програма може поводитися як кіт, який удає, що вас не знає.

Уявімо нашу консольну програму обліку витрат. Ми додаємо витрати до списку, будуємо звіти за категоріями й іноді хочемо запобігати дублікатам. Але що таке «дублікат»? Два обʼєкти з однаковими полями? Чи «це буквально той самий обʼєкт»? А коли ми робимо println(expense), то хочемо побачити щось читабельне, а не загадкову адресу в памʼяті рівня «обʼєкт десь є, але де — не скажу».

data class якраз і створений для таких сценаріїв: моделі даних майже завжди потрібно зручно друкувати й коректно порівнювати. Kotlin бере на себе генерування стандартних методів, зокрема equals(), hashCode() і toString().

2. Швидке повторення: == і ===

Перш ніж «звинувачувати data class у всіх гріхах», важливо впевнено розрізняти два оператори порівняння. == у Kotlin — це структурна рівність (за змістом/значенням), тобто фактично викликає equals(). ===посилальна рівність: вона перевіряє, що це один і той самий обʼєкт (у певному сенсі «та сама коробка», а не «однаковий вміст коробок»). Для даних зазвичай потрібен ==, інакше ви порівнюватимете не зміст, а «чи це той самий екземпляр».

Розгляньмо мінімальний приклад (і водночас дотримуймося майбутньої моделі нашого застосунку):

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val a = Expense(1, "Кава", 250)
    val b = Expense(1, "Кава", 250)

    println(a == b)   // true
    println(a === b)  // false
}

Логіка така: витрати однакові за полями, тому == істинно. Але це різні обʼєкти, створені двома викликами конструктора, — отже === хибно.

3. Що генерує data class і які поля беруть участь

Дуже поширена пастка новачків: «Я написав data class — отже тепер усе всередині обʼєкта бере участь у порівнянні й друці». Ні. Kotlin робить це передбачувано: він використовує лише властивості (val/var), оголошені в primary constructor. Саме з них і виводяться equals/hashCode/toString (і не тільки вони, але сьогодні ми тримаємо фокус саме на цих трьох).

Це зручно, бо primary constructor стає «паспортом даних»: що там перелічено — те й вважається станом моделі.

Приклад «як ми хочемо» для обліку витрат:

data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Long,
    val category: String
)

Такий обʼєкт зручно порівнювати за всіма чотирма полями, зручно друкувати й зручно використовувати в колекціях.

І навпаки: якщо ви винесли властивість у тіло класу, вона не бере участі в автоматично згенерованих equals/hashCode/toString. Це офіційне правило data class: властивості в тілі класу виключаються з генерації.

4. toString() для налагодження та логів

Коли програма не працює, перші дві реакції розробника: «чому?» і «де мій println?». І ось тут toString() раптом стає дуже корисним: ви хочете побачити обʼєкт повністю й читабельно. У data class toString() генерується у форматі на кшталт Expense(id=1, title=Кава, amountCents=250, category=Їжа) — тобто прямо показує назву класу та значення полів конструктора.

Додаймо друк витрати:

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val e = Expense(1, "Кава", 250)
    println(e) // Expense(id=1, title=Кава, amountCents=250)
}

Зверніть увагу на важливий нюанс: toString() — це діагностичне подання, а не «гарний користувацький вивід». Користувач, найімовірніше, хоче «Кава — $2.50», а не «Expense(amountCents=250)». Тому в нашому застосунку ми використовуватимемо toString() як інструмент налагодження та логів, а для виводу користувачеві зробимо окрему функцію форматування (але без фанатизму — ми все ще живемо в консольному світі).

Невелика практична звичка: коли ви налагоджуєте бізнес-логіку, друк println(expense) — це як ліхтарик у темному підвалі. Він не робить підвал гарним, але допомагає не впасти в люк.

5. equals() і зміст «однаковості» обʼєктів

Моделі даних часто порівнюють саме за вмістом. Для data class Kotlin генерує equals() так, щоб порівняння відбувалося за значеннями властивостей primary constructor.

Тобто така перевірка стає природною:

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val a = Expense(1, "Кава", 250)
    val b = Expense(1, "Кава", 250)
    val c = Expense(2, "Кава", 250)

    println(a == b) // true
    println(a == c) // false
}

У межах нашого застосунку це має зрозумілий зміст: якщо дві витрати мають однакові поля, то як дані вони однакові.

Але тут є важлива думка для проєктування: рівність — це контракт змісту. Ви маєте вирішити, які поля справді визначають «однаковість». Наприклад, якщо id — унікальний ідентифікатор, то можна вважати, що дві витрати рівні лише тоді, коли збігаються їхні id (а решта полів — це просто «дані запису»). Але якщо ви хочете знаходити дублікати за вмістом, тоді id може заважати. Сьогодні ми не будемо «ламати собі голову» архітектурними суперечками — просто зафіксуймо: data class порівнює за всіма полями конструктора, тож обирайте ці поля свідомо.

6. hashCode() і чому без нього Set і Map ламаються

Порівняння через equals() — це лише половина історії. У колекціях Set і Map ключову роль відіграє hashCode(). У data class Kotlin генерує пару equals()/hashCode() узгоджено — так, щоб обʼєкти, які рівні за equals, мали однаковий hashCode.

Чому це так важливо? Тому що Set і Map працюють приблизно за схемою: «спочатку за хешем знайти кошик, потім через equals() уточнити». Якщо hashCode і equals «живуть кожен своїм життям», колекція почне губити елементи або не знаходити ключі.

Дуже спрощена схема пошуку в HashMap має такий вигляд:

flowchart TD
    A["Ключ (key)"] --> B["hashCode()"]
    B --> C["кошик / bucket"]
    C --> D["equals() з кандидатами"]
    D --> E["знайшли значення / не знайшли"]

Тепер покажемо користь на практиці. Припустімо, ми хочемо зберігати витрати за id:

data class ExpenseId(val value: Int)

fun main() {
    val m = mapOf(ExpenseId(1) to "Кава", ExpenseId(2) to "Таксі")
    println(m[ExpenseId(1)]) // Кава
}

Чому це працює? Тому що ExpenseId(1) «рівний» іншому ExpenseId(1) за equals, і в них однаковий hashCode. Саме тому ми можемо створити новий обʼєкт-ключ і успішно знайти старе значення.

7. Властивості в тілі data class і «невидимі» поля

Це класична помилка: ви додали поле, очікуєте, що воно бере участь у порівнянні та друці, а воно «ніби невидиме». Причина майже завжди одна: поле оголошене не в primary constructor, а в тілі класу. Тоді його виключено з equals/hashCode/toString (і з інших згенерованих функцій), бо Kotlin бере до уваги лише властивості конструктора.

Подивімося на приклад, який виглядає логічно, але поводиться несподівано:

data class User(val name: String) {
    var age: Int = 0
}

fun main() {
    val a = User("Ann").also { it.age = 10 }
    val b = User("Ann").also { it.age = 99 }

    println(a == b) // true
    println(a)      // User(name=Ann)
}

Якщо читати це «по-людськи», хочеться сказати: «вони ж різні, вік різний». Але з точки зору data class вік — не частина даних, бо його немає в primary constructor. Kotlin робить це навмисно й передбачувано: ви самі вирішуєте, що входить у «паспорт даних», а що є допоміжним станом.

Практичне правило для нашого застосунку обліку витрат: якщо поле впливає на унікальність, порівняння, використання в Map/Set і загалом є частиною даних моделі — тримайте його в primary constructor. Якщо це кеш, тимчасовий прапорець для інтерфейсу (у консолі теж буває «інтерфейс», не смійтеся) або технічний стан — можна винести в тіло класу. Але тоді не дивуйтеся наслідкам.

8. Практика в застосунку: Set, Map і стабільність ключів

Антидублікати та Set

У наших ранніх версіях проєкту ми часто зберігали записи в MutableList і шукали/видаляли їх вручну. Для старту це нормально. Але коли ви вже використовуєте класи й особливо data class, хочеться трохи більше порядку: наприклад, не додавати повний дублікат, якщо користувач випадково повторив команду.

Нехай у нас є проста функція додавання витрати до списку, але ми хочемо відсіювати дублікати. Для демонстрації достатньо Set, бо він зберігає унікальні елементи (за equals/hashCode).

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val unique = mutableSetOf<Expense>()

    unique.add(Expense(1, "Кава", 250))
    unique.add(Expense(1, "Кава", 250))

    println(unique.size) // 1
}

З точки зору Set другий елемент — дублікат, бо він рівний першому за equals() і має той самий hashCode(). Для моделей даних це зазвичай саме те, чого очікує розробник (і користувач теж: «навіщо мені дві однакові кави в один і той самий момент?»).

Але тут знову спливає проєктне питання: що вважати дублікатом — «той самий вміст» чи «той самий id»? У реальному застосунку це залежить від вашої логіки. Сьогодні нам важливіше інше: data class робить поведінку колекцій передбачуваною, якщо ви правильно обрали поля конструктора.

Ключі Map і «чому воно не знаходиться?!»

Наступний практичний сценарій: ви будуєте індекс або швидкий доступ. Наприклад, хочете за id знаходити витрату. Поки ви використовували List, ви шукали циклом або через find. Але Map уміє діставати значення за ключем майже миттєво — якщо ключі коректно порівнюються.

Покажемо на маленькому прикладі (без ускладнень архітектурою):

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val byId = mapOf(
        1 to Expense(1, "Кава", 250),
        2 to Expense(2, "Таксі", 1500)
    )

    println(byId[2]) // Expense(id=2, title=Таксі, amountCents=1500)
}

А якщо ви хочете ключ не Int, а окремий тип (так часто роблять, щоб не плутати різні ідентифікатори), то data class підходить ідеально:

data class ExpenseId(val value: Int)

fun main() {
    val byId = mapOf(ExpenseId(1) to "Кава")
    println(byId[ExpenseId(1)]) // Кава
}

І саме тут знову видно, чому equals/hashCode критичні: ми створили новий ExpenseId(1) і все одно знайшли значення. Це базова «магія» Map, і вона працює завдяки контракту рівності, який data class генерує автоматично.

Пастка: мутабельні ключі в Map і елементи в Set

Зараз буде момент, коли багато хто вперше розуміє, чому досвідчені розробники так люблять val, а не var. І річ тут не в «моралі». Річ у тому, що hashCode має бути стабільним, поки обʼєкт лежить у HashSet або використовується як ключ у HashMap.

Якщо поля, що беруть участь у equals/hashCode, можна змінювати (var), то ви можете покласти обʼєкт у Set, потім змінити його — і Set починає поводитися дивно: обʼєкт ніби є, але contains може повернути false. Це не «помилка Kotlin», а закономірність: обʼєкт опинився в кошику за старим хешем, а після зміни мав би лежати за новим.

Поганий приклад (саме як приклад, а не як зразок для наслідування):

data class Key(var id: Int)

fun main() {
    val s = hashSetOf(Key(1))
    val k = s.first()

    k.id = 2

    println(s.contains(Key(2))) // false (дуже ймовірно)
}

Тут немає гарантії, що ви побачите однаковий результат на всіх реалізаціях, але сама ідея лишається: змінювати ключові поля після додавання в HashSet/HashMap — це як міняти номер квартири на дверях, коли курʼєр уже стоїть на сходах. Курʼєр (колекція) чесно прийшов за адресою, а адреса раптом «оновилася».

Для нашого застосунку правило просте: якщо обʼєкт буде елементом Set або ключем Map, то поля, що впливають на equals/hashCode, краще робити val і не змінювати. Це робить програму передбачуваною й економить години налагодження, які інакше йдуть на діалог із порожнечею: «чому ключ не знаходиться, я ж щойно його додав».

Чи можна перевизначати equals/hashCode/toString вручну

Іноді виникає думка: «А що як мені потрібен інший формат друку або інша рівність?». У Kotlin це можливо: ви можете написати власні реалізації equals(), hashCode() або toString() прямо в тілі data class. Але тоді компілятор не генеруватиме ці методи сам — він використає ваші. Це описано як частина правил генерації data class: якщо є явні реалізації (або фінальні в суперкласі), то вони не генеруються автоматично.

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

toString() іноді хочеться змінити, але памʼятайте: ми домовилися, що toString() — це інструмент налагодження. Якщо ви зробите його «гарним для користувача», ви можете втратити діагностичну цінність. Трохи пізніше, коли ми поліпшуватимемо дизайн доменних моделей, ви навчитеся ще акуратніше відокремлювати «логування/налагодження» і «користувацький вивід».

9. Типові помилки

Помилка №1: очікувати, що поля в тілі data class беруть участь у рівності й друці.
Це одна з найчастіших причин «магічної поведінки»: ви додали var age або var cachedTotal, а потім дивуєтеся, що println(obj) його не показує, а obj1 == obj2 його ігнорує. У data class автогенерація бере лише властивості primary constructor, і це зроблено навмисно, щоб «структуру даних» було чітко видно вже в заголовку класу.

Помилка №2: використовувати === для порівняння моделей даних.
Посилальна рівність корисна рідко й зазвичай у спеціальних випадках. Для моделей на кшталт Expense, User, Product майже завжди потрібен ==, бо зміст «однаковості» — це однакові поля, а не «те саме посилання». Якщо ви випадково порівнюєте ===, ви отримаєте хибу навіть для «однакових» витрат і почнете будувати логіку на неправильній перевірці.

Помилка №3: робити ключові поля var і змінювати обʼєкт після додавання в HashSet/HashMap.
Це той випадок, коли програма не просто падає — вона починає вводити в оману: «елемента немає», хоча ви його додавали. Причина в тому, що HashSet і HashMap покладаються на стабільність hashCode. Якщо поле, що бере участь в обчисленні хешу, змінилося, обʼєкт «переїхав» би в інший кошик, але колекція про це не дізнається. Тому для ключів і унікальних елементів майже завжди обирають val.

Помилка №4: вважати toString() користувацьким виводом.
toString() у data class справді дуже зручний і читабельний, але він призначений для діагностики: налагодження, логів, перевірки стану. Якщо почати показувати його користувачу як «інтерфейс», ви швидко впираєтеся в те, що формат не дружній (і може змінитися після перейменування полів). Краще мати окрему функцію форматування для відображення, а toString() залишити розробнику — тобто вам, коли ви вночі лагодите помилку й тихо обіцяєте собі «завтра все перепишу гарно».

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