1. Знайомство з data class
Якщо ви колись писали клас на кшталт «User(name, age)» або «Expense(amount, category)», то майже напевно опинялися в такій ситуації: ви створили об’єкт, хочете його надрукувати — а він виводиться як Expense@6d06d69c. Потім ви намагаєтеся порівняти два об’єкти «за значеннями полів», а порівняння раптом працює не так, як ви очікуєте. І десь поруч уже маячить думка: «Гаразд, зараз напишу toString() і equals() вручну…» — і ось ви вже в клубі людей, які пишуть одне й те саме всоте.
У моделях даних зазвичай повторюються ті самі потреби. Ми хочемо швидко й читабельно виводити об’єкт у консоль (для налагодження), порівнювати два об’єкти за полями (а не за принципом «це один і той самий об’єкт»), інколи створювати «майже такий самий об’єкт, але зі зміненим одним полем», а ще — зручно «розпаковувати» об’єкт у кілька змінних. Kotlin уміє автоматично дати вам усе це, якщо ви чесно скажете компілятору: «Це клас даних, тут головне — значення».
Що таке data class
data class — це звичайний клас Kotlin, але позначений ключовим словом data. Так ви ніби укладаєте угоду з компілятором: «Мій клас насамперед зберігає дані, а його поведінка має бути стандартною й передбачуваною». У відповідь компілятор генерує кілька методів, які в типових моделях майже завжди потрібні: друк, порівняння, копіювання, деконструкцію. Це не «магія», а цілком конкретний набір функцій.
Мінімальний приклад має такий вигляд:
data class User(val name: String, val age: Int)
fun main() {
val u = User("Ann", 20)
println(u) // User(name=Ann, age=20)
}
Найприємніше в цьому прикладі — те, що println(u) уже працює «по-людськи», бо компілятор автоматично зробив toString() у вигляді User(name=..., age=...).
2. Що генерує компілятор для data class
Зовні data class виглядає невеликою, але насправді вона заощаджує вам багато ручної рутини. Компілятор автоматично генерує набір методів — і робить це лише на основі властивостей у первинному конструкторі (до цього ми ще повернемося).
| Що генерується | Як це відчувається в коді | Навіщо в реальному житті |
|---|---|---|
|
println(expense) показує «людський» текст | Налагодження, логи, швидка діагностика |
|
a == b порівнює за полями | Перевірки «той самий зміст», пошук/видалення в колекціях |
|
іде в парі з equals() | Потрібно для коректної роботи в Set/Map |
|
val new = old.copy(amount = 500) | «Оновлення через копію», зручно в незмінюваних моделях |
|
дає змогу val (x, y) = point | Деконструкція |
toString() — друкуємо об’єкт для налагодження
data class Product(val id: Int, val title: String)
fun main() {
val p = Product(10, "Milk")
println(p) // Product(id=10, title=Milk)
}
Тут важливий момент: це діагностичний вивід. Він ідеально підходить для налагодження, але не зобов’язаний бути «красивим для користувача» (користувацький вивід ви зазвичай робите окремо).
equals() і hashCode() — порівняння за даними
data class Point(val x: Int, val y: Int)
fun main() {
val a = Point(1, 2)
val b = Point(1, 2)
println(a == b) // true
}
Для data class оператор == порівнює значення властивостей конструктора (тобто викликає згенерований equals). hashCode() генерується узгоджено з equals(), тому такі об’єкти коректно працюють у Set і як ключі в Map.
copy() — копія зі змінами
data class User(val name: String, val age: Int)
fun main() {
val u1 = User("Ann", 20)
val u2 = u1.copy(age = 21)
println(u1) // User(name=Ann, age=20)
println(u2) // User(name=Ann, age=21)
}
Чому це зручно: ви не «змінюєте об’єкт», а створюєте нову версію.
componentN() і деконструкція
data class Rect(val width: Int, val height: Int)
fun main() {
val r = Rect(10, 20)
val (w, h) = r
println("w=$w, h=$h") // w=10, h=20
}
Деконструкція — це синтаксичний цукор, а «під капотом» стоять методи component1(), component2() тощо.
3. Правила data class
Ураховуються лише val/var у первинному конструкторі
Ось тут багато хто вперше спотикається. У data class компілятор бере для автогенерації лише властивості (val або var) із первинного конструктора. Якщо ви оголосите поле в тілі класу, воно не братиме участі в toString/equals/hashCode/copy/componentN. Це не «помилка», а спосіб явно сказати: «це поле — не частина “даних моделі”».
Приклад, який виглядає невинно, але призводить до сюрпризів:
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)
}
Чому так? Бо age оголошено в тілі, а не в конструкторі, і за правилами Kotlin його виключено з автозгенерованих функцій.
Тут є здорова ідея: стан моделі даних має бути «офіційно» описаний у конструкторі. Тоді і ви, і компілятор, і людина, яка читає код, розумітимете, що саме вважається значущим станом об’єкта.
Вимоги та обмеження data class
Компілятор може генерувати методи лише тоді, коли структура класу достатньо зрозуміла й не суперечить сама собі. Тому у data class є кілька вимог.
Потрібен хоча б один параметр у первинному конструкторі:
data class Empty() // не скомпілюється: потрібен хоча б один параметр
Параметри мають бути val або var:
data class User(name: String, val age: Int) // не скомпілюється: name не val/var
Не можна open:
open data class User(val name: String) // не скомпілюється
На практиці це корисно: data class обіцяє передбачувану рівність, копіювання та деконструкцію. Успадкування часто робить ці обіцянки розмитими, тому Kotlin забороняє деякі комбінації, щоб ви випадково не отримали «логічну кашу».
4. Практика: модель витрат і читабельні результати
Переносимо модель витрати на data class
Зараз зробимо те, заради чого ми тут і зібралися: застосуємо data class у нашому міні‑застосунку обліку витрат. Нехай у нас є витрата із сумою в центах (щоб не звʼязуватися з Double), категорією та коментарем.
Припустімо, раніше модель могла виглядати так (звичайний клас):
class Expense(
val amountCents: Long,
val category: String,
val note: String
)
fun main() {
val e = Expense(199_00, "food", "Pizza")
println(e) // Expense@6d06d69c (приблизно так)
}
Це нормально для старту, але для діагностики незручно: println(e) нічого про витрату не каже.
Тепер замінимо на data class:
data class Expense(
val amountCents: Long,
val category: String,
val note: String
)
fun main() {
val e = Expense(199_00, "food", "Pizza")
println(e) // Expense(amountCents=19900, category=food, note=Pizza)
}
Щоб трохи наблизити приклад до нашого застосунку, додамо список витрат і виведемо його:
data class Expense(val amountCents: Long, val category: String, val note: String)
fun main() {
val expenses = listOf(
Expense(199_00, "food", "Pizza"),
Expense(350_00, "transport", "Taxi")
)
println(expenses)
// [Expense(amountCents=19900, category=food, note=Pizza), Expense(amountCents=35000, category=transport, note=Taxi)]
}
Так, вивід не надто гарний для користувача, зате для налагодження — дуже зручний. Раніше вам довелося б або писати форматування вручну, або друкувати поля окремо.
Чому data class кращий, ніж Pair/Triple
У попередніх темах ми користувалися Pair/Triple як швидким способом повернути 2–3 значення з функції. Це робочий інструмент, але в нього є недолік: «перше» і «друге» самі по собі нічого не пояснюють. За тиждень ви дивитеся на pair.first і думаєте: «first — це сума? чи категорія? чи що взагалі відбувається?»
Порівняйте відчуття.
Варіант на Pair:
fun parseAmountAndCategory(raw: String): Pair<Long, String> {
return 199_00L to "food"
}
fun main() {
val p = parseAmountAndCategory("19900 food")
println(p.first) // 19900
println(p.second) // food
}
Працює, але сенс доводиться тримати в голові.
Варіант на data class:
data class ParsedExpense(val amountCents: Long, val category: String)
fun parseAmountAndCategory(raw: String): ParsedExpense {
return ParsedExpense(199_00L, "food")
}
fun main() {
val p = parseAmountAndCategory("19900 food")
println(p.amountCents) // 19900
println(p.category) // food
}
Результат той самий, зате читабельність помітно вища. А читабельність — це не «краса», а «менше шансів зламати програму о 2-й ночі».
«Трохи поведінки» в data class — це нормально
Іноді новачки сприймають data class надто буквально: «Раз це “клас даних”, то всередині взагалі не можна нічого робити». Можна. data class — це все ще клас. Ви можете додати методи, обчислювані властивості, невеликі перевірки тощо. Питання не в «можна/не можна», а в тому, щоб модель не перетворювалася на звичайний клас, який і зберігає дані, і спілкується з консоллю, і працює з файлами, і приймає команди користувача.
Для нашого Expense доречно, наприклад, додати просту обчислювану властивість для виведення суми в доларах і центах (як формат даних, без I/O):
data class Expense(val amountCents: Long, val category: String, val note: String) {
val amountLabel: String
get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
}
fun main() {
val e = Expense(199_05, "food", "Pizza")
println(e.amountLabel) // 199.05
}
Зверніть увагу: це все ще «дані поруч із невеликою зручністю». Ми не друкуємо всередині моделі в консоль, не читаємо readln(), не змінюємо глобальні списки — просто допомагаємо зручніше працювати з даними.
5. Типові помилки під час роботи з data class
Перші дні з data class зазвичай виглядають так: ви щасливо скоротили код, потім трохи менш щасливо отримали «дивну рівність», а потім остаточно щасливо розібралися з правилами гри. Помилки нижче — не тому, що ви «поганий програміст», а тому, що мозок чесно намагається застосувати старі звички зі «звичайних класів».
Помилка №1: очікувати, що data class — це просто «коротше писати», не розуміючи, що саме генерується.
У підсумку людина починає використовувати copy() або деконструкцію «на автоматі», а потім дивується поведінці. Лікується дуже просто: тримайте в голові список — equals/hashCode/toString/copy/componentN. Це не «додаткові фішки», це ядро контракту data‑класу.
Помилка №2: винести важливе поле в тіло класу й чекати, що воно братиме участь у equals() і toString().
Це одна з найчастіших пасток: «Ну я ж додав var age… чому println(user) його не показує?» Бо автогенерація враховує лише властивості первинного конструктора. Якщо поле впливає на сенс об’єкта, тримайте його в конструкторі. Якщо ж воно «тимчасове/службове», тоді, навпаки, логічно тримати його в тілі.
Помилка №3: робити data class із var‑полями «про всяк випадок».
Технічно можна, але психологічно це часто призводить до хаосу: об’єкт змінюється десь по дорозі, а ви не розумієте, де саме. Як стартове правило для новачка: якщо не впевнені — починайте з val. Мутабельність додати легше, ніж потім полювати на «хто змінив поле».
Помилка №4: намагатися зробити data class «універсальним контейнером для всього» і запхати туди половину застосунку.
Якщо в моделі даних раптом з’являються методи, які читають введення, друкують меню й змінюють глобальні колекції, — це вже не модель даних, а комбайн. Так, Kotlin дозволить, але підтримувати це потім важко. data class найсильніший тоді, коли він описує дані й максимум невеликі чисті обчислення поруч із ними.
Помилка №5: сперечатися з вимогами data class, замість того щоб сприйняти їх як підказку для дизайну.
Коли компілятор забороняє data class без параметрів або вимагає val/var у конструкторі, він не шкідничає. Він захищає вас від класів, у яких незрозуміло, що вважати «даними». Ці обмеження зроблено для того, щоб згенерований код був осмисленим і несуперечливим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ