Files
obsidian/Kotlin/Синтаксис и типы/Базовые типы ООП/Data class.md
T

12 KiB
Raw Blame History

title, description, tags
title description tags
Data class Автоматические equals, hashCode, toString, copy и componentN, их границы и ловушки
kotlin
oop

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.1 copy получает ту же видимость, что конструктор, если включён -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