Files
obsidian/Kotlin/Синтаксис и типы/Базовые типы ООП/equals, hashCode и toString.md
T

321 lines
14 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: 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)