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 создаёт aliasing: теперь 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 | «Какой “ID” у этого экземпляра?» | да, это значение и оно Hashable |
И, чтобы окончательно закрепить, можно нарисовать маленькую схему:
flowchart LR
A[a: Cache] --> O[(Cache object #1)]
C[c: Cache] --> O
B[b: Cache] --> P[(Cache object #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("visit:", node.name) // visit: A, visit: 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] ?? "unknown") // 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 called, ctx =", ObjectIdentifier(context)) // help called, 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 called, ctx =", ObjectIdentifier(ctx))
}
runList()
runList()
В логах вы увидите разные идентификаторы. И это очень наглядно объясняет баг: «счётчик команд не растёт», потому что вы каждый раз создаёте новый объект.
В реальном LibraryCLI такие проверки часто спасают время, когда вы отлаживаете «куда делось состояние» — особенно на переходе от value-логики к reference-логике, где состояние может жить «в одном месте», но ссылок на него много.
Ограничения: ObjectIdentifier — не «вечный ID данных»
Эта часть кажется скучной, но именно она спасает от очень дорогих ошибок (дорогих — в смысле вашего времени и нервов). ObjectIdentifier — это идентификатор экземпляра, а не сущности «по смыслу». Он хорош, чтобы отличать два объекта “здесь и сейчас”, пока они существуют в памяти в рамках запуска программы.
Если вы попытаетесь использовать ObjectIdentifier как постоянный идентификатор, который можно сохранить в файл, передать по сети, восстановить завтра — вы получите странное поведение. И это ожидаемо: ObjectIdentifier не обещает быть стабильным между запусками. Он даже не обязан быть уникальным «навсегда»; он нужен как инструмент идентичности в рамках жизни объекта.
То есть правильный mental model такой: 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-semantics: let фиксирует ссылку (нельзя переназначить obj на другой экземпляр), но если внутри объекта есть var-свойства, они меняются. Из-за этого иногда кажется, что «у меня константа внезапно меняется». На самом деле меняется состояние объекта, а ссылка остаётся той же — и это видно по одинаковому ObjectIdentifier(obj).
Ошибка №5: строить логику приложения на идентичности там, где нужна логика на данных.
Идентичность полезна для отладки, кэшей, обходов графов и контроля «один ли это экземпляр». Но если задача звучит как «это та же книга?» или «это тот же пользователь?», почти всегда нужно сравнивать их доменный идентификатор или поля данных, а не ObjectIdentifier. Иначе вы получите ситуацию, где одна и та же сущность, загруженная дважды (двумя экземплярами), внезапно считается «разной».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ