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

14 KiB
Raw Blame History

title, description, tags
title description tags
equals, hashCode и toString Контракт равенства, == против ===, правила hashCode и как не сломать HashMap
kotlin
oop

Три метода объявлены в Any и есть у каждого объекта. Их реализация по умолчанию - по ссылке и по адресу в памяти.

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 по хешу
}

== и ===

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 не будет.

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
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? - иначе это не переопределение, а перегрузка:

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

Единственное жёсткое правило: равные объекты обязаны иметь равный хеш.

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.

Обратное неверно: разные объекты могут иметь одинаковый хеш (коллизия) - это нормально и лишь замедляет поиск.

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
}

Стандартный способ считать хеш по нескольким полям:

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, но он создаёт массив:

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 делает это за вас

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

toString

Единственный из трёх, у которого нет контракта - но есть ожидания. Он попадает в логи и в сообщения об ошибках:

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 превращает лог в набор адресов.

Равенство и наследование

Строгая симметричность при наследовании достигается проверкой точного класса:

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.

Равенство примитивов и упаковка

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:

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, HashMap в Java

[!warning] Подводные камни Переопределить equals без hashCode - значит получить объект, который теряется в HashSet и HashMap. IDE предупреждает, компилятор - нет.

Изменяемый объект как ключ HashMap:

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 по содержимому:

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 сравнение идёт по ссылке. Подробнее: Массивы

=== для чисел зависит от значения:

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 печатает всё, включая секреты:

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 Далее: ООП