JavaRush /Курси /Kotlin SELF /== vs === і модель «значення vs посилання»

== vs === і модель «значення vs посилання»

Kotlin SELF
Рівень 23 , Лекція 0
Відкрита

1. Два види рівності: «за змістом» і «це той самий обʼєкт»

Коли ви пишете програму, то постійно щось порівнюєте. Іноді — «за змістом»: наприклад, два рядки "cat" і "cat" — це те саме слово. А інколи вас цікавить інше: «це буквально одна й та сама штука чи дві різні, просто схожі?».

Це як із ключами: два ключі можуть бути однаково нарізані (==), але один — оригінал, а інший — копія. У деяких ситуаціях це принципово (===).

У Kotlin ці два завдання порівняння розділено на рівні мови, тож ви можете явно сказати, що саме хочете перевірити. Особливо помітно це в колекціях. Колекції — «контейнерні» структури: вони можуть бути однаковими за вмістом, але різними контейнерами. Або навпаки: один контейнер може мати в коді «два імені».

== — структурна рівність

Оператор == у Kotlin означає структурну рівність. У звичайному розумінні це «рівні за значенням / вмістом», тобто «збігається те, що важливо за змістом».

Технічно Kotlin зводить a == b до виклику equals, але робить це акуратно: порівняння з null — безпечне. Тому окремі перевірки на кшталт «а раптом anull» найчастіше не потрібні.

Важливо одразу запамʼятати: == — ваш «робочий» оператор порівняння. Ви використовуватимете його майже завжди: коли перевіряєте результат обчислення, шукаєте елемент у колекції, порівнюєте рядки, числа тощо.

Мінітаблиця: що зазвичай означає == на різних типах

Тип Що означає == (інтуїтивно)
Int, Double, Char, Boolean
чи рівні значення (число, символ, факт)
String
чи однаковий текст посимвольно
List
чи однаковий розмір і чи всі елементи рівні в тому самому порядку (порядок важливий)
Pair/Triple
чи рівні всі компоненти

У списках це особливо важливо: два списки з тими самими елементами, але в іншому порядку, зазвичай вважаються нерівними (бо порядок — частина змісту списку).

Приклад: == на рядках і числах

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: тягнути === у логіку застосунку (умови команд, фільтри, перевірки «вже є такий елемент»).
Навіть якщо код «працює на ваших тестах», він часто буде логічно неправильним: ви порівнюєте не зміст, а ідентичність контейнера. Щойно дані почнуть приходити з іншого місця (новий список, новий обʼєкт, інше збирання рядка), поведінка стане дивною. Змістові перевірки мають бути на ==.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ