Codable означает Encodable & Decodable: объявите соответствие, и компилятор сгенерирует код, который по именам переводит хранимые свойства в JSON (или другой формат) и обратно. Если имена расходятся, ключи переименовывают через enum CodingKeys или задают стратегию декодирования ключей.
**CodingKeys**: вложенный String-enum, raw value каждого кейса задаёт ключ в JSON. Свойство, которого нет в enum, пропускается, поэтому ему нужно значение по умолчанию.
**keyDecodingStrategy = .convertFromSnakeCase**: преобразует все входящие ключи, и author_name совпадёт с authorName без всяких CodingKeys. Не сочетайте её с snake_case в raw value: ключи сначала преобразуются, и "author_name" уже никогда не совпадёт.
Когда синтеза недостаточно: для неопционального свойства отсутствующий ключ или null приведёт к keyNotFound или valueNotFound (сделайте свойство Optional или напишите init(from:)); свой init(from:) справится с полем, которое приходит то как Int, то как String.
iOSMiddle
Swift: дженерики и продвинутое
Generics, opaque types, property wrappers, result builders, key paths
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
Generics позволяют написать одну функцию или тип для любого типа, удовлетворяющего ограничениям, при этом компилятор проверяет каждое использование: никаких Any и приведений типов. where добавляет ограничения на associated types и связи между параметрами типа.
Параметры типа: func clamp<T: Comparable> или struct Stack<Element>. Конкретный тип фиксируется в каждом месте использования и обычно выводится из аргументов.
Ограничения в угловых скобках: соответствия и суперклассы самого параметра: <T: Comparable>, <T: Codable & Sendable>, а для primary associated types <C: Collection<Int>>. Для связей между параметрами всё равно нужен where.
**where для associated types**: where C.Element: Equatable ограничивает тип, который принадлежит параметру.
Требования равенства типов: where A.Element == B.Element связывает два параметра.
**where у расширения**: extension Stack where Element: Equatable добавляет методы только для некоторых типов элементов; extension Array: P where Element: P объявляет условное соответствие (conditional conformance).
В отличие от Any или параметра с типом протокола, тип сохраняется: clamp(15, to: 0...10) возвращает Int, а не то, что нужно приводить обратно. А в Release-сборке оптимизатор может специализировать код под каждый конкретный тип.
Под associated type понимают тип-заполнитель внутри протокола, который каждый соответствующий тип подставляет сам, как Element в Collection. Он делает протокол обобщённым, и имя протокола само по себе уже не описывает один законченный тип.
Обычно выводится: реализующий тип редко пишет typealias Item = User: компилятор берёт тип из сигнатур методов.
Ограничения: associatedtype Item: Identifiable, плюс where в месте использования: where S.Item == User.
Ограничение протокола: до Swift 5.7 такой протокол можно было использовать только как generic-ограничение. Сейчас any Store разрешён, но через него нельзя вызвать методы, принимающие Item как параметр, а возвращаемые Item стираются до any Identifiable: компилятор не знает, какой Item лежит в коробке.
Primary associated types: protocol Store<Item> позволяет писать some Store<User> и any Store<User>. Когда Item зафиксирован в месте использования, все методы снова доступны, и большинство самописных стирающих обёрток (type erasers) становятся не нужны.
Выбор: generic <S: Store>, когда за раз используется одно конкретное хранилище, и any Store<User>, когда разные реализации должны лежать в одном массиве или свойстве.
Третий print. Box является Equatable только where Value: Equatable, а Token не соответствует Equatable, поэтому у Box<Token> нет ==: «operator function '==' requires that 'Token' conform to 'Equatable'». Первые две строки компилируются, потому что Box<Int> и Box<String> выполняют условие.
Условное соответствие (conditional conformance): extension Type: Protocol where … делает обобщённый тип соответствующим протоколу только для некоторых аргументов типа.
Почему строка с массивом работает: Array сам является Equatable ровно тогда, когда его элементы Equatable, поэтому [Box<String>] сравнивается. Стандартная библиотека делает то же для Optional, Dictionary и Set с Equatable, Hashable, Codable и Sendable, поэтому [[Int]] сравнивается, а [User] кодируется.
Синтез продолжает работать: достаточно пустого extension Box: Equatable where Value: Equatable {}; с Hashable и Codable так же.
Исправление: объявить struct Token: Equatable, и все три строки скомпилируются.
Ограничение: тип может соответствовать протоколу только одним способом, поэтому второе соответствие с другим условием добавить нельзя.
some Shape обозначает непрозрачный тип (opaque type): один конкретный тип, который компилятор знает, но скрывает от вызывающего. any Shape обозначает экзистенциальный тип (existential): коробка, в которой может лежать значение любого соответствующего типа.
**some в возвращаемом типе**: тип выбирает функция, и он должен быть одним и тем же на всех путях. Вызывающий видит только протокол, но компилятор помнит настоящий тип, и так some View прячет огромные вложенные generic-типы SwiftUI.
**some в параметре**: сокращение для generic-параметра: func f(_ s: some Shape) означает func f<S: Shape>(_ s: S). Тип выбирает вызывающий.
**any**: для хранения действительно разных типов: [any Shape]. Цена складывается из коробки и динамической диспетчеризации; подробности разобраны в вопросе о том, как выполняется generic-код.
Открытие коробки: начиная со Swift 5.7 any Shape можно передать в параметр some Shape или generic-параметр: компилятор открывает коробку на время вызова.
Написание: any обязателен для протоколов с associated types или требованиями Self. Для остальных протоколов голое имя по-прежнему компилируется; upcoming feature ExistentialAny делает any обязательным везде.
По умолчанию берите some и generics; any берите, когда разные типы должны жить в одном месте.
some Shape обещает один конкретный тип на всю функцию, а два return возвращают Circle и Square. Компилятор сообщает, что return statements «do not have matching underlying types».
Вызывающий тип не узнаёт, но компилятор знать его обязан, и тип не может зависеть от значения во время выполнения вроде rounded.
Исправления
**Вернуть any Shape: каждый вызов может вернуть другой тип внутри коробки. Самый простой вариант, если вызывающему нужен только API протокола.
Обернуть оба типа в один конкретный**: enum с кейсом на каждую фигуру, который сам соответствует Shape. Функция может оставить some Shape: без коробки и со статической диспетчеризацией.
Дать выбор вызывающему: сделать функцию generic или разбить её на две.
Случай SwiftUI
var body: some View в SwiftUI может содержать if/else с разными вью только потому, что @ViewBuilder сворачивает ветки в один тип _ConditionalContent.
@escaping говорит, что замыкание может быть вызвано после возврата из функции (его сохраняют или передают в асинхронную работу). Non-escaping выбрано по умолчанию, потому что замыкание, которое не переживает вызов, дешевле и безопаснее: оно не может создать цикл сильных ссылок.
Что даёт non-escaping: компилятор может держать контекст замыкания на стеке и не ретейнить захваченное, а внутри можно использовать self неявно.
Что меняется при escaping: захваченные объекты живут столько же, сколько замыкание. Если self хранит (напрямую или через другой объект) замыкание, захватившее self, получается retain cycle, отсюда и [weak self]. В классах компилятор требует явный self. или список захвата, чтобы захват был виден.
**Нельзя захватить inout**: escaping-замыкание не может захватить inout-параметр: когда оно выполнится, записывать результат будет уже некуда.
Опциональные замыкания неявно escaping: completion: (() -> Void)? нельзя пометить, и оно считается escaping.
Swift 6: escaping-замыкание, которое выполняется на другом акторе или потоке, должно быть @Sendable (или передаваться как sending), а всё, что оно захватывает, должно быть Sendable.
Key path представляет собой проверяемую компилятором ссылку на свойство, записываемую как \User.name. Её можно хранить и передавать, а потом читать или записывать это свойство у любого экземпляра.
Типы: KeyPath<Root, Value> только читает; WritableKeyPath ещё и пишет, через var значимого типа; ReferenceWritableKeyPath пишет через ссылку на класс. PartialKeyPath<Root> и AnyKeyPath стирают тип значения, и тогда его можно только прочитать как Any.
Key path как функция: users.map(\.name) делает то же самое, что users.map { $0.name }.
Generic-хелперы: один sorted(by: \.age), grouped(by:) или sum(of:) вместо замыкания в каждом месте вызова.
Во фреймворках: ForEach(items, id: \.self), @Environment(\.colorScheme), SortDescriptor(\.date) в SwiftData и assign(to:on:) в Combine принимают key paths. $model.name в SwiftUI даёт Binding через @dynamicMemberLookup с key path-сабскриптом.
По сравнению со строками KVC из Objective-C: их проверяет компилятор, поэтому переименование свойства ломает сборку, а не падает во время выполнения. Ещё key paths соответствуют Hashable, их можно использовать как ключи словаря.
Property wrapper определяется как тип с атрибутом @propertyWrapper, который владеет хранилищем свойства: для @Clamped var volume компилятор хранит скрытое _volume и проводит каждое чтение и запись через его wrappedValue.
**wrappedValue**: единственное требование. init(wrappedValue:) позволяет писать = 50, а дополнительные аргументы дают @Clamped(0...100).
**projectedValue**: то, что возвращает $volume. В SwiftUI $text у @State даёт Binding.
Mutating-сеттер: если обёртка устроена как структура, которая хранит значение в себе, запись в свойство мутирует содержащее значение, поэтому экземпляр должен быть var, а внутри типа присваивать могут только mutating-методы.
**nonmutating set**: если обёртка пишет через ссылку (класс-коробку, хранилище фреймворка), присваивать можно даже у let-экземпляра; так работает @State. В собственных значимых типах это ловушка: копии делят одну коробку.
Типичное применение: ограничение диапазона и валидация, значения в UserDefaults (@AppStorage), внедрение зависимостей. Лок внутри обёртки не делает count += 1 атомарным: get и set выполняются двумя отдельными вызовами.
Ограничения: обёртка официально видит только своё значение; до содержащего экземпляра можно добраться лишь через недокументированный сабскрипт _enclosingInstance. Поэтому поведение на весь тип, как у @Observable, сделано макросом.
finished += 1 выполняется не как одна операция под блокировкой: сначала вызывается геттер (lock, чтение, unlock), потом сеттер (lock, запись, unlock). Два потока могут прочитать одно и то же значение и оба записать его плюс один, и инкременты теряются.
Почему обёртка не спасает: компилятор превращает finished += 1 в _finished.wrappedValue = _finished.wrappedValue + 1. Обёртка видит два несвязанных вызова. То же самое с append у обёрнутого массива или dict[key] = … у обёрнутого словаря.
Исправление: выполнять весь цикл «чтение-изменение-запись» под одной блокировкой: метод mutate { $0 += 1 }, доступный через projectedValue. Либо отказаться от обёртки и взять Mutex<Int> (withLock { $0 += 1 }) или Atomic<Int> (wrappingAdd) из модуля Synchronization (iOS 18+), либо актор, если вызывающий код может делать await.
Почему Thread Sanitizer молчит: каждое обращение к памяти идёт под блокировкой, поэтому гонки данных (data race) нет, есть только потерянное обновление. К тому же @unchecked Sendable отключает проверки Swift 6, так что компилятор тоже не поможет.
Как это поймать: стресс-тест: DispatchQueue.concurrentPerform(iterations: 100_000) вызывает markFinished(), затем XCTAssertEqual(stats.finished, 100_000). С этим багом прогон заканчивается намного меньшим числом.
Result builder представляет собой тип с атрибутом @resultBuilder, чьи статические методы build… компилятор вызывает, чтобы превратить инструкции замыкания в одно значение. К ним относится и @ViewBuilder в SwiftUI. Каждая конструкция управления работает, только если билдер реализует соответствующий метод.
Как отображается трансформация
buildExpression(_:): преобразует каждую строку (необязателен, но позволяет билдеру принимать другие типы)
buildBlock(_:): объединяет инструкции; единственный обязательный метод
buildOptional(_:): if без else
buildEither(first:) / buildEither(second:): if/else и switch
buildArray(_:): for … in
buildLimitedAvailability(_:): if #available
buildFinalResult(_:): последнее преобразование готового значения
Если метода нет, получаете ошибки вроде «closure containing control flow statement cannot be used with result builder».
Ограничения
У guard, while и repeat нет метода билдера; локальный let разрешён, но ничего не добавляет в результат.
Явный return в замыкании отключает билдер для этого замыкания.
buildPartialBlock объединяет дочерние элементы попарно (так устроен RegexBuilder), а parameter packs позволяют buildBlock принимать любое число детей; так SwiftUI избавился от старого ограничения в десять вью на контейнер.
Применяются для вью SwiftUI, RegexBuilder, DSL для тестов и билдеров HTML или attributed-строк.
Sequence обещает только, что по элементам можно пройти итератором, возможно, лишь один раз. Collection добавляет индексы, сабскрипт и повторяемый обход без разрушения данных. Для for-in достаточно Sequence.
**IteratorProtocol**: один метод mutating func next() -> Element?, который в конце возвращает nil. Здесь живёт состояние обхода.
**Sequence**: требует makeIterator(). Вместе с ним бесплатно получаете map, filter, reduce, contains, prefix, zip и sorted.
Короткие пути: sequence(first:next:), sequence(state:next:) или AnyIterator { … }, когда хватает функции шага.
**Collection**: требует startIndex, endIndex, index(after:) и сабскрипт и обещает доступ по индексу за O(1). count в общем случае всё равно O(n).
Уточнения: BidirectionalCollection добавляет дешёвые last и reversed(); RandomAccessCollection добавляет count за O(1) и прыжки по индексам. С индексами Int стандартная библиотека сама дописывает арифметику индексов.
Зачем это разделение: последовательность может быть одноразовой или бесконечной (поток из сокета, генератор случайных чисел), поэтому алгоритмы, которым нужно пройти данные дважды, есть только у Collection.
#if проверяет условие во время компиляции: код в ложной ветке вообще не попадает в бинарник. if #available проверяет во время выполнения версию ОС, на которой запущено приложение, а @available помечает объявление, чтобы компилятор заставил каждого вызывающего сделать эту проверку.
**#if**: проверяет сборку, а не устройство: #if DEBUG, #if os(iOS), #if targetEnvironment(simulator), #if canImport(UIKit), #if compiler(>=6.0). DEBUG существует только потому, что Debug-конфигурация Xcode прописывает его в Active Compilation Conditions; свои флаги добавляются туда же.
**if #available(iOS 18, *)**: код есть в любой сборке, но ветка выполняется только на iOS 18 и новее. Нужна, когда deployment target ниже версии API. Есть также guard #available и if #unavailable.
**@available(iOS 18, *)** на функции или типе переносит проверку к вызывающим. Через @available(*, deprecated, message: "…") помечают устаревшим собственный API.
Частая путаница: #if не умеет проверять версию ОС, а #available не может спрятать код, который не компилируется под платформу, например импорт UIKit в macOS-таргете.
Objective-C доступен в Swift через bridging header (в фреймворке через umbrella header). Swift доступен в Objective-C через сгенерированный YourModule-Swift.h, но только объявления с @objc в подклассах NSObject и типы, которые Objective-C умеет представить.
Nullability: аннотации (NS_ASSUME_NONNULL_BEGIN, nullable) решают, увидит Swift T или T?; без них он видит T!.
Переименование: NS_SWIFT_NAME даёт импортируемому API имя в стиле Swift.
Не видно из Objective-C: структуры, enum с associated values, generics, кортежи, расширения протоколов.
Диспетчеризация: вызовы Swift статические, через vtable или witness table; сообщения Objective-C идут через objc_msgSend. Для KVO и swizzling нужен @objc dynamic.
C++: -cxx-interoperability-mode=default (Swift 5.9+) позволяет Swift импортировать C++ напрямую.
Выведет start, затем map 1, map 2, map 2, 4, и то же ещё раз для второго first. Ленивая цепочка хранит замыкания, а не результаты, поэтому каждое обращение выполняет их заново, а first вызывает map для найденного элемента дважды: при поиске начального индекса и при обращении по нему.
По шагам
При создании squares ничего не выполняется. .lazy у массива даёт LazyFilterSequence<LazyMapSequence<[Int], Int>> (это ещё и коллекция, потому что массив тоже коллекция), которая хранит массив и два замыкания. Поэтому первым печатается start.
first у коллекции читает startIndex, а потом берёт элемент по нему (так first реализован в текущей стандартной библиотеке). startIndex фильтра проходит по базе и вызывает map для 1 (1 нечётное) и 2 (4 чётное). Затем сабскрипт читает базу по этому индексу, и ленивый map снова вызывает замыкание: это второй map 2.
Ничего не кэшируется, поэтому второй first повторяет всё заново.
Обычный (eager) numbers.map { … }.filter { … } напечатал бы map 1 … map 6 один раз, до start, и выделил бы два массива.
Когда lazy помогает, а когда вредит
Хорош для одного прохода с ранним выходом, например first(where:) или prefix(3), по большим или бесконечным последовательностям, и не создаёт промежуточных массивов.
Плох, когда результат читают больше одного раза или когда замыкания дорогие либо с побочными эффектами. count и isEmpty у ленивого фильтра тоже проходят всё заново.
Если значения нужны больше одного раза, материализуйте их один раз через Array(…).
Стирание типа (type erasure) прячет конкретный тип за обёрткой с фиксированным типом, чтобы разные типы, соответствующие одному протоколу, можно было хранить и передавать как один тип. AnyPublisher, AnySequence, AnyHashable и AnyView из SwiftUI относятся к стирающим обёрткам (type erasers).
Как устроен самописный eraser
Generic-структура вроде AnyRepository<Item> сама соответствует протоколу.
Её generic init<R: Repository>(_ base: R) where R.Item == Item захватывает методы базового значения в замыкания (или хранит приватный класс-коробку), и каждое требование протокола перенаправляется к ним.
Что когда использовать
Сначала generics: если за раз используется один конкретный тип, struct Screen<R: Repository> обходится без коробки и специализируется.
**any Repository<Order>: с primary associated types (Swift 5.7) покрывает большинство случаев, где нужен был только смешанный массив или заменяемое свойство.
Именованный eraser по-прежнему выигрывает**, когда обёртка должна соответствовать протоколу: экзистенциальный тип не соответствует собственному протоколу, поэтому Screen<any Repository<Order>> не компилируется. А ещё когда нужно спрятать длинный конкретный тип в публичном API (eraseToAnyPublisher()) или для протоколов с требованиями Self, как AnyHashable для Hashable.
Enum: когда набор реализаций закрыт, кейсы с associated values вообще не требуют стирания.
Цена
Каждый вызов идёт через замыкание или коробку: нет инлайнинга, нет специализации и обычно аллокация на каждую обёртку.
AnyView вдобавок прячет структуру вью, по которой SwiftUI их сравнивает, поэтому теряется не только скорость, но и идентичность и эффективность обновлений.
Оптимизатор может специализировать generic-функцию, создав копию под каждый встреченный конкретный тип, и тогда вызовы прямые и могут инлайниться. Если не может, работает одно общее тело, которое получает метаданные типа и witness tables; значение any всегда идёт этим косвенным путём.
Неспециализированный generic-код
Функция получает скрытые параметры: метаданные T (размер, выравнивание и value witness table для копирования, перемещения и уничтожения) и protocol witness table на каждое соответствие.
Каждый вызов требования становится косвенным вызовом через witness table, значения неизвестного размера обрабатываются через буферы, ничего не инлайнится.
Экзистенциальные типы
any Shape устроен как контейнер: встроенный буфер на три слова (большие значения уходят в кучу), указатель на метаданные и witness table, в сумме 40 байт на 64-битной платформе. Экзистенциальный тип, ограниченный классами, занимает 16 байт.
Вызовы всегда динамические, присваивание большого значения может аллоцировать, а сквозь коробку оптимизатор специализировать не может.
Передача any в generic-параметр открывает коробку, но вызываемый код всё равно работает неспециализированным, если оптимизатор не видит конкретный тип.
Когда специализация теряется
Границы модулей: клиент не может специализировать тело, которого не видит. @inlinable (вместе с @usableFromInline на внутренностях, которых оно касается) публикует тело, но делает его частью вашего ABI. Из других инструментов есть cross-module optimization и @_specialize.
Debug-сборки: при -Onone ничего не специализируется, поэтому замеряйте в Release через Instruments.
Generics нужны на горячих путях; any нужен там, где разные типы действительно должны жить в одном контейнере.
Фантомным типом (phantom type) называют generic-параметр, который тип никогда не хранит; он нужен только для того, чтобы компилятор различал в остальном одинаковые значения. ID<User> и ID<Order> оба оборачивают String, но передать один вместо другого уже не получится.
Ошибка, от которой защищает: путаница значений с одинаковым базовым типом: ID пользователя передали как ID заказа, метры сложили с футами, сырой ввод использовали там, где нужен проверенный.
Нулевая цена во время выполнения: тег обычно пустой enum, экземпляр которого никогда не создаётся, а ID<User> имеет ровно тот же layout, что и String.
API в зависимости от состояния: ограниченные расширения дают методы только некоторым тегам: у SignUpForm<Validated> есть submit(), а у SignUpForm<Draft> есть только validate(). Порядок шагов проверяет компилятор.
Соответствия проходят насквозь: Hashable, Codable и Sendable синтезируются по хранимому значению и игнорируют тег.
Цена в коде: больше generic-сигнатур и преобразований на границах модулей. Используйте для идентификаторов и единиц измерения, которые действительно путают, а не для каждой String.
Начиная со Swift 6 функция может объявить точный тип ошибки, throws(ParseError), и тогда catch получает ParseError, а не any Error, и вы можете разобрать его в switch исчерпывающе. Обычный throws по-прежнему правильный выбор по умолчанию для большей части кода.
Модель: throws означает throws(any Error), а не бросающая функция по сути соответствует throws(Never). Внутри блока do, где все вызовы бросают один тип, catch выводит этот тип.
Generic-проброс: func retry<T, E: Error>(_ body: () throws(E) -> T) throws(E) -> T пробрасывает точную ошибку замыкания. Небросающее замыкание даёт E == Never, и try при вызове не нужен. Это заменяет большинство случаев rethrows.
Хорошо подходит: для закрытого набора ошибок, который вызывающий должен обработать по кейсам (парсинг, валидация, внутренний слой модуля); для Embedded Swift и другого кода без аллокаций, ведь коробка any Error может аллоцировать; для generic-кода, который только пробрасывает ошибки.
Плохо подходит: для публичных API, которым позже могут понадобиться новые виды ошибок: тип ошибки становится частью контракта. Слои, которые вызывают URLSession, JSONDecoder или файловую систему, видят и чужие ошибки, и их пришлось бы оборачивать.
Только один тип: объединений ошибок нет; остальные ошибки оборачивают в свой enum.
Буфер общий для всех копий, пока кто-то не начнёт писать; перед записью проверка уникальности решает, нужно ли сначала клонировать буфер.
Механизм
Array, Dictionary, Set, String и Data устроены как структуры, оборачивающие ссылку на буфер в куче (String и Data хранят короткие значения прямо внутри, без буфера). Копирование структуры копирует только ссылку, то есть один retain, поэтому передача массива стоит одинаково при любой длине.
Каждый мутирующий метод сначала проверяет, единственный ли эта структура владелец. Если да, он пишет на месте; если нет, копирует буфер, а другую копию не трогает.
Свой тип
final-класс хранилища с данными, private внутри структуры.
isKnownUniquelyReferenced(&storage) на var-свойстве в начале каждого мутирующего члена и клонирование, если вернулось false.
Копирование максимум один раз: после первой записи структура владеет своим буфером, и следующие записи идут на месте.
На чём спотыкаются реализации
Ссылка, которая всё время указывает на текущий буфер: закэшированная ссылка на тот же объект хранилища или итератор, который его держит, заставляет isKnownUniquelyReferenced возвращать false при каждой записи, и каждая запись копирует весь буфер.
Проверяйте само хранимое свойство: вызывайте isKnownUniquelyReferenced(&storage) на var-свойстве, а не на локальной копии ссылки: копия становится вторым владельцем, и ответ всегда false.
Copy-on-write даёт семантику значений, а не потокобезопасность. Если два потока меняют две копии, это нормально; если два потока трогают одну переменную, это всё ещё гонка данных (data race).
Где это видно при профилировании
Добавление в массив в цикле стоит амортизированно O(1), но добавление, пока другая переменная держит тот же массив, оплачивает полную копию при первой записи. Структура с большим массивом, которую копируют во view model и потом меняют, платит эту копию каждый раз.
removeAll() пишет в общий Storage без проверки уникальности. После var draft = saved оба значения указывают на один объект, поэтому очистка draft очищает и saved: структура незаметно получает ссылочную семантику.
Почему это компилируется: storage хранит ссылку на класс, и storage.ids.removeAll() меняет объект, а не хранимое свойство структуры. Компилятор не видит ничего плохого: метод скомпилировался бы даже без mutating. Семантика значения есть только там, где перед записью вызывается isKnownUniquelyReferenced(&storage).
Исправление: один makeUnique(), который клонирует хранилище, пока его держит ещё одно значение, и вызывается первым в каждом мутирующем члене: removeAll, append, insert, сеттерах сабскриптов. В append и insert это уже было, поэтому «протекают» только некоторые операции.
Держите проверку в одном месте: когда мутирующих методов много, пропускайте каждую запись через один mutating-аксессор, например приватное свойство writableStorage, геттер которого вызывает makeUnique(). Тогда метод, добавленный позже, не сможет её пропустить.
Как это поймать: юнит-тест на каждый мутирующий API: скопировать значение, изменить копию, проверить, что оригинал не изменился. Держите класс хранилища вложенным, private и final, чтобы снаружи структуры в него никто не писал.
Каждый объект Swift хранит счётчики strong и unowned прямо в заголовке, а первая weak-ссылка переносит счётчики в side table, на которую и указывают weak-ссылки. deinit выполняется, когда strong-счётчик доходит до нуля, но память объекта освобождается, только когда обнулится и unowned-счётчик.
Три счётчика
Strong: обычные ссылки. На нуле выполняется deinit и освобождаются хранимые свойства.
Unowned: unowned-ссылки указывают на сам объект. Счётчик несёт лишний +1 от имени всех strong-ссылок, который снимается после deinit, и память освобождается, только когда счётчик доходит до нуля.
Weak: живёт только в side table. Первая weak-ссылка выделяет её в куче, поле счётчиков в заголовке становится указателем на неё, а weak-ссылки указывают на таблицу, а не на объект.
Жизненный цикл
Deiniting: strong-счётчик дошёл до нуля, выполняется deinit. С этого момента чтение weak возвращает nil, а чтение unowned падает.
Deinited: если unowned-ссылки остались, память объекта не освобождается, поэтому чтение unowned находит мёртвый объект и останавливается с «Attempted to read an unowned reference but object 0x… was already destroyed», а не читает переиспользованную память.
Freed: когда unowned-счётчик доходит до нуля. Side table живёт, пока не исчезнет последняя weak-ссылка, поэтому weak-ссылка тоже никогда не указывает на переиспользованную память.
На практике
unowned обходится без side table и без optional, поэтому дешевле weak, но держит память мёртвого объекта, пока не исчезнут все unowned-ссылки.
Забытая weak-ссылка держит только маленькую side table.
Отказ от Copyable убирает неявные копии, поэтому у значения в каждый момент ровно один владелец, и компилятор, а не код-ревью, доказывает, что дескриптор закрывается один раз и после этого не используется.
Некопируемая структура или enum может объявить deinit, который выполняется, когда единственный владелец исчезает. Присваивание, передача как consuming или возврат перемещают значение: старая привязка мертва, и обращение к ней даёт ошибку компиляции «used after consume». Ни подсчёта ссылок, ни коробки.
Владение у параметров
borrowing: доступ только на чтение на время вызова; владелец остаётся прежним.
consuming: вызываемая функция забирает владение, привязка у вызывающего заканчивается. Явный close() пишут как consuming-метод, который делает системный вызов, а затем discard self, чтобы deinit не закрыл дескриптор повторно.
inout: эксклюзивный изменяемый доступ, владение остаётся у вызывающего.
consume x завершает жизнь привязки в выбранной точке, а не в конце области видимости.
Чем платите
Generic-код должен явно это разрешить: обычный <T> по-прежнему означает <T: Copyable>, поэтому функциям и типам, которые должны принимать дескриптор, нужен <T: ~Copyable>.
Array, Dictionary и Set по-прежнему требуют копируемых элементов. Optional поддерживает некопируемые значения, как и InlineArray из Swift 6.2.
discard self сейчас разрешён, только если все хранимые свойства тривиальные, например целые числа и сырые указатели; поле String компилятор отклонит.
Используйте для типов, моделирующих ресурс (дескриптор, токен блокировки, транзакцию), а не для обычных данных.
Property wrapper меняет то, как читается и пишется одно хранимое свойство во время выполнения. Макрос генерирует код во время компиляции и может добавить члены, аксессоры и соответствия протоколам целому объявлению. @Observable должен переписать каждое свойство класса и добавить соответствие.
Property wrapper
Тип времени выполнения вокруг одного свойства; официально видит только своё значение, а не содержащий его экземпляр (до него можно добраться лишь через недокументированный сабскрипт _enclosingInstance), и не может добавлять члены или соответствия.
Дёшев: ни плагина сборки, ни дополнительной зависимости.
Макрос
Freestanding, пишется через #: выражение или объявления (#Preview, #expect, #Predicate).
Attached, пишется через @, по ролям: member, memberAttribute, accessor, peer, extension.
Реализуется на SwiftSyntax в отдельном таргете-плагине компилятора. Он может только добавлять код, результат проверяется типами как написанный вручную, а Expand Macro в Xcode его показывает.
Почему @Observable сделан макросом
Он превращает каждое хранимое свойство в вычисляемое, чей геттер вызывает access(keyPath:), а сеттер вызывает withMutation(keyPath:), добавляет ObservationRegistrar, пропускает свойства с @ObservationIgnored и добавляет соответствие Observable. Так получается отслеживание по свойствам: вью обновляется, только когда меняется свойство, которое она прочитала. Обёртки @Published могли лишь отправить один objectWillChange на весь объект.
Цена макросов
Время сборки: плагин и SwiftSyntax компилируются при каждой чистой сборке, если не используется готовая собранная копия.
Доверие: Xcode спрашивает разрешения перед запуском стороннего макроса, поэтому на CI это тоже нужно подтвердить (-skipMacroValidation).
Диагностики в сгенерированном коде читать труднее.
Правило: поведение одного свойства задают обёрткой; шаблонный код на весь тип или проверки во время компиляции делают макросом.