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