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 выглядит маленькой, но внутри она экономит вам очень много ручной рутины. Компилятор автоматически генерирует набор методов, причём только на основе свойств в primary constructor (к этому мы ещё вернёмся).
| Что генерируется | Как это ощущается в коде | Зачем в реальной жизни |
|---|---|---|
|
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 в primary constructor
Вот здесь многие впервые спотыкаются. В data class компилятор берёт для автогенерации только свойства (val или var) из primary constructor. Если вы объявите поле в теле класса, оно не будет участвовать в 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 есть несколько требований.
Нужен хотя бы один параметр в primary constructor:
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 — это всё ещё класс. Вы можете добавить методы, computed properties, маленькие проверки и т.д. Вопрос не в “можно/нельзя”, а в том, чтобы модель не превращалась в обычный класс, который и хранит данные, и общается с консолью, и ходит в файлы, и принимает команды пользователя.
Для нашего 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) его не показывает?” Потому что автогенерация учитывает только свойства primary constructor. Если поле влияет на смысл объекта, держите его в конструкторе; если это “временное/служебное” — тогда, наоборот, логично держать в теле.
Ошибка №3: делать data class с var‑полями “на всякий случай”.
Технически можно, но психологически это часто приводит к хаосу: объект меняется где-то по дороге, а вы не понимаете где. Как стартовое правило для новичка: если не уверены — начинайте с val. Мутабельность добавлять легче, чем потом охотиться за “кто поменял поле”.
Ошибка №4: пытаться сделать data class “универсальным контейнером всего” и засунуть туда половину приложения.
Если в модели данных внезапно появляются методы, которые читают ввод, печатают меню и изменяют глобальные коллекции — это уже не модель данных, это комбайн. Да, Kotlin позволит, но поддерживать это потом тяжело. data class сильнее всего, когда он описывает данные и максимум маленькие чистые вычисления рядом с ними.
Ошибка №5: спорить с требованиями data class, вместо того чтобы принять их как подсказку дизайна.
Когда компилятор запрещает data class без параметров или требует val/var в конструкторе, он не вредничает. Он защищает вас от классов, у которых непонятно, что считать “данными”. Эти ограничения сделаны, чтобы сгенерированный код был осмысленным и непротиворечивым.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ