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

277 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Data class
description: Автоматические equals, hashCode, toString, copy и componentN, их границы и ловушки
tags:
- kotlin
- oop
---
`data class` - класс, для которого компилятор генерирует стандартный набор методов.
```kotlin
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` | для [деструктуризации](../Работа%20с%20данными/Деструктуризация.md) |
| `copy` | копия с заменой части свойств |
## Требования
```kotlin
data class Point(val x: Int, val y: Int)
```
- основной конструктор с **хотя бы одним** параметром
- все параметры конструктора - `val` или `var`
- класс не может быть `abstract`, `open`, `sealed` или `inner`
```kotlin
// data class Empty // ошибка: нет параметров
// data class Bad(x: Int) // ошибка: x не val/var
```
## Только свойства конструктора
Это главное, что нужно понимать про `data class`. Свойства, объявленные в теле, **не участвуют** в генерации:
```kotlin
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
Создаёт новый объект, меняя указанные свойства:
```kotlin
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
```kotlin
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=Арбат))
}
```
Для глубоких структур такие цепочки становятся громоздкими - это известная цена иммутабельности.
## Переопределение сгенерированного
Любой из методов можно написать руками - свой вариант побеждает:
```kotlin
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 сгенерирован
}
```
Частый случай - равенство только по идентификатору:
```kotlin
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`:
```kotlin
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`, но может реализовывать интерфейсы и наследоваться от обычного класса:
```kotlin
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%20class%20и%20interface.md):
```kotlin
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`:
```kotlin
object Plain
data object Loading
fun main() {
println(Plain) // Plain@1b6d3586
println(Loading) // Loading
}
```
Подробнее: [Object и companion object](Object%20и%20companion%20object.md)
## Когда не нужен
- **Сущность с идентичностью** (entity в БД): равенство по всем полям неверно, нужно по `id`
- **Класс с поведением**: если методов больше, чем свойств, это не носитель данных
- **Публичный API, который будет развиваться**: порядок свойств в конструкторе становится контрактом из-за `componentN`
> [!info] Отличия от Java
> Ближайший аналог - `record` из Java 16:
> ```java
> record User(int id, String name) {}
> ```
>
> | | Java record | Kotlin data class |
> |---|---|---|
> | `equals`/`hashCode`/`toString` | да | да |
> | копия с изменением | нет | `copy()` |
> | деструктуризация | только в pattern matching | везде |
> | изменяемые поля | нет, всегда final | `var` разрешён |
> | наследование от класса | нет | можно |
> | значения по умолчанию | нет | есть |
> | своё тело | можно | можно |
>
> Главное практическое различие - `copy()`. В Java для изменения одного поля record приходится вызывать конструктор со всеми аргументами:
> ```java
> var moved = new User(u.id(), "Пётр");
> ```
>
> Второе - `record` всегда неизменяем, `data class` допускает `var`. Это гибкость, которой лучше не пользоваться.
>
> До появления `record` в Java такие классы писали руками или генерировали Lombok-ом (`@Data`, `@Value`). Lombok генерировал ещё и `@Builder`, аналога которому в Kotlin нет - его заменяют значения по умолчанию и именованные аргументы.
>
> Сравнить: [Record в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Record.md)
> [!warning] Подводные камни
> **Массив в `data class` ломает равенство:**
> ```kotlin
> data class Packet(val data: ByteArray)
>
> fun main() {
> println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // false!
> }
> ```
> Сгенерированный `equals` вызывает `data.equals(other.data)`, а у массива это сравнение ссылок. Нужно писать руками:
> ```kotlin
> 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
> }
> ```
> Подробнее: [Массивы](../Работа%20с%20данными/Массивы.md)
>
> **`var` в `data class` + использование как ключа `Map` = потерянная запись:**
> ```kotlin
> 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` копирует поверхностно.** Вложенный изменяемый объект остаётся общим:
> ```kotlin
> 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`. Перестановка свойств одинакового типа не вызовет ошибки компиляции у клиентов, но сломает их деструктуризацию. Подробнее: [Деструктуризация](../Работа%20с%20данными/Деструктуризация.md)
>
> **`copy` до Kotlin 2.0 игнорировал видимость конструктора.** Приватный конструктор не защищал от создания невалидного объекта через `copy`. С Kotlin 2.1 `copy` получает ту же видимость, что конструктор, если включён `-Xconsistent-data-class-copy-visibility`; это станет поведением по умолчанию.
>
> **`toString` печатает всё, включая пароли и токены.** Логирование `data class` с секретами - реальная утечка:
> ```kotlin
> 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](equals,%20hashCode%20и%20toString.md)
Далее: [Enum class](Enum%20class.md)