Existential Container в Swift: подготовка к собеседованию

от автора

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 должен иметь фиксированный размер (иначе нельзя помещать в массив, передавать в функции, копировать), но содержать значение произвольного размера, известного только в рантайме.

Варианты решения:

  1. Всегда класть значение на heap, в переменной — указатель. Каждое значение требует heap-аллокации, что дорого для маленьких типов.

  2. Резервировать заведомо большой буфер. Расточительно по памяти.

  3. Гибрид: маленькие значения — 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’а, происходит примерно следующее:

  1. Читается указатель на metadata из slot 3.

  2. Через metadata получается указатель на VWT.

  3. Через 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")

Что происходит:

  1. Создаётся Dog(name: "Rex") как struct. Размер — 16 байт (String — два машинных слова: указатель + length+flags).

  2. Выделяется память под Existential Container — 40 байт.

  3. Размер Dog (16 байт) ≤ 24 → значение копируется inline в buffer.

  4. В slot type metadata записывается указатель на Dog.Type metadata.

  5. В 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()

Как это работает:

  1. Компилятор знает на этапе компиляции, что makeSound() — это слот 0 в требованиях Animal.

  2. Рантайм читает PWT из последнего слота контейнера.

  3. По индексу 0 в PWT берёт указатель на функцию → это Dog.makeSound.

  4. Передаёт указатель на self (на начало inline buffer) как неявный первый параметр.

  5. Indirect call по полученному адресу.

Получаются два уровня индирекции (контейнер → PWT, PWT → метод) и indirect call. Indirect call плохо предсказывается процессорным branch predictor’ом — pipeline стопорится.

Шаг 3: копирование контейнера

let copy = animal

Контейнер — это value type. Копирование происходит через VWT:

  1. Рантайм читает указатель на metadata.

  2. Через metadata получает VWT.

  3. Вызывает VWT.initializeWithCopy(into: &copy.buffer, from: &animal.buffer, metadata).

  4. Для Dog копируется String — увеличивается refcount строкового буфера.

  5. Указатели на metadata и PWT просто копируются (они const).

То есть копирование existential — это вызов функции через VWT, которая знает, как именно копировать конкретный тип. Для тривиальных типов — это просто memcpy. Для типов с reference-полями — нужны retain/release вызовы.

Шаг 4: уничтожение

Когда animal выходит из scope:

  1. Рантайм читает metadata.

  2. Через VWT вызывает destroy.

  3. Для 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                 │└────────────────────────────────────┘

Почему так:

  1. Bridging с NSError. NSError — это Objective-C класс. Чтобы конвертация между Swift Error и NSError была дешёвой, Swift Error тоже представлен как «ссылка на объект».

  2. Errors часто бросаются. При try-вызове функция должна передать error обратно по стеку. Один указатель — это самый простой способ представления.

  3. 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, где idAnyObject. Когда происходит мост:

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 убрать нельзя

  1. Class-only protocols (protocol P: AnyObject). Уменьшают контейнер до 2 слов и убирают inline-vs-heap логику.

  2. Делать conforming-типы не больше 24 байт. Тогда они помещаются inline, без heap-аллокации.

  3. @frozen для conforming-структур (в библиотеках с library evolution). Позволяет компилятору работать с layout напрямую.

  4. Использовать implicit opening (Swift 5.7+) и писать generic-функции, передавая в них existential — компилятор сам откроет.

  5. 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/