1. Два види рівності: «за змістом» і «це той самий обʼєкт»
Коли ви пишете програму, то постійно щось порівнюєте. Іноді — «за змістом»: наприклад, два рядки "cat" і "cat" — це те саме слово. А інколи вас цікавить інше: «це буквально одна й та сама штука чи дві різні, просто схожі?».
Це як із ключами: два ключі можуть бути однаково нарізані (==), але один — оригінал, а інший — копія. У деяких ситуаціях це принципово (===).
У Kotlin ці два завдання порівняння розділено на рівні мови, тож ви можете явно сказати, що саме хочете перевірити. Особливо помітно це в колекціях. Колекції — «контейнерні» структури: вони можуть бути однаковими за вмістом, але різними контейнерами. Або навпаки: один контейнер може мати в коді «два імені».
== — структурна рівність
Оператор == у Kotlin означає структурну рівність. У звичайному розумінні це «рівні за значенням / вмістом», тобто «збігається те, що важливо за змістом».
Технічно Kotlin зводить a == b до виклику equals, але робить це акуратно: порівняння з null — безпечне. Тому окремі перевірки на кшталт «а раптом a — null» найчастіше не потрібні.
Важливо одразу запамʼятати: == — ваш «робочий» оператор порівняння. Ви використовуватимете його майже завжди: коли перевіряєте результат обчислення, шукаєте елемент у колекції, порівнюєте рядки, числа тощо.
Мінітаблиця: що зазвичай означає == на різних типах
| Тип | Що означає == (інтуїтивно) |
|---|---|
|
чи рівні значення (число, символ, факт) |
|
чи однаковий текст посимвольно |
|
чи однаковий розмір і чи всі елементи рівні в тому самому порядку (порядок важливий) |
|
чи рівні всі компоненти |
У списках це особливо важливо: два списки з тими самими елементами, але в іншому порядку, зазвичай вважаються нерівними (бо порядок — частина змісту списку).
Приклад: == на рядках і числах
fun main() {
val a = "hi"
val b = "hi"
val x = 10
val y = 10
println(a == b) // true
println(x == y) // true
}
Це саме та поведінка, на яку ви й очікуєте.
Приклад: == безпечний із null
У Kotlin є приємна особливість: == «розуміє» null і не падає.
fun main() {
val x: String? = null
val y: String? = "hi"
println(x == null) // true
println(y == null) // false
println(x == y) // false
}
Зверніть увагу на останній println(x == y): тут немає жодного ризику «завалити програму» зверненням до null. == безпечний, і це робить код простішим та охайнішим.
2. === і спільні посилання (aliasing)
=== — посилальна рівність
Оператор === відповідає на інше питання: «ці дві змінні вказують на один і той самий обʼєкт?». У розмовній формі: «це один контейнер чи два?». Це не «краще» і не «гірше» за == — це просто інший зміст. Якщо == — про значення, то === — про ідентичність.
У повсякденних задачах === трапляється рідко. Зате для налагодження він корисний: коли треба перевірити, «чи не скопіювали ми посилання випадково», або діагностувати дивну поведінку колекцій.
Приклад: дві однакові колекції — це не один і той самий обʼєкт
fun main() {
val a = mutableListOf(1, 2, 3)
val b = mutableListOf(1, 2, 3)
println(a == b) // true
println(a === b) // false
}
Вміст однаковий, тому a == b дає true. Але це два різні списки, тому a === b дає false.
Приклад: «друге імʼя» для того самого списку
fun main() {
val a = mutableListOf("x")
val b = a
b.add("y")
println(a) // [x, y]
println(b) // [x, y]
println(a === b) // true
}
Ось головне джерело «магії» в очах новачків: ви додали елемент через b, а змінився a. Тому що a і b — це два імені одного й того самого контейнера.
Що таке aliasing і чому він плутає
Ситуація, коли дві змінні посилаються на один обʼєкт, називається aliasing (можна подумки перекласти як «псевдонім» або «друге імʼя»). У новачків aliasing іноді викликає відчуття, що мова «живе своїм життям». Насправді все логічно: ви просто тримаєте в двох змінних не два контейнери, а один.
Зручно уявляти це як стрілочки: змінна — це «ярлик», який вказує на реальний обʼєкт. Два ярлики можуть вказувати на один обʼєкт — і тоді зміни через один ярлик видно через інший.
Намалюємо просту схему:
flowchart LR
A[a: MutableList] --> L[(Список)]
B[b: MutableList] --> L
Саме тому в Kotlin так важливо розрізняти «значення» і «посилання», навіть якщо ми не заглиблюємося в устрій памʼяті JVM. Для повсякденної логіки достатньо памʼятати ось що: змінювана колекція може мати кілька «імен», і тоді зміни будуть спільними.
Мініприклад на темі курсу: простий CLI-застосунок
Уявімо, що ми робимо найпростіший облік витрат (без класів; поки що лише Pair) і зберігаємо операції як пари "категорія" to сума.
fun main() {
val expenses = mutableListOf("food" to 250, "taxi" to 120)
val visibleExpenses = expenses // "зробили вигляд, що це копія"
visibleExpenses.add("books" to 90)
println(expenses) // [(food, 250), (taxi, 120), (books, 90)]
println(visibleExpenses) // [(food, 250), (taxi, 120), (books, 90)]
}
Якщо ви розраховували, що visibleExpenses — це «окремий список для відображення», то у вас щойно зʼявився баг: ви змінили «відображення», а разом із ним змінилися й «дані».
3. val не робить обʼєкт незмінним
Дуже поширена плутанина звучить так: «Я ж зробив val, отже воно не змінюється». Але val означає лише одне: змінна не може почати вказувати на інший обʼєкт. Водночас якщо сам обʼєкт змінюваний (наприклад, MutableList), його вміст можна змінювати.
Приклад: val + MutableList — і все одно змінюється
fun main() {
val numbers = mutableListOf("one", "two")
numbers.add("three")
println(numbers) // [one, two, three]
}
І це нормально. val тут захищає вас від випадкового перепризначення змінної (тобто ви не зможете зробити numbers = mutableListOf(...)), але не забороняє змінювати вміст списку.
Чому це добре
Якби val забороняв змінювати «нутрощі» змінюваного обʼєкта, довелося б вводити окрему мовну конструкцію на кшталт «жорсткої незмінюваності». Kotlin робить простіше: val — про змінну, а mutability — про сам обʼєкт або тип.
Тому корисна звичка в проєкті така: за замовчуванням ставити val, а мутабельність вмикати лише там, де вона справді потрібна. Це зменшує кількість неочікуваних «перекидань посилань» у коді.
4. Пастка: === на nullable-числах
Іноді новачок, дізнавшись про ===, починає використовувати його «для надійності». І ось тут стається маленька трагікомедія. Для типів на кшталт String, List, Map === інколи справді може бути корисним, але на nullable-числах (Int?, Double?) ви майже гарантовано отримаєте поведінку, яка виглядає як баг.
Ми не будемо заглиблюватися в те, «як це зберігається в памʼяті», але практичне правило просте: якщо у вас Int?, то === перевіряє не значення, а те, «чи це точно той самий обʼєкт-обгортка». І відповідь часто буде false, навіть коли числа однакові.
fun main() {
val a: Int? = 1000
val b: Int? = 1000
println(a == b) // true
println(a === b) // часто false (і це нормально)
}
Висновок для життя: числа порівнюємо через ==. === не додає «надійності» — він змінює зміст перевірки. У результаті ви самі собі копаєте яму, а потім героїчно з неї вибираєтеся.
5. Практичні правила та безпечні прийоми
Коли ви читаєте або пишете Kotlin-код, корисно тримати в голові просту евристику. У звичайній бізнес-логіці вас цікавлять значення: «чи однакова команда?», «чи збігається категорія?», «чи рівна сума?». Усе це — про ==.
Оператор === — інструмент, який частіше потрібен не для логіки застосунку, а для розуміння: «а чи не один це контейнер?» або «чому зміни “пролазять” сюди?».
Якщо зовсім коротко: == — «рівно за змістом», === — «це той самий обʼєкт».
Як не зловити баг «спільного посилання» у практичному застосунку
У нашому простому CLI-обліку витрат може виникнути така ситуація: ми хочемо «підготувати список для виводу», але випадково беремо той самий список і починаємо його змінювати (наприклад, тимчасово фільтрувати, тимчасово видаляти або тимчасово додавати тестові елементи).
Щоб не влаштовувати собі самосаботаж, зручно запровадити правило: якщо ви робите тимчасову обробку, працюйте з копією.
Функція «зробити знімок» списку витрат
fun snapshotExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
// toList() створює новий список, який можна безпечно показувати/обробляти окремо
return expenses.toList()
}
І використовуємо:
fun main() {
val expenses = mutableListOf("food" to 250, "taxi" to 120)
val snapshot = snapshotExpenses(expenses)
expenses.add("books" to 90)
println(snapshot) // [(food, 250), (taxi, 120)]
println(expenses) // [(food, 250), (taxi, 120), (books, 90)]
}
Тут важливо, що snapshot — окремий список. Навіть якщо початковий список змінюється, «знімок» лишається попереднім. Це корисно, коли ви хочете стабільно щось показати користувачеві, а дані тим часом продовжують «жити своїм життям».
Налагоджувальний прийом: перевірка ===, щоб упіймати aliasing
Іноді ви підозрюєте, що «десь у мене одна колекція стала двома іменами». У такому разі === — ваш ліхтарик.
fun main() {
val expenses = mutableListOf("food" to 250)
val sameRef = expenses
val copy = expenses.toMutableList()
println(expenses === sameRef) // true
println(expenses === copy) // false
}
Ця перевірка майже ніколи не має бути частиною бізнес-логіки, але як діагностика — чудова.
6. Типові помилки
Помилка № 1: використовувати === замість == «про всяк випадок».
Так робити хочеться з благих намірів: «ну === же суворіший, отже надійніший». Але === не «суворіший» — він про інше. Ви починаєте порівнювати не значення, а ідентичність обʼєктів, і раптом «однакові» рядки / числа / результати обчислень стають «нерівними». Правильна звичка така: за замовчуванням ==, а === — лише тоді, коли ви справді хочете перевірити «той самий обʼєкт».
Помилка № 2: дивуватися, що зміну списку через одну змінну видно через іншу.
Це не містика і не «Kotlin зламався». Це aliasing: ви присвоїли b = a, і тепер b — друге імʼя тієї самої колекції. «Ліки» тут не в тому, щоб «заборонити собі присвоювання», а в тому, щоб усвідомлено розрізняти «я хочу посилання на той самий контейнер» і «я хочу копію контейнера».
Помилка № 3: думати, що val робить колекцію незмінною.
val забороняє перепризначати змінну, але не забороняє змінювати вміст змінюваної колекції. Це нормальний і корисний дизайн: ви захищаєте посилання, але не заважаєте працювати зі структурою даних.
Помилка № 4: перевіряти null через складні конструкції замість простого x == null.
Іноді після знайомства з null-safety хочеться писати перевірки «у три поверхи»: if (...) { ... } else { ... }, навіть коли вам просто потрібно зрозуміти, null там чи ні. == у Kotlin безпечний для null, тому x == null — читабельно й нормально.
Помилка № 5: тягнути === у логіку застосунку (умови команд, фільтри, перевірки «вже є такий елемент»).
Навіть якщо код «працює на ваших тестах», він часто буде логічно неправильним: ви порівнюєте не зміст, а ідентичність контейнера. Щойно дані почнуть приходити з іншого місця (новий список, новий обʼєкт, інше збирання рядка), поведінка стане дивною. Змістові перевірки мають бути на ==.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ