12 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Data class | Автоматические equals, hashCode, toString, copy и componentN, их границы и ловушки |
|
data class - класс, для которого компилятор генерирует стандартный набор методов.
data class User(val id: Int, val name: String)
fun main() {
val u = User(1, "Иван")
println(u) // User(id=1, name=Иван)
println(u == User(1, "Иван")) // true
println(u.copy(name = "Пётр")) // User(id=1, name=Пётр)
val (id, name) = u
println("$id $name") // 1 Иван
}
Генерируется:
| Метод | Что делает |
|---|---|
equals |
сравнивает все свойства основного конструктора |
hashCode |
считает по тем же свойствам |
toString |
Имя(свойство=значение, ...) |
componentN |
для деструктуризации |
copy |
копия с заменой части свойств |
Требования
data class Point(val x: Int, val y: Int)
- основной конструктор с хотя бы одним параметром
- все параметры конструктора -
valилиvar - класс не может быть
abstract,open,sealedилиinner
// data class Empty // ошибка: нет параметров
// data class Bad(x: Int) // ошибка: x не val/var
Только свойства конструктора
Это главное, что нужно понимать про data class. Свойства, объявленные в теле, не участвуют в генерации:
data class User(val id: Int) {
var nickname: String = "" // в теле — игнорируется
}
fun main() {
val a = User(1).apply { nickname = "иван" }
val b = User(1).apply { nickname = "пётр" }
println(a == b) // true! nickname не сравнивается
println(a) // User(id=1) — nickname не печатается
println(a.copy()) // User(id=1) — nickname потерян, станет ""
}
Если свойство важно для равенства - оно должно быть в конструкторе.
copy
Создаёт новый объект, меняя указанные свойства:
data class User(val id: Int, val name: String, val active: Boolean = true)
fun main() {
val u = User(1, "Иван")
println(u.copy()) // User(id=1, name=Иван, active=true)
println(u.copy(name = "Пётр")) // User(id=1, name=Пётр, active=true)
println(u.copy(id = 2, active = false))
println(u) // исходный не изменился
}
copy - основной способ «изменить» неизменяемый объект. Аргументы почти всегда именованные.
Вложенные data class
data class Address(val city: String, val street: String)
data class Person(val name: String, val address: Address)
fun main() {
val p = Person("Иван", Address("Москва", "Арбат"))
println(p == Person("Иван", Address("Москва", "Арбат"))) // true
// замена вложенного свойства — copy внутри copy
val moved = p.copy(address = p.address.copy(city = "Казань"))
println(moved) // Person(name=Иван, address=Address(city=Казань, street=Арбат))
}
Для глубоких структур такие цепочки становятся громоздкими - это известная цена иммутабельности.
Переопределение сгенерированного
Любой из методов можно написать руками - свой вариант побеждает:
data class User(val id: Int, val name: String) {
override fun toString() = "#$id $name"
}
fun main() {
println(User(1, "Иван")) // #1 Иван
println(User(1, "Иван") == User(1, "Иван")) // true — equals сгенерирован
}
Частый случай - равенство только по идентификатору:
data class User(val id: Int, val name: String) {
override fun equals(other: Any?) = other is User && other.id == id
override fun hashCode() = id
}
fun main() {
println(User(1, "Иван") == User(1, "Пётр")) // true — сравниваем id
}
Но если равенство нестандартное, data class уже почти не нужен - остаются только copy и toString.
В коллекциях
Корректный equals/hashCode делает data class пригодным для Set и ключей Map:
data class Key(val a: Int, val b: String)
fun main() {
val map = mapOf(Key(1, "x") to "значение")
println(map[Key(1, "x")]) // значение — новый объект находит запись
println(setOf(Key(1, "x"), Key(1, "x")).size) // 1 — дубликат убран
println(listOf(Key(1, "x")).contains(Key(1, "x"))) // true
}
Наследование
data class не может быть open, но может реализовывать интерфейсы и наследоваться от обычного класса:
interface Named { val name: String }
data class User(override val name: String, val id: Int) : Named
fun main() {
val n: Named = User("Иван", 1)
println(n.name) // Иван
}
Часто data class реализуют варианты sealed-иерархии:
sealed interface Event
data class Click(val x: Int, val y: Int) : Event
data class KeyPress(val code: Int) : Event
data object Close : Event
fun handle(e: Event): String = when (e) {
is Click -> "клик ${e.x},${e.y}"
is KeyPress -> "клавиша ${e.code}"
Close -> "закрытие"
}
fun main() {
println(handle(Click(1, 2))) // клик 1,2
println(handle(Close)) // закрытие
}
data object
Для варианта без данных есть data object (Kotlin 1.9+) - он отличается от обычного object осмысленным toString:
object Plain
data object Loading
fun main() {
println(Plain) // Plain@1b6d3586
println(Loading) // Loading
}
Подробнее: Object и companion object
Когда не нужен
- Сущность с идентичностью (entity в БД): равенство по всем полям неверно, нужно по
id - Класс с поведением: если методов больше, чем свойств, это не носитель данных
- Публичный API, который будет развиваться: порядок свойств в конструкторе становится контрактом из-за
componentN
[!info] Отличия от Java Ближайший аналог -
recordиз Java 16:record User(int id, String name) {}
Java record Kotlin data class equals/hashCode/toStringда да копия с изменением нет copy()деструктуризация только в pattern matching везде изменяемые поля нет, всегда final varразрешённаследование от класса нет можно значения по умолчанию нет есть своё тело можно можно Главное практическое различие -
copy(). В Java для изменения одного поля record приходится вызывать конструктор со всеми аргументами:var moved = new User(u.id(), "Пётр");Второе -
recordвсегда неизменяем,data classдопускаетvar. Это гибкость, которой лучше не пользоваться.До появления
recordв Java такие классы писали руками или генерировали Lombok-ом (@Data,@Value). Lombok генерировал ещё и@Builder, аналога которому в Kotlin нет - его заменяют значения по умолчанию и именованные аргументы.Сравнить: Record в Java
[!warning] Подводные камни Массив в
data classломает равенство:data class Packet(val data: ByteArray) fun main() { println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // false! }Сгенерированный
equalsвызываетdata.equals(other.data), а у массива это сравнение ссылок. Нужно писать руками:class Packet(val data: ByteArray) { override fun equals(other: Any?) = other is Packet && data.contentEquals(other.data) override fun hashCode() = data.contentHashCode() } fun main() { println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // true }Подробнее: Массивы
varвdata class+ использование как ключаMap= потерянная запись:data class Key(var id: Int) fun main() { val key = Key(1) val map = hashMapOf(key to "значение") key.id = 2 // hashCode изменился println(map[key]) // null — запись потеряна println(map[Key(1)]) // null — старый бакет, другой equals println(map.size) // 1 — но она там есть }Это худшая ошибка с
data class. Поэтому свойства делаютval.
copyкопирует поверхностно. Вложенный изменяемый объект остаётся общим:data class Box(val items: MutableList<Int>) fun main() { val a = Box(mutableListOf(1)) val b = a.copy() b.items.add(2) println(a.items) // [1, 2] — изменился и оригинал! }Свойства в теле класса не входят ни в
equals, ни вcopy, ни вtoString- значение теряется при копировании.Порядок свойств - часть API из-за
componentN. Перестановка свойств одинакового типа не вызовет ошибки компиляции у клиентов, но сломает их деструктуризацию. Подробнее: Деструктуризация
copyдо Kotlin 2.0 игнорировал видимость конструктора. Приватный конструктор не защищал от создания невалидного объекта черезcopy. С Kotlin 2.1copyполучает ту же видимость, что конструктор, если включён-Xconsistent-data-class-copy-visibility; это станет поведением по умолчанию.
toStringпечатает всё, включая пароли и токены. Логированиеdata classс секретами - реальная утечка:data class Credentials(val login: String, val password: String) { override fun toString() = "Credentials(login=$login, password=***)" } fun main() { println(Credentials("ivan", "secret")) }
Дополнительно: equals, hashCode и toString Далее: Enum class