Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
@@ -0,0 +1,320 @@
---
title: equals, hashCode и toString
description: Контракт равенства, == против ===, правила hashCode и как не сломать HashMap
tags:
- kotlin
- oop
---
Три метода объявлены в [Any](Any,%20Unit,%20Nothing.md) и есть у каждого объекта. Их реализация по умолчанию - по ссылке и по адресу в памяти.
```kotlin
class Point(val x: Int, val y: Int)
fun main() {
val a = Point(1, 2)
val b = Point(1, 2)
println(a == b) // false — equals по ссылке
println(a) // Point@1b6d3586 — toString по хешу
}
```
## == и ===
```kotlin
fun main() {
val a = "Kotlin"
val b = StringBuilder("Kotlin").toString()
println(a == b) // true — структурное равенство, вызывает equals
println(a === b) // false — ссылочное равенство, тот же объект?
println(a !== b) // true
}
```
| Оператор | Что делает |
|---|---|
| `==` | `a?.equals(b) ?: (b === null)` |
| `===` | сравнение ссылок (для примитивов - значений) |
`==` безопасен для `null`: левый операнд может быть `null`, NPE не будет.
```kotlin
fun main() {
val a: String? = null
println(a == "x") // false — без исключения
println(a == null) // true
println(null == null) // true
}
```
## Контракт equals
Реализация должна быть:
- **рефлексивной**: `a == a` всегда `true`
- **симметричной**: `a == b` тогда и только тогда, когда `b == a`
- **транзитивной**: `a == b` и `b == c` влечёт `a == c`
- **согласованной**: результат не меняется, пока объекты не изменились
- `a == null` всегда `false`
```kotlin
class Point(val x: Int, val y: Int) {
override fun equals(other: Any?): Boolean {
if (this === other) return true // быстрый путь
if (other !is Point) return false // проверка типа и null сразу
return x == other.x && y == other.y
}
override fun hashCode(): Int = 31 * x + y
override fun toString() = "Point($x, $y)"
}
fun main() {
val a = Point(1, 2)
println(a == Point(1, 2)) // true
println(a == null) // false
println(a) // Point(1, 2)
}
```
`other !is Point` закрывает и `null`, и неверный тип: `is` для `null` даёт `false`.
Параметр обязан иметь тип `Any?` - иначе это не переопределение, а перегрузка:
```kotlin
class Bad(val x: Int) {
fun equals(other: Bad) = x == other.x // перегрузка! override не стоит
}
fun main() {
val a: Any = Bad(1)
println(a == Bad(1)) // false — вызвался equals из Any
}
```
Компилятор не позволит поставить `override` на такую функцию - это и есть подсказка.
## Контракт hashCode
Единственное жёсткое правило: **равные объекты обязаны иметь равный хеш**.
```kotlin
class Broken(val x: Int) {
override fun equals(other: Any?) = other is Broken && other.x == x
// hashCode не переопределён — контракт нарушен
}
fun main() {
val a = Broken(1)
val set = hashSetOf(a)
println(set.contains(Broken(1))) // false — объект "потерян"
println(a == Broken(1)) // true — хотя equals говорит "равны"
}
```
`HashSet` сначала ищет бакет по хешу. Хеши разные - поиск даже не доходит до `equals`.
Обратное неверно: разные объекты **могут** иметь одинаковый хеш (коллизия) - это нормально и лишь замедляет поиск.
```kotlin
class Pair2(val a: Int, val b: Int) {
override fun equals(other: Any?) = other is Pair2 && other.a == a && other.b == b
override fun hashCode() = 31 * a + b
}
fun main() {
println(Pair2(1, 32).hashCode()) // 63
println(Pair2(2, 1).hashCode()) // 63 — коллизия, но всё корректно
println(Pair2(1, 32) == Pair2(2, 1)) // false
}
```
Стандартный способ считать хеш по нескольким полям:
```kotlin
class User(val id: Int, val name: String, val tags: List<String>) {
override fun equals(other: Any?) =
other is User && other.id == id && other.name == name && other.tags == tags
override fun hashCode(): Int {
var result = id
result = 31 * result + name.hashCode()
result = 31 * result + tags.hashCode()
return result
}
}
fun main() {
val a = User(1, "Иван", listOf("admin"))
println(a == User(1, "Иван", listOf("admin"))) // true
println(a.hashCode() == User(1, "Иван", listOf("admin")).hashCode()) // true
}
```
Множитель 31 - соглашение из JDK: простое число, а умножение на него оптимизируется в сдвиг и вычитание.
Короче - через `Objects.hash`, но он создаёт массив:
```kotlin
import java.util.Objects
class User(val id: Int, val name: String) {
override fun equals(other: Any?) = other is User && other.id == id && other.name == name
override fun hashCode() = Objects.hash(id, name)
}
fun main() { println(User(1, "a") == User(1, "a")) } // true
```
## data class делает это за вас
```kotlin
data class Point(val x: Int, val y: Int)
fun main() {
val a = Point(1, 2)
println(a == Point(1, 2)) // true
println(a) // Point(x=1, y=2)
println(hashSetOf(a).contains(Point(1, 2))) // true
}
```
В большинстве случаев ручная реализация не нужна - достаточно `data class`. Писать руками приходится, когда: равенство только по части полей, в классе есть массив, или класс - сущность с идентификатором. Подробнее: [Data class](Data%20class.md)
## toString
Единственный из трёх, у которого нет контракта - но есть ожидания. Он попадает в логи и в сообщения об ошибках:
```kotlin
class Order(val id: Int, val items: List<String>) {
override fun toString() = "Order(id=$id, items=${items.size})"
}
fun main() {
println(Order(1, listOf("a", "b"))) // Order(id=1, items=2)
println("заказ: ${Order(1, emptyList())}") // заказ: Order(id=1, items=0)
println(listOf(Order(1, emptyList()))) // [Order(id=1, items=0)]
}
```
Строковый шаблон, конкатенация и печать коллекции - все вызывают `toString`. Поэтому класс без нормального `toString` превращает лог в набор адресов.
## Равенство и наследование
Строгая симметричность при наследовании достигается проверкой точного класса:
```kotlin
open class Base(val x: Int) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
return x == (other as Base).x
}
override fun hashCode() = x
}
class Derived(x: Int, val y: Int) : Base(x) {
override fun equals(other: Any?) =
super.equals(other) && y == (other as Derived).y
override fun hashCode() = 31 * super.hashCode() + y
}
fun main() {
println(Base(1) == Derived(1, 2)) // false — разные классы
println(Derived(1, 2) == Derived(1, 2)) // true
}
```
С `other is Base` вместо сравнения классов получилась бы асимметрия: `base == derived` истина, `derived == base` ложь. Это нарушение контракта, которое ломает коллекции.
Проще всего не наследоваться там, где важно равенство - именно поэтому `data class` нельзя сделать `open`.
## Равенство примитивов и упаковка
```kotlin
fun main() {
val a: Int = 1000
val b: Int = 1000
println(a == b) // true
println(a === b) // true — примитивы, сравнение значений
val c: Int? = 1000
val d: Int? = 1000
println(c == d) // true
println(c === d) // false! разные объекты Integer
}
```
Nullable-число упаковывается в `Integer`, и `===` сравнивает ссылки. Для значений от -128 до 127 работает кеш JVM, и `===` даёт `true` - то есть результат зависит от величины числа. Для чисел используйте только `==`.
> [!info] Отличия от Java
> | Java | Kotlin |
> |---|---|
> | `a.equals(b)` | `a == b` |
> | `a == b` (ссылки) | `a === b` |
> | `Objects.equals(a, b)` для null | `a == b` |
> | `@Override boolean equals(Object o)` | `override fun equals(other: Any?)` |
> | `o instanceof Point p` (Java 16+) | `other is Point` + умное приведение |
> | `Objects.hash(a, b)` | то же, или вручную |
> | генерация в IDE или Lombok | `data class` |
> | `String.valueOf(obj)` для null | `obj.toString()` работает на `null?` |
>
> **Главное: `==` в Kotlin вызывает `equals`.** Это убирает самый частый класс ошибок Java - сравнение строк через `==`, которое иногда «работает» из-за пула строк и ломается на строках, собранных в рантайме.
>
> **`==` не бросает NPE**, в отличие от `a.equals(b)` в Java. Обёртка `Objects.equals` больше не нужна.
>
> **`toString()` можно звать на `null`:**
> ```kotlin
> fun main() {
> val s: String? = null
> println(s.toString()) // "null" — расширение Any?.toString()
> }
> ```
> В Java `null.toString()` - это NPE, нужен `String.valueOf`.
>
> **`equals` в Kotlin не перегрузить случайно.** Параметр с типом `Any?` обязателен для `override`, поэтому ошибка «написал `equals(Point)` вместо `equals(Object)`» ловится компилятором.
>
> Сравнить: [Контракт между equals и hashCode](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Контракт%20между%20equals%20и%20hashCode.md), [HashMap в Java](../../../Программирование%20на%20Java/Коллекции/HashMap.md)
> [!warning] Подводные камни
> **Переопределить `equals` без `hashCode`** - значит получить объект, который теряется в `HashSet` и `HashMap`. IDE предупреждает, компилятор - нет.
>
> **Изменяемый объект как ключ `HashMap`:**
> ```kotlin
> data class Key(var id: Int)
>
> fun main() {
> val key = Key(1)
> val map = hashMapOf(key to "значение")
> key.id = 2
> println(map[key]) // null — ищем в новом бакете
> println(map.keys.first()) // Key(id=2) — запись на месте, но недоступна
> println(map.size) // 1
> }
> ```
> Запись становится невидимой и неудаляемой. Ключи обязаны быть неизменяемыми.
>
> **Массив внутри класса ломает `equals` по содержимому:**
> ```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
> }
> ```
> Без `contentEquals`/`contentHashCode` сравнение идёт по ссылке. Подробнее: [Массивы](../Работа%20с%20данными/Массивы.md)
>
> **`===` для чисел зависит от значения:**
> ```kotlin
> fun main() {
> val a: Int? = 127
> val b: Int? = 127
> println(a === b) // true — из кеша Integer
> val c: Int? = 128
> val d: Int? = 128
> println(c === d) // false — за пределами кеша
> }
> ```
> Это не особенность Kotlin, а кеш `Integer.valueOf` в JVM. Вывод: `===` на числах не использовать.
>
> **`equals` с `is` вместо проверки класса даёт асимметрию при наследовании** - и вместе с ней непредсказуемое поведение коллекций.
>
> **`hashCode`, зависящий от изменяемого поля, нарушает согласованность.** Объект должен давать один и тот же хеш всё время, пока лежит в хеш-коллекции.
>
> **`toString` печатает всё, включая секреты:**
> ```kotlin
> data class Token(val value: String) {
> override fun toString() = "Token(***)"
> }
>
> fun main() { println(Token("secret-abc")) } // Token(***)
> ```
> `data class` с паролем или токеном попадёт в лог целиком, если не переопределить `toString`.
>
> **`hashCode` не обязан быть стабильным между запусками JVM.** `Object.hashCode` зависит от адреса, `String.hashCode` стабилен по спецификации, но для своего класса гарантий нет - сохранять хеш в БД нельзя.
Дополнительно: [Data class](Data%20class.md)
Далее: [ООП](../../ООП/ООП.md)