Введение: два метода, которые ломают коллекции

«Почему в HashMap два одинаковых объекта лежат в разных ячейках?» — вопрос, после которого собеседование обычно разворачивается в другую сторону. Потому что без правильного hashCode работают не только коллекции — ломаются тесты, базы-кэши, DTO-сравнения. И почти всегда причина одна: equals переопределили, а hashCode забыли.

Тема equals/hashCode — классика для собеседований любого уровня: от джуна, который путает == и ===, до мидла, который не может объяснить контракт. Сегодня разберём всё по порядку: виды равенства в Kotlin, контракт из официальной документации, как это ломается на практике и когда писать руками вообще не нужно. Все факты — по документации Kotlin.

Два вида равенства: == и ===

В Kotlin два оператора сравнения, и их путаница — первый провал кандидатов. В документации по равенству всё разложено чётко:

  • == — структурное равенство: вызывает функцию equals(). Сравнивает содержимое.
  • === — референсное равенство: проверяет, указывают ли две ссылки на один объект в памяти.

Причём a == b — это не просто вызов equals: выражение транслируется в a?.equals(b) ?: (b === null). То есть Kotlin сам обрабатывает null: если a не null — вызывается equals, если null — проверяется, что b тоже null. Поэтому писать a == null бессмысленно, а a?.equals(b) вручную — тем более.

Equality.kt
fun main() {
    val a = "hello"
    val b = "hello"
    val c = String(charArrayOf('h', 'e', 'l', 'l', 'o'))

    println(a == b)  // true  — структурное равенство
    println(a == c)  // true  — значения совпадают
    println(a === b) // true  — литералы из пула строк, одна ссылка
    println(a === c) // false — разные объекты
}

Ключевой момент: у всех классов equals наследуется от Any, и по умолчанию он реализует референсное равенство. Обычный класс без data-модификатора сравнивает объекты по ссылкам, пока вы не переопределите equals сами. Исключения — data class и value classes: они переопределяют equals автоматически.

База

== — это equals (структура), === — это ссылки (один ли объект). Для классов без data-модификатора оба оператора по умолчанию работают одинаково — по ссылкам, пока equals не переопределён.

Контракт equals и hashCode

Теперь главное. Когда вы переопределяете equals, вы обязаны переопределить и hashCode — это прямо написано в документации: переопределяя equals(), следует также переопределить hashCode(), чтобы сохранить согласованность между равенством и хешированием.

Контракт hashCode из API-документации Kotlin состоит из двух пунктов:

  1. При вызове на одном и том же объекте hashCode должен возвращать одно и то же значение, пока не изменилась информация, используемая в equals.
  2. Если два объекта равны по equals, их hashCode обязаны быть одинаковыми.

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

Point.kt
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
        return x == other.x && y == other.y
    }

    override fun hashCode(): Int {
        var result = x
        result = 31 * result + y
        return result
    }
}

Этот пример — с паттерном из документации Kotlin: сначала проверка на ту же ссылку, потом other !is Point (это и тип-проверка, и проверка на null), потом сравнение полей.

Почему контракт так важен — на примере HashMap. Коллекция раскладывает ключи по «корзинам» на основе hashCode, а при совпадении корзины сверяет объекты через equals. Если два равных объекта дают разные хеши — они попадут в разные корзины, и contains/get их не найдут:

HashMap.kt
fun main() {
    val map = HashMap<Point, String>()
    val p1 = Point(1, 2)
    map[p1] = "точка"

    val samePoint = Point(1, 2)
    println(map.containsKey(samePoint))
    // true, если equals и hashCode согласованы,
    // false, если hashCode забыли переопределить
}

Есть и нюанс сигнатуры, о котором спрашивают: метод equals(other: Foo) с другим типом параметра — это просто перегрузка, на операторы == и != она никак не влияет. Работает только override fun equals(other: Any?): Boolean.

Ошибка-классика: переопределили equals, забыли hashCode — и HashMap, HashSet, HashMap-кэши, многие тестовые ассерты начинают вести себя странно. Контракт нарушен, а искать причину потом можно часами.

Когда писать руками, а когда — data class

Хорошая новость: в Kotlin руками это пишут редко. data class генерирует equals, hashCode и toString автоматически — на основе всех свойств из первичного конструктора. Это полностью соответствует контракту: равны объекты с одинаковыми значениями всех свойств, хеш считается по тем же свойствам.

User.kt
data class User(val id: Int, val name: String)

fun main() {
    val u1 = User(1, "Аня")
    val u2 = User(1, "Аня")

    println(u1 == u2)          // true — сгенерированный equals
    println(u1.hashCode() == u2.hashCode()) // true — согласовано
}

Есть нюанс, который любят спрашивать: если в классе-родителе equals объявлен как final, сгенерированный equals data class не заменит его — поведение останется родительским. И ещё: в equals data class не входят свойства, объявленные в теле класса, — только из первичного конструктора.

Когда data class не подходит? Когда равенство по всем свойствам избыточно — например, у тяжёлой сущности нужно сравнивать только id. Тогда пишем руками: equals по id, hashCode по id. Это стандартный сценарий для entity-классов, и на собеседовании такой ответ выглядит сильно.

  • data class — равенство по всем свойствам первичного конструктора: DTO, модели, доменные объекты.
  • Ручной equals/hashCode — равенство по части полей (например, только по id), наследование с нестандартной логикой.
  • Не переопределять вообще — если объект сравнивается по ссылкам: View, Activity, менеджеры, синглтоны.

Где кандидаты ошибаются

  • «== сравнивает ссылки, как в Java». Нет: в Kotlin == — структурное равенство через equals.
  • «equals переопределил — hashCode необязательно». Обязательно: контракт требует, чтобы равные объекты имели одинаковый хеш.
  • «HashSet не найдёт объект, если у него плохой equals». Скорее наоборот: не найдёт, если нарушен hashCode. Раскладка по корзинам работает на хеше.
  • «equals(other: Foo) переопределяет равенство». Нет: это перегрузка. Нужна сигнатура equals(other: Any?): Boolean.
  • «data class с var в конструкторе — безопасно». Опасно: если поле, участвующее в equals/hashCode, мутирует после помещения в HashSet, объект «потеряется» в коллекции — хеш изменился, корзина та же не найдётся.

Последний пункт стоит разобрать отдельно, потому что это реальная боль в продакшене. Объект положили в HashSet, потом поменяли свойство через var — и всё, контракт hashCode нарушен: первый пункт требует, чтобы хеш не менялся, пока не изменилась информация, используемая в equals. Искать такой баг можно долго, поэтому в коллекциях с хешированием объекты должны быть неизменяемыми — ещё одна причина держать свойства data class как val.

Два пункта контракта

Первое: hashCode стабилен для одного объекта, пока не изменились данные equals. Второе: равные объекты — одинаковый хеш. Переопределил equals — переопределил hashCode. Нарушил — потерял объекты в коллекциях.

Итоги

equals и hashCode — тема, которая проверяет системное мышление: понимание того, как связаны язык, коллекции и производительность. На собеседовании это одна из самых частых точек, где кандидат с опытом «пересказывает код» и кандидат, понимающий контракт, расходятся.

  • Два равенства: == — через equals, === — по ссылкам. Для обычных классов по умолчанию оба по ссылкам.
  • Контракт: равные объекты обязаны иметь одинаковый hashCode; hashCode должен быть стабилен для неизменного объекта.
  • Практика: data class решает 90% задач; руками пишут, когда равенство только по части полей.

Главное

Формулировка для собеседования: «equals и hashCode переопределяются парой. Контракт: если a.equals(b) — то a.hashCode() == b.hashCode(). Обратное не требуется. hashCode не должен меняться, пока не изменились данные, участвующие в equals. В Kotlin == вызывает equals и сам обрабатывает null».

Если чувствуете, что в Kotlin есть темы, где ответы держатся на зубрёжке, а не на понимании, — не нужно готовиться в одиночку. Напишите в Telegram, разберём вашу ситуацию и составим план, что закрыть в первую очередь.