JavaRush /Курси /Swift SELF /Ідентичність обʼєктів: Obje...

Ідентичність обʼєктів: ObjectIdentifier

Swift SELF
Рівень 35 , Лекція 4
Відкрита

1. Навіщо потрібна «ідентичність»

Якщо ви тільки звикаєте до програмування, легко подумати так: «Якщо два обʼєкти виглядають однаково, значить, це одне й те саме». У реальній програмі це працює не завжди. Це трохи схоже на фразу: «Дві однакові кави в однакових стаканчиках — це одна й та сама кава». Бариста, звісно, оцінить вашу філософію, але бухгалтерія — ні.

У Swift ми часто розвʼязуємо дві різні задачі: порівняти дані (наприклад, два імена, дві суми, два масиви) і порівняти екземпляри обʼєктів (наприклад, це той самий обʼєкт кешу чи вже новий). Особливо це важливо, коли зʼявляються class, тому що клас — це посилальний тип: можна мати дві змінні, але один обʼєкт «під капотом».

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

2. Перевірка «це той самий обʼєкт»: === і !==

Перед тим як братися за ObjectIdentifier, корисно відчути базову механіку ідентичності: у Swift є оператори === і !==. Вони відповідають не на запитання «чи рівні дані», а на запитання «чи посилаються дві змінні на один і той самий екземпляр класу». Це перевірка рівня «це один і той самий обʼєкт у памʼяті», а не «чи схожий він за вмістом».

Важливо не намагатися застосовувати ці оператори до struct або enum: для них сенс інший, і компілятор вас зупинить. === існує саме для посилальних типів (class), тому що там справді є поняття «один конкретний екземпляр», на який можуть дивитися кілька змінних.

Давайте зробимо крихітний приклад. Він спеціально максимально простий, щоб ви побачили ефект, а не втонули в деталях:

import Foundation

final class Settings {
    var isVerbose: Bool
    init(isVerbose: Bool) { self.isVerbose = isVerbose }
}

let a = Settings(isVerbose: true)
let b = Settings(isVerbose: true)
let c = a

print(a === b) // false  (дані збіглися, але обʼєкти різні)
print(a === c) // true   (c — це просто ще одне посилання на a)

Тут a і b «за змістом» можуть виглядати однаково (обидва isVerbose: true), але це два різні екземпляри. Зате c = a створює аліасування: тепер c і a — це два посилання на один обʼєкт, і це видно через a === c.

Ще один важливий момент: === зручно використовувати для if-перевірки «на місці», але щойно ви захочете, наприклад, зберігати «вже відвідані обʼєкти» в Set або використовувати їх як ключі в Dictionary, ви впираєтеся в те, що Set і Dictionary вимагають Hashable. Саме тут на сцену виходить герой цієї лекції — ObjectIdentifier.

3. ObjectIdentifier: ідентифікатор екземпляра для Set і Dictionary

ObjectIdentifier — це спеціальний тип у Swift, який дає змогу перетворити ідентичність обʼєкта (екземпляра класу) на значення, придатне для зберігання в колекціях. Якщо === — це «перевірити просто зараз», то ObjectIdentifier — це «зробити мітку й носити її із собою».

Простою мовою, ObjectIdentifier — це наче «серійний номер екземпляра» (але лише в межах поточного запуску програми). Ви можете створити його так: ObjectIdentifier(obj), де obj — обʼєкт класу. Отриманий ідентифікатор можна порівнювати, класти в множини й використовувати як ключ у словнику.

Мініприклад, щоб “помацати” руками:

import Foundation

final class Cache {
    var hits: Int = 0
}

let cache1 = Cache()
let cache2 = Cache()
let cache3 = cache1

let id1 = ObjectIdentifier(cache1)
let id2 = ObjectIdentifier(cache2)
let id3 = ObjectIdentifier(cache3)

print(id1 == id2) // false
print(id1 == id3) // true

Тут cache3 — це аліас cache1, тому ідентифікатори збігаються. А cache2 — інший екземпляр, тому й ObjectIdentifier інший.

Карта понять: == vs === vs ObjectIdentifier

Щоб не плутатися, зручно тримати таку таблицю:

Інструмент До чого застосовується Питання, на яке відповідає Чи можна зберігати в Set/Dictionary
==
до типів із логікою рівності «Чи рівні дані?» залежить від типу (для ключа потрібен Hashable)
===
тільки до class «Це один і той самий екземпляр?» ні, це оператор, а не значення
ObjectIdentifier
тільки до class «Який ідентифікатор цього екземпляра?» так, це значення, і воно Hashable

А щоб остаточно закріпити, можна намалювати маленьку схему:

flowchart LR
    A[a: Cache] --> O[(Об’єкт кешу №1)]
    C[c: Cache] --> O
    B[b: Cache] --> P[(Об’єкт кешу №2)]

    AID["ObjectIdentifier(a)"] --- O
    CID["ObjectIdentifier(c)"] --- O
    BID["ObjectIdentifier(b)"] --- P

Дві змінні (a і c) «дивляться» на один обʼєкт — і їхній ObjectIdentifier збігається.

4. Практика: «чи бачив я цей обʼєкт раніше?»

Тепер давайте чесно: «Ну гаразд, є ObjectIdentifier. І що з того?» Сенс стає очевидним, коли вам треба відстежувати саме екземпляри. Це особливо часто трапляється у двох сценаріях: захисті від повторної обробки та зберіганні метаданих «на обʼєкт».

Set<ObjectIdentifier>: позначки «вже зустрічали»

Коли ви працюєте з обʼєктами класів, ви можете будувати структури, де один обʼєкт посилається на інший. Навіть без складних тем на кшталт ARC і витоків памʼяті (ми сьогодні туди не йдемо) виникає проста задача: ви обходите граф обʼєктів або список посилань і хочете не обробляти один і той самий екземпляр двічі.

Припустімо, у нас є вузли, і вони можуть утворювати цикл (A → B → A). Нам потрібно позначати «відвідані» вузли за ідентичністю:

import Foundation

final class Node {
    let name: String
    var next: Node?
    init(name: String) { self.name = name }
}

var visited: Set<ObjectIdentifier> = []

func visit(_ node: Node) {
    let id = ObjectIdentifier(node)
    if visited.contains(id) { return }   // уже були тут
    visited.insert(id)

    print("відвідування:", node.name)     // відвідування: A, відвідування: B, ...
    if let next = node.next { visit(next) }
}

Так, це рекурсія (ми її вже вивчали раніше), і так, зараз ми не пишемо «ідеальний» обхід графа. Але ідея проста: ми запамʼятовуємо конкретні екземпляри, а не «вузли з імʼям A». Якщо у вас є два різні вузли з імʼям "A", вони матимуть різні ObjectIdentifier, і це правильно: це два різні обʼєкти.

Dictionary<ObjectIdentifier, ...>: метадані «на екземпляр»

Друга поширена історія — ви хочете зберігати додаткові відомості про обʼєкт, не змінюючи сам клас. Наприклад, вести діагностичні імена обʼєктів для логів, лічильники, «час останнього звернення» — що завгодно.

Словник за ObjectIdentifier виглядає так:

import Foundation

final class Cache {
    var hits: Int = 0
}

let mainCache = Cache()

var debugNameByID: [ObjectIdentifier: String] = [:]
debugNameByID[ObjectIdentifier(mainCache)] = "mainCache"

let id = ObjectIdentifier(mainCache)
print(debugNameByID[id] ?? "невідомо") // mainCache

Зверніть увагу на важливу дрібницю: ключ — це не сам обʼєкт (у класів немає автоматичного Hashable «за ідентичністю»), а саме ObjectIdentifier. Тобто ми ніби кажемо: «Не намагайся захешувати обʼєкт цілком — ось тобі компактний ідентифікатор екземпляра».

5. Приклад із CLI: діагностика «у нас один контекст чи ні?»

Зараз зробимо приклад ближче до відчуття справжнього проєкту — нашого умовного LibraryCLI, який ми розвиваємо в курсі. Уявімо, що у нас є обʼєкт runtime-контексту, який має бути один на запуск: він зберігає, скажімо, прапорець докладного виводу та лічильник виконаних команд.

Ми поки що не будуємо складну архітектуру й не обговорюємо, як правильно проєктувати шари, — тут мета інша: навчитися бачити, це той самий обʼєкт чи ні.

import Foundation

final class RuntimeContext {
    var isVerbose: Bool
    var commandCount: Int

    init(isVerbose: Bool) {
        self.isVerbose = isVerbose
        self.commandCount = 0
    }
}

func runHelp(context: RuntimeContext) {
    context.commandCount += 1
    print("help викликано, ctx =", ObjectIdentifier(context)) // help викликано, ctx = ...
}

let ctx = RuntimeContext(isVerbose: true)
runHelp(context: ctx)
runHelp(context: ctx)

Що тут корисного? Ви можете вивести ObjectIdentifier(context) у різних місцях програми й переконатися: «Ага, це один і той самий контекст, я його не загубив і не створив заново».

Тепер додамо типову помилку новачка: «Випадково створив другий контекст, бо було лінь передавати старий»:

import Foundation

final class RuntimeContext {
    var commandCount: Int = 0
}

func runList() {
    let ctx = RuntimeContext()              // ❌ новий контекст на кожен виклик
    ctx.commandCount += 1
    print("list викликано, ctx =", ObjectIdentifier(ctx))
}

runList()
runList()

У логах ви побачите різні ідентифікатори. Це дуже наочно пояснює баг: «лічильник команд не зростає», тому що ви щоразу створюєте новий обʼєкт.

У реальному LibraryCLI такі перевірки часто економлять час, коли ви налагоджуєте, куди подівся стан, — особливо на переході від value-логіки до reference-логіки, де стан може жити в одному місці, але посилань на нього багато.

Обмеження: ObjectIdentifier — не «вічний ID даних»

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

Якщо ви спробуєте використовувати ObjectIdentifier як постійний ідентифікатор, який можна зберегти у файл, передати мережею, відновити завтра, ви отримаєте дивну поведінку. І це очікувано: ObjectIdentifier не обіцяє бути стабільним між запусками. Він навіть не зобовʼязаний бути унікальним «назавжди»; він потрібен як інструмент ідентичності в межах життя обʼєкта.

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

Якщо вам потрібен «ID сутності» (наприклад, ID книги в бібліотеці), це вже історія про окреме поле id у ваших даних: рядок, число, UUID тощо. Але такі «постійні ID» — це інший тип ідентичності: ідентичність даних/сутності, а не екземпляра в памʼяті.

6. Типові помилки

Помилка №1: плутати «однакові дані» та «один екземпляр».
Дуже легко побачити два обʼєкти з однаковими полями й вирішити, що це «одне й те саме». Для struct це часто близько до правди — ми порівнюємо значення, а для class — ні: ви можете мати два різні екземпляри, що збігаються за вмістом. Якщо ви налагоджуєте, чому зміни не видно, перевірте ідентичність через === або порівняйте ObjectIdentifier.

Помилка №2: намагатися застосовувати ObjectIdentifier до value-типів.
ObjectIdentifier працює з обʼєктами (class), тому що там є поняття ідентичності екземпляра. Для struct і enum це не має сенсу: вони копіюються як значення, і «той самий екземпляр» як ідея зазвичай не потрібен. Якщо ви відчуваєте потребу в ObjectIdentifier для struct, часто це сигнал, що ви подумки змішали value- та reference-модель.

Помилка №3: використовувати ObjectIdentifier як постійний ID і зберігати його кудись «на майбутнє».
Це найнеприємніший клас помилок: здається, що «ID же унікальний», отже можна зберегти. Але ObjectIdentifier хороший лише доти, доки обʼєкт існує в поточному запуску. Для зберігання й обміну даними потрібні окремі ідентифікатори в моделі (наприклад, поле id).

Помилка №4: думати, що let obj = SomeClass() робить обʼєкт незмінним.
Це стара пастка reference-семантики: let фіксує посилання (не можна перепризначити obj на інший екземпляр), але якщо всередині обʼєкта є var-властивості, вони змінюються. Через це іноді здається, що «в мене константа раптом змінюється». Насправді змінюється стан обʼєкта, а посилання залишається тим самим — і це видно за однаковим ObjectIdentifier(obj).

Помилка №5: будувати логіку застосунку на ідентичності там, де потрібна логіка на даних.
Ідентичність корисна для налагодження, кешів, обходів графів і контролю «це один екземпляр чи ні». Але якщо задача звучить як «це та сама книга?» або «це той самий користувач?», майже завжди потрібно порівнювати їхній доменний ідентифікатор або поля даних, а не ObjectIdentifier. Інакше ви отримаєте ситуацію, де та сама сутність, завантажена двічі (двома екземплярами), раптом вважається «різною».

1
Опитування
Value Semantics, рівень 35, лекція 4
Недоступний
Value Semantics
Структури, класи, inout, Copy-on-Write
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ