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 | «Це один і той самий екземпляр?» | ні, це оператор, а не значення |
|
тільки до 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. Інакше ви отримаєте ситуацію, де та сама сутність, завантажена двічі (двома екземплярами), раптом вважається «різною».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ