Existential Container — одна из тех тем, которые регулярно всплывают на собеседованиях на middle и senior iOS-разработчика. Если понимать layout контейнера, проще объяснить несколько связанных тем: any vs some, Protocol with Associated Types, type erasure и стоимость protocol dispatch.
Статья построена в формате подготовки к собеседованию: сначала компактная шпаргалка с вопросами и ответами для быстрого повторения, затем детальный разбор с примерами и диаграммами.
Шпаргалка: 15 вопросов с короткими ответами
Что такое Existential Container?
Структура фиксированного размера, которая представляет «значение неизвестного на этапе компиляции типа, соответствующего протоколу». Используется, когда переменная имеет тип any P (или просто P до Swift 5.6).
Какой размер у обычного Existential Container на 64-битной системе?
40 байт (5 машинных слов по 8 байт каждое).
Из каких полей состоит контейнер?
Три слота inline buffer (24 байта) + указатель на type metadata + указатель на Protocol Witness Table. Итого — 5 слов.
Что такое inline buffer и зачем он нужен?
Это зарезервированные 24 байта внутри контейнера для inline-хранения значения, если оно туда влезает. Помогает избежать heap-аллокации для маленьких значений.
Что произойдёт, если значение больше 24 байт?
Будет аллоцировано в heap, а в первый слот inline buffer запишется указатель на эту аллокацию. Каждое создание и уничтожение такого значения вызывает malloc / free.
Что такое type metadata?
Структура, описывающая тип в рантайме: kind (class/struct/enum), size, alignment, имя, указатель на Value Witness Table. Лежит в read-only сегменте бинарника, по одной на каждый тип.
Что такое Value Witness Table (VWT)?
Таблица указателей на функции базовых операций над значением: copy, destroy, move, assign. Позволяет рантайму работать со значением неизвестного типа: копировать, уничтожать, перемещать.
Что такое Protocol Witness Table (PWT)?
Таблица указателей на методы конкретного типа в порядке требований протокола. Уникальна для каждой пары (тип, протокол). Используется для диспетчеризации вызова методов через existential.
Чем диспетчеризация через PWT отличается от обычного вызова?
Indirect call хуже предсказывается процессором и мешает инлайнингу. Поэтому такой вызов обычно сложнее оптимизировать, чем direct call.
Какой размер контейнера для protocol composition (any P1 & P2)?
Растёт линейно: на каждый дополнительный протокол добавляется одно слово (под Protocol Witness Table). any P1 & P2 — 6 слов, any P1 & P2 & P3 — 7 слов и т.д.
Что особенного у class-only протоколов (protocol P: AnyObject)?
Контейнер сильно компактнее — всего 2 слова (16 байт): указатель на объект + указатель на PWT. Inline buffer и type metadata не нужны: значение — всегда ссылка на объект, а metadata можно достать через isa-pointer класса.
Что особенного у типа Error?
У Error собственный формат: один указатель на heap-аллоцированный «error box». Сделано для облегчения bridging с NSError и передачи через границы try/catch. Каждый брошенный error — это heap-аллокация.
Почему до Swift 5.7 нельзя было использовать Hashable и Protocol with Associated Types как existential?
Потому что в их сигнатурах есть Self или ассоциированные типы. Проблема была не в физическом хранении значения, а в операциях, где в сигнатуре участвует Self или associated type. У existential есть значение и его dynamic type, но не все требования такого протокола можно вызвать без открытия конкретного типа.
Что изменилось в Swift 5.7?
Появилась возможность писать any Hashable, any Equatable и т. п. Также появилось implicit opening — автоматическое «раскрытие» existential в generic-параметр при передаче в generic-функцию. Это синтаксический сахар компилятора, не runtime-механика.
Когда стоит избегать any P?
В часто выполняемом коде с большим количеством вызовов (witness table dispatch медленнее direct call); для conforming-типов больше 24 байт (heap-аллокация на каждом значении); когда тип фактически один и известен — там лучше generic или some.
Детальный разбор
1. Какую проблему решает Existential Container
Допустим, есть протокол и несколько сущностей, которые его реализуют:
protocol Animal { func makeSound()}struct Dog: Animal { let name: String func makeSound() { print("\(name): Woof!") }}struct Cat: Animal { let mood: String let age: Int func makeSound() { print("Meow") }}struct Whale: Animal { let location: GPSCoordinate let dna: [DNASegment] // массив из миллиона элементов func makeSound() { print("...") }}
Размеры у них разные: у Dog — 16 байт, у Cat — 24, у Whale — десятки килобайт. Но мы хотим иметь возможность написать:
let animal: any Animal = Dog(...)let animals: [any Animal] = [Dog(...), Cat(...), Whale(...)]
То есть — переменную или массив, в которых может лежать любой Animal. Тип any Animal должен иметь фиксированный размер (иначе нельзя помещать в массив, передавать в функции, копировать), но содержать значение произвольного размера, известного только в рантайме.
Варианты решения:
-
Всегда класть значение на heap, в переменной — указатель. Каждое значение требует heap-аллокации, что дорого для маленьких типов.
-
Резервировать заведомо большой буфер. Расточительно по памяти.
-
Гибрид: маленькие значения — inline, большие — в heap.
Swift выбрал третий вариант. Помимо самого значения нужно ещё хранить:
-
Информацию о типе (чтобы знать, как с ним работать — копировать, уничтожать).
-
Таблицу методов протокола (чтобы знать, что именно вызывать при
animal.makeSound()).
2. Анатомия opaque Existential Container
Базовый («opaque») Existential Container на 64-битной системе занимает 5 машинных слов, то есть 40 байт:
┌─────────┬────────────────────────────────────────┐│ Slot 0 │ Inline buffer word 0 │├─────────┼────────────────────────────────────────┤│ Slot 1 │ Inline buffer word 1 │├─────────┼────────────────────────────────────────┤│ Slot 2 │ Inline buffer word 2 │├─────────┼────────────────────────────────────────┤│ Slot 3 │ Type metadata pointer │├─────────┼────────────────────────────────────────┤│ Slot 4 │ Witness table pointer │└─────────┴────────────────────────────────────────┘
Каждое поле:
-
Slots 0–2 (24 байта) — inline buffer. Сюда копируется значение, если оно влезает. Если нет — кладётся указатель на heap-копию.
-
Slot 3 — указатель на type metadata конкретного типа, лежащего внутри.
-
Slot 4 — указатель на Protocol Witness Table для пары (конкретный тип, протокол).
Проверить размер можно прямо в коде:
print(MemoryLayout<any Animal>.size) // 40
Это значение не зависит от того, какой тип лежит внутри. Любой any Animal — это всегда 40 байт. Это «единый формат», ради которого нужен контейнер.
3. Граница 24 байта: inline vs heap
Размер inline buffer’а — 24 байта. Если значение помещается в этот объём — оно копируется inline. Если нет — аллоцируется на heap.
struct Small: Animal { let x: Int // 8 байт func makeSound() {}}struct Medium: Animal { let x: Int // 8 let y: Int // 8 let z: Int // 8 func makeSound() {}}struct Big: Animal { let x: Int // 8 let y: Int // 8 let z: Int // 8 let w: Int // 8 — на одно поле больше func makeSound() {}}print(MemoryLayout<Small>.size) // 8print(MemoryLayout<Medium>.size) // 24 — точно на границеprint(MemoryLayout<Big>.size) // 32 — больше границы
Small и Medium лягут inline. Big пойдёт в heap.
Когда значение inline, контейнер выглядит так:
animal:┌────────────────────────────────────────┐│ small value bytes 0-7 │├────────────────────────────────────────┤│ small value bytes 8-15 │├────────────────────────────────────────┤│ small value bytes 16-23 (+padding) │├────────────────────────────────────────┤│ → type metadata │├────────────────────────────────────────┤│ → witness table │└────────────────────────────────────────┘
Когда значение в heap, в первый слот пишется указатель:
animal:┌────────────────────────────────────────┐│ → heap allocation │├────────────────────────────────────────┤│ unused │├────────────────────────────────────────┤│ unused │├────────────────────────────────────────┤│ → type metadata │├────────────────────────────────────────┤│ → witness table │└────────────────────────────────────────┘heap:┌────────────────────────────────────────┐│ allocation header │├────────────────────────────────────────┤│ actual big value │└────────────────────────────────────────┘
Каждое создание большого значения в existential — это вызов malloc, каждое уничтожение — free. Это значительный overhead, особенно если работа с такими значениями идёт в часто используемом коде.
Типичный вопрос на собеседовании: «Если у struct A 24 байта, а у struct B 25 байт, и обе соответствуют протоколу P, есть ли разница при хранении в any P?» — Да, A ляжет inline, B пойдёт в heap.
4. Type metadata: что это такое
Type metadata — структура, лежащая в read-only сегменте бинарника. Создаётся компилятором для каждого Swift-типа. Содержит описание типа для рантайма:
-
Kind — class / struct / enum / tuple / function / protocol / metatype.
-
Size, alignment, stride значения.
-
Указатель на Value Witness Table (VWT).
-
Имя типа (для reflection и отладки).
-
Для классов — virtual table, isa-pointer для совместимости с Objective-C.
-
Для дженериков — параметры типа.
Когда рантайм копирует значение из Existential Container’а, происходит примерно следующее:
-
Читается указатель на metadata из slot 3.
-
Через metadata получается указатель на VWT.
-
Через VWT вызывается нужная операция (
copy,destroy,move).
Это универсальный механизм работы с типами неизвестного размера.
5. Value Witness Table
VWT — это, по сути, «интерфейс» для базовых операций над значением. Упрощённо:
VWT:┌────────────────────────────────────────────┐│ ptr → destroy ││ ptr → initializeWithCopy ││ ptr → assignWithCopy ││ ptr → initializeWithTake ││ ptr → assignWithTake ││ size ││ stride ││ alignment mask ││ flags (isPOD, isBitwiseTakable, ...) │└────────────────────────────────────────────┘
Несколько важных моментов:
isBitwiseTakable — флаг «можно ли копировать через memcpy». Для тривиальных типов (Int, Double, struct из примитивов без классов) — true, рантайм использует прямой memcpy. Для типов с reference-полями (классы внутри, indirect enum cases) — false, нужен сложнее протокол с retain/release.
destroy — для тривиальных типов это no-op. Для типов с классами вызывает release на каждом reference-поле.
initializeWithCopy vs assignWithCopy — разница в том, инициализируем мы «свежую» память или присваиваем в уже инициализированную (которую сначала надо уничтожить).
Большую часть времени про VWT не задумываешься, но через эти функции рантайм обрабатывает any-значения. Для интервью обычно достаточно понимать роль VWT: она описывает, как копировать, перемещать и уничтожать значение неизвестного concrete type.
6. Protocol Witness Table
PWT — таблица указателей на методы конкретного типа в порядке требований протокола.
protocol Animal { func makeSound() // слот 0 func eat() // слот 1 var name: String { get } // слот 2 (getter)}extension Dog: Animal { func makeSound() { print("woof") } func eat() { print("eating") } var name: String { "Rex" }}
PWT для пары (Dog : Animal):
┌─────────────────────────────────┐│ ptr → Dog.makeSound │ слот 0│ ptr → Dog.eat │ слот 1│ ptr → Dog.name.getter │ слот 2└─────────────────────────────────┘
PWT уникальна для каждой пары (тип, протокол). Если Dog ещё соответствует CustomStringConvertible — у него отдельная PWT для этой пары. Если Cat тоже соответствует Animal — это уже третья таблица, не имеющая никакого отношения к (Dog : Animal).
Важный нюанс: что попадает в PWT
В PWT попадают только методы, объявленные в protocol { }. Методы из protocol extension, не объявленные в самом протоколе, в PWT не попадают.
Это объясняет классическую ловушку, которую любят на собеседованиях:
protocol P { func foo() }extension P { func foo() { print("ext foo") } func bar() { print("ext bar") } // НЕ в требованиях протокола}struct S: P { func foo() { print("S foo") } func bar() { print("S bar") }}let p: any P = S()p.foo() // "S foo" — динамический dispatch через PWTp.bar() // "ext bar" — статический dispatch по типу P
Почему bar() печатает "ext bar", а не "S bar"? Метод bar не объявлен в требованиях P, поэтому слота в PWT для него нет. Компилятор не может сделать динамический dispatch — нет таблицы, через которую осуществляется вызов. Он подставляет реализацию статически по типу переменной (any P), а это extension-метод протокола.
Запомнить просто: в PWT попадает только то, что объявлено в самом protocol { }.
7. Полный жизненный цикл existential container
Разберём пошагово, что происходит, когда мы создаём, используем и уничтожаем any Animal.
protocol Animal { func makeSound() }struct Dog: Animal { let name: String func makeSound() { print("\(name): Woof!") }}let animal: any Animal = Dog(name: "Rex")animal.makeSound()
Шаг 1: создание контейнера
let animal: any Animal = Dog(name: "Rex")
Что происходит:
-
Создаётся
Dog(name: "Rex")как struct. Размер — 16 байт (String— два машинных слова: указатель + length+flags). -
Выделяется память под Existential Container — 40 байт.
-
Размер
Dog(16 байт) ≤ 24 → значение копируется inline в buffer. -
В slot type metadata записывается указатель на
Dog.Typemetadata. -
В slot witness table записывается указатель на PWT
(Dog : Animal).
Раскладка в памяти:
animal (40 байт):┌────────────────────────────────────────┐│ "Rex" string pointer │ ← Dog.name pointer├────────────────────────────────────────┤│ "Rex" length+flags │ ← Dog.name discriminator├────────────────────────────────────────┤│ unused │├────────────────────────────────────────┤│ → Dog.Type metadata │├────────────────────────────────────────┤│ → PWT (Dog : Animal) │└────────────────────────────────────────┘
Шаг 2: вызов метода
animal.makeSound()
Как это работает:
-
Компилятор знает на этапе компиляции, что
makeSound()— это слот 0 в требованияхAnimal. -
Рантайм читает PWT из последнего слота контейнера.
-
По индексу 0 в PWT берёт указатель на функцию → это
Dog.makeSound. -
Передаёт указатель на self (на начало inline buffer) как неявный первый параметр.
-
Indirect call по полученному адресу.
Получаются два уровня индирекции (контейнер → PWT, PWT → метод) и indirect call. Indirect call плохо предсказывается процессорным branch predictor’ом — pipeline стопорится.
Шаг 3: копирование контейнера
let copy = animal
Контейнер — это value type. Копирование происходит через VWT:
-
Рантайм читает указатель на metadata.
-
Через metadata получает VWT.
-
Вызывает
VWT.initializeWithCopy(into: ©.buffer, from: &animal.buffer, metadata). -
Для
Dogкопируется String — увеличивается refcount строкового буфера. -
Указатели на metadata и PWT просто копируются (они const).
То есть копирование existential — это вызов функции через VWT, которая знает, как именно копировать конкретный тип. Для тривиальных типов — это просто memcpy. Для типов с reference-полями — нужны retain/release вызовы.
Шаг 4: уничтожение
Когда animal выходит из scope:
-
Рантайм читает metadata.
-
Через VWT вызывает
destroy. -
Для
Dogэто release строки.
8. Большие типы и heap-аллокация
Теперь рассмотрим случай, когда значение не помещается inline:
struct BigData: Animal { let a: Int let b: Int let c: Int let d: Int // итого 32 байта func makeSound() { print("big") }}let animal: any Animal = BigData(a: 1, b: 2, c: 3, d: 4)
Здесь sizeof(BigData) = 32 > 24, поэтому значение аллоцируется в heap:
animal (40 байт, на стеке):┌────────────────────────────────────────┐│ → BigData on heap │├────────────────────────────────────────┤│ unused │├────────────────────────────────────────┤│ unused │├────────────────────────────────────────┤│ → BigData.Type metadata │├────────────────────────────────────────┤│ → PWT (BigData : Animal) │└────────────────────────────────────────┘heap:┌────────────────────────────────────────┐│ allocation header (~16 bytes) │├────────────────────────────────────────┤│ a: 1, b: 2, c: 3, d: 4 (32 bytes) │└────────────────────────────────────────┘
При копировании здесь важна value-семантика: два контейнера не должны начать разделять одно изменяемое значение. Если внутри лежит указатель на heap-объект, не нарушит ли копирование value-семантику (два контейнера ссылаются на один объект)?
Нет, не нарушит. Когда контейнер копируется, VWT.initializeWithCopy для BigData создаёт отдельную heap-копию. У каждой копии контейнера — своя heap-аллокация. Value-семантика сохраняется, но ценой ещё одной аллокации при каждом копировании.
В рантайме Swift используется более сложный механизм с opaque value buffers, который может поддерживать copy-on-write для existential container. Но детали этого зависят от версии компилятора и не специфицированы публично.
9. Protocol composition: несколько witness tables
let value: any Hashable & CustomStringConvertible = "hello"
Сколько witness tables в контейнере? Две — по одной на каждый протокол. Контейнер растёт:
┌────────────────────────────────────────────┐│ Inline buffer (24 байта) │├────────────────────────────────────────────┤│ Type metadata │├────────────────────────────────────────────┤│ PWT (String : Hashable) │├────────────────────────────────────────────┤│ PWT (String : CustomStringConvertible) │└────────────────────────────────────────────┘
Итого 6 слов = 48 байт. Для трёх протоколов — 7 слов. И так далее. Размер контейнера пропорционален числу протоколов в композиции.
При вызове метода компилятор знает, какой witness table брать — он знает, из какого протокола вызываемый метод. Если из Hashable — PWT1, если из CustomStringConvertible — PWT2.
10. Class-only protocols: компактный контейнер
Если протокол наследует AnyObject (то есть является class-only), значение гарантированно будет ссылкой на класс. Inline buffer не нужен — ссылка всегда умещается в одно слово.
protocol Worker: AnyObject { func work()}class Employee: Worker { func work() { print("working") } }let worker: any Worker = Employee()print(MemoryLayout<any Worker>.size) // 16, не 40
Class Existential Container — всего 2 слова на 64-битной системе:
worker:┌────────────────────────────────────┐│ → Employee object reference │├────────────────────────────────────┤│ → PWT (Employee : Worker) │└────────────────────────────────────┘
Type metadata здесь не нужна отдельным полем — её можно достать прямо из объекта (через isa-pointer класса). Inline buffer не нужен — ссылка всегда занимает ровно одно слово.
Это компактнее opaque-варианта и быстрее в работе: нет логики inline-vs-heap, нет heap-аллокаций для самого значения (значение всегда уже на heap — это класс).
Для собеседования: если все conforming-типы протокола заведомо классы, имеет смысл явно унаследовать протокол от AnyObject. Это уменьшит размер Existential Container’а с 40 до 16 байт и уберёт overhead копирования значения.
protocol DataSource { ... } // 40 байт в any DataSourceprotocol DataSource: AnyObject { ... } // 16 байт в any DataSource
11. Особый случай: тип Error
Тип Error имеет собственный, отличный от обычного existential, формат:
let err: Error = MyError.somethingprint(MemoryLayout<Error>.size) // 8
Один указатель. Под капотом:
err:┌────────────────────────────────────┐│ → ErrorObject on heap │└────────────────────────────────────┘heap:┌────────────────────────────────────┐│ refcount header ││ → ErrorType metadata ││ → PWT for Error ││ actual error value │└────────────────────────────────────┘
Почему так:
-
Bridging с NSError.
NSError— это Objective-C класс. Чтобы конвертация между SwiftErrorиNSErrorбыла дешёвой, SwiftErrorтоже представлен как «ссылка на объект». -
Errors часто бросаются. При
try-вызове функция должна передать error обратно по стеку. Один указатель — это самый простой способ представления. -
Errors часто сохраняются в коллекциях (массивы ошибок, словари error-handlers). Один указатель компактнее.
Следствие: каждый throw — это heap-аллокация. На путях с частыми try/catch это становится заметно в профайлере.
В Swift 6 появились typed throws, которые в перспективе могут это исправить:
func parse() throws(ParseError) -> Result { ... }
Здесь тип ошибки известен компилятору, и если он маленький value type, можно обойтись без heap-аллокации.
12. Protocol with Associated Types и existentials
До Swift 5.7 нельзя было написать:
let h: any Hashable = 5 // ❌ ошибка до 5.7let array: [any Equatable] = [] // ❌ тоже
Причина: Hashable и Equatable — это протоколы с Self-requirements. У них в сигнатуре == участвует Self:
public protocol Equatable { static func == (lhs: Self, rhs: Self) -> Bool}
Existential Container не умел корректно представлять «значение со ссылкой на Self» — это требовало бы знания конкретного типа на этапе компиляции. То же касается Protocol with Associated Types: ассоциированный тип, по определению, известен только для каждой конкретной реализации.
С Swift 5.7 ситуация изменилась:
1. Protocol with Associated Types и Self-requirement протоколы можно использовать через any:
let array: [any Hashable] = [1, "hello", true] // OKlet h: any Hashable = 5 // OK
Контейнер для такого existential устроен сложнее обычного — нужно хранить дополнительную информацию об ассоциированных типах. Но снаружи это выглядит как обычный existential.
2. Implicit opening of existentials. Компилятор автоматически «открывает» any в generic-параметр при передаче в generic-функцию:
func use<T: Hashable>(_ value: T) { var hasher = Hasher() value.hash(into: &hasher)}let h: any Hashable = 5use(h) // Swift 5.7+: работает! Компилятор сам распаковывает any в T.
До Swift 5.7 это требовало ручного перебора через as?:
if let h = h as? Int { use(h) }else if let h = h as? String { use(h) }// — невозможно перечислить все типы
Implicit opening — это синтаксический сахар компилятора, а не runtime-магия. Компилятор просто генерирует код, который достаёт type metadata из контейнера и вызывает специализированную или generic версию функции.
Однако ограничения остаются. Не все операции с any Hashable доступны напрямую:
let h: any Hashable = 5h.hash(into: &hasher) // ❌ нельзя — inout self с ассоциированным типом
Тут поможет либо передача в generic-функцию (implicit opening), либо downcast к конкретному типу.
13. Что видно в lldb
Полезно для понимания, что в реальности лежит в переменной.
let animal: any Animal = Dog(name: "Rex")
В lldb:
(lldb) po animalDog(name: "Rex")(lldb) p animal(Animal) animal = (payload_data_0 = ..., payload_data_1 = ..., payload_data_2 = 0, metadata = 0x100008f70, wtable = 0x100008fa8)
Видно 5 полей payload_data_X — это слоты контейнера. metadata и wtable — указатели на type metadata и Protocol Witness Table.
Можно проинспектировать metadata:
(lldb) image lookup -a 0x100008f70
Покажет, к какому типу относится metadata по этому адресу.
14. Что видно в SIL
SIL (Swift Intermediate Language) — промежуточное представление компилятора, в которое транслируется ваш код перед оптимизациями и преобразованием в LLVM IR. Полезно посмотреть, чтобы убедиться, как именно компилятор представляет existential.
swiftc -emit-sil file.swift > out.sil
Для нашего примера в SIL появятся примерно такие инструкции:
%container = alloc_stack $Animal%addr = init_existential_addr %container : $*Animal, $Dogstore %dog to %addr : $*Dog// Для вызова метода:%opened = open_existential_addr immutable_access %container : $*Animal to $*@opened("UUID") Animal%method = witness_method $@opened("UUID") Animal, #Animal.makeSound : ...%result = apply %method<@opened("UUID") Animal>(%opened)
Ключевые инструкции:
-
init_existential_addr— «инициализируй Existential Container типом X». Здесь рантайм заполняет metadata и witness table слоты. -
open_existential_addr— «открой контейнер, получи указатель на значение как на opaque-тип». Используется для доступа к значению. -
witness_method— «достань функцию из witness table по заданному требованию протокола». -
apply— собственно вызов функции.
Если в часто используемом коде встречаются init_existential / open_existential / witness_method — это сигнал, что код активно работает через existentials, и стоит подумать о переходе на generic или opaque types.
15. Сравнение с другими механизмами
Тип Any
Any — тоже existential, но без requirement’ов протокола. Только type metadata + inline buffer:
print(MemoryLayout<Any>.size) // 32 (4 слова: 3 buffer + 1 metadata)
Witness tables не нужны — нет протокола, методов которого нужно вызывать. С Any можно делать только as? cast или инспектировать через Mirror.
Generic параметры
func process<T: Animal>(_ animal: T) { ... }
В отличие от any P, generic-параметр не использует Existential Container. Компилятор либо генерирует специализированную копию функции под конкретный T (generic specialization при оптимизации), либо передаёт type metadata и witness table как скрытые параметры. В обоих случаях нет inline-vs-heap логики и нет копирования через VWT.
Opaque types (some P)
func makeAnimal() -> some Animal { Dog() }
some P — тоже не использует Existential Container. Это opaque-тип: конкретный тип скрыт от вызывающего, но известен компилятору. По устройству и перформансу аналогичен generic-параметру.
Type erasure через wrapper
struct AnyAnimal { ... }let array: [AnyAnimal] = [...]
Type erasure — это паттерн, в котором мы создаём собственную обёртку (обычно через box pattern с классом или closures), стирающую конкретный тип. Не использует Existential Container, но реализует похожую идею другими средствами. Часто выбирают как альтернативу any P, когда нужен больший контроль над API и поведением обёртки.
Bridging с Objective-C
NSArray под капотом — это массив id, где id ≈ AnyObject. Когда происходит мост:
let arr = someNSArray as [Any]
Каждый элемент оборачивается в Swift Existential Container. Это O(n) операция с аллокациями.
16. Когда стоит избегать any P
На собеседовании важно понимать не только устройство, но и практические следствия.
Случаи, когда any P оправдан
-
Гетерогенные коллекции:
[any View],[any Animal]. Без any эту задачу не решить. -
API-границы, где конкретный тип меняется или принципиально неважен.
-
Type erasure для скрытия деталей реализации.
-
Декоратор-паттерны, где конкретный тип не имеет значения.
Случаи, когда any P стоит избегать
-
Часто выполняемый код с большим числом вызовов. Witness table dispatch — это indirect call, плохо предсказывается, не инлайнится. В таких случаях лучше generic с
<T:>или opaque-типsome P. -
Conforming-типы больше 24 байт часто помещаются в
any P. Каждое значение — heap-аллокация. На массиве из миллиона таких значений это даёт миллион аллокаций. -
Тип на самом деле один и известен. Тогда нет смысла платить за динамику — generic работает на скорости direct call.
Оптимизации, если existential убрать нельзя
-
Class-only protocols (
protocol P: AnyObject). Уменьшают контейнер до 2 слов и убирают inline-vs-heap логику. -
Делать conforming-типы не больше 24 байт. Тогда они помещаются inline, без heap-аллокации.
-
@frozenдля conforming-структур (в библиотеках с library evolution). Позволяет компилятору работать с layout напрямую. -
Использовать implicit opening (Swift 5.7+) и писать generic-функции, передавая в них existential — компилятор сам откроет.
-
Typed throws для error-типов (Swift 6+).
17. Типичные вопросы на собеседовании
Свод того, что обычно гоняют на этой теме:
«Что такое Existential Container?» — Структура фиксированного размера, представляющая «значение неизвестного типа, соответствующее протоколу». Состоит из inline buffer + type metadata + witness table(s).
«Какого размера opaque existential на 64-битной системе?» — 5 слов = 40 байт.
«Что произойдёт, если положить в any P struct размером 32 байта?» — Будет heap-аллокация (>24 байт). Каждое создание и уничтожение → malloc/free.
«Чем отличается class-only existential?» — 2 слова: object reference + witness table. Inline buffer не нужен, type metadata достаётся через isa-pointer класса.
«Почему Error — особый?» — У него собственный формат: один указатель на heap-error-box. Облегчает bridging с NSError и передачу через границы try/catch.
«Когда any P медленнее <T: P>?» — Почти всегда. any — динамический dispatch через witness table + потенциальный boxing. Generic со специализацией — direct call с инлайнингом.
«Какой dispatch у метода из protocol extension, если он не объявлен в самом протоколе?» — Статический, по типу переменной. Override в conforming-типе виден только при вызове через конкретный тип, не через any P.
«Почему до Swift 5.7 нельзя было использовать Protocol with Associated Types как existential?» — Existential Container не мог корректно представить значение с ассоциированными типами или Self-requirements: компилятор не знал бы, как сопоставлять типы при сравнениях и какой размер буфера выделять.
«Что такое implicit opening of existentials?» — Автоматическое раскрытие any в generic-параметр при передаче в generic-функцию. Появилось в Swift 5.7. Синтаксический сахар компилятора.
«Как уменьшить overhead any P?» — Сделать протокол class-only (: AnyObject), уменьшить conforming-типы до 24 байт, использовать @frozen в библиотеках с library evolution, перейти на generic в часто выполняемом коде.
Полезные ссылки
Теги: swift, ios development, performance, protocols, generics, interview
ссылка на оригинал статьи https://habr.com/ru/articles/1043692/