--- 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) { 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) { 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)