JavaRush /Курси /Kotlin SELF /data class як модел...

data class як модель даних

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

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 виглядає невеликою, але насправді вона заощаджує вам багато ручної рутини. Компілятор автоматично генерує набір методів — і робить це лише на основі властивостей у первинному конструкторі (до цього ми ще повернемося).

Що генерується Як це відчувається в коді Навіщо в реальному житті
toString()
println(expense) показує «людський» текст Налагодження, логи, швидка діагностика
equals()
a == b порівнює за полями Перевірки «той самий зміст», пошук/видалення в колекціях
hashCode()
іде в парі з equals() Потрібно для коректної роботи в Set/Map
copy()
val new = old.copy(amount = 500) «Оновлення через копію», зручно в незмінюваних моделях
componentN()
дає змогу 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 у конструкторі, він не шкідничає. Він захищає вас від класів, у яких незрозуміло, що вважати «даними». Ці обмеження зроблено для того, щоб згенерований код був осмисленим і несуперечливим.

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