14 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| equals, hashCode и toString | Контракт равенства, == против ===, правила hashCode и как не сломать HashMap |
|
Три метода объявлены в 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 == ba == b(ссылки)a === bObjects.equals(a, b)для nulla == 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 classString.valueOf(obj)для nullobj.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 Далее: ООП