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() залишити розробнику — тобто вам, коли ви вночі лагодите помилку й тихо обіцяєте собі «завтра все перепишу гарно».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ