Введение: два метода, которые ломают коллекции
«Почему в 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) вручную — тем более.
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 состоит из двух пунктов:
- При вызове на одном и том же объекте
hashCodeдолжен возвращать одно и то же значение, пока не изменилась информация, используемая вequals. - Если два объекта равны по
equals, ихhashCodeобязаны быть одинаковыми.
Обратное не требуется: разные объекты могут иметь одинаковый хеш (коллизии — это нормально).
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 их не найдут:
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 автоматически — на основе всех свойств из первичного конструктора. Это полностью соответствует контракту: равны объекты с одинаковыми значениями всех свойств, хеш считается по тем же свойствам.
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, разберём вашу ситуацию и составим план, что закрыть в первую очередь.