Existential container - это структура данных в рантайме, которая хранит значение типа, скрытого за протоколом. Он появляется там, где мы используем название протокола в качестве типа переменной или аргумента функции. С помощью existential container реализован динамический вызов методов протокола, а также управление жизненным циклом внутренного значения типа.

Здесь и далее мы рассмотрим 64-х битную архитектуру, где 1 машинное слово - это 8 байт. Existential container занимает 5 машинных слов или 40 байт и состоит из трех компонентов:

  1. 24 байта для данных

  2. Указатель размером в 8 байт на меданные типа

  3. Указатель размером в 8 байт на protocol witness table

Рассмотрим каждый компонент отдельно начиная с данных. Если экземпляр занимает меньше или ровно 24 байта, тогда все поля помещаются внутрь existential container, как, например, структура из 3-х полей типа Int.

struct InlineExample: Existential {
    let id = 0xA
    let hash: Int = 0xB
    let sum: Int = 0xC
}

В нашем примере памяти existential container для этой структуры будет выглядеть так:

Схематичное изображение полей inline existential container
Схематичное изображение полей inline existential container

В первых трех адресах мы видим значения трех полей структуры. Это пример inline existential container, когда данные размещаются внутри контейнера.

Если структура занимает больше 24 байтов, тогда в начале existential container будет лежать указатель на специальный объект в куче. Этот объект состоит из указателя на метаданные, счетчика ссылок и значений всех полей этой структуры. По сути это и есть обычный reference type object. Но благодаря existential container при работе со значением сохраняется верная семантика копирования. Рассмотрим структуру с пятью полями, которая занимает больше 3 байт.

struct OutlineExample: Existential {
    let id: Int = 0xA
    let hash: Int = 0xB
    let sum: Int = 0xC
    let age: Int = 0xD
    let code: Int = 0xE
}

Для нее будет создан existential container в первом бите которого будет указатель на объект с данными. Такой existential container называется outline existential container и в памяти выглядит так:

Схематичное изображение полей outline existential container
Схематичное изображение полей outline existential container

В первом байте лежит указатель на объект в куче, который выглядит так:

Схематичное изображение полей объекта в куче с данными структуры
Схематичное изображение полей объекта в куче с данными структуры

Мы разобрались с тем, где и как хранятся поля структуры внутри existential container. Далее перейдем к следующему компоненту контейнера - к метаданным типа и value witness table.

Value witness table

Посмотрим на значение в 4-м байте - здесь лежит указатель на метаданные типа и в одном из полей интересующая нас структура Value witness table (VWT). С ее помощью описывается, как работать со значениями конкретного типа на низком уровне - как создавать, копировать, уничтожать значения и какой размер у этого типа.

VWT состоит из следующих полей, мы рассмотрим только первые 6 из них.

  1. Указатель на функцию инициализации

  2. Указатель на функцию деинициализации

  3. Указатель на функцию инициализации из копии initializeWithCopy

  4. Указатель на функцию присваивания с копированием assignWithCopy 

  5. Указатель на функцию инициализации с переносом initializeWithTake

  6. Указатель на функцию присваивания с переносом assignWithTake 

  7. Указатель на функцию получения тега из перечисления (enum) getEnumTagSinglePayload

  8. Указатель на функцию для сохранения тега StoreEnumTagSinglePayload

  9. Размер необходимый для хранения одного объекта этого типа Size

  10. Размер каждого элемента этого типа в масиве Stride

  11. Служебные флаги

  12. Кол-во дополнительных полей в типе ExtraInhabitantCount

Общий смысл всех этих функций в выполнении каких-то операций в рантайме. Например, в примере кода для копирования переменной fixed в another компилятор генерирует вызов рантайм функция initializeBufferWithCopyOfBuffer.

struct Fixed: Existential {
    var num: Int
    var arr: [Int]
}

func copy(fixed: Existential) {
    let another = fixed
}

Она принимает три аргумента: existential container в который копировать, из которого копировать и указатель на метаданные типа, а возвращает указатель на existential container с новыми данными. Внутри функции данные из одного existential container копируются в другой.

Совсем другое дело если внутри inline existential container лежат примитивные типы данных, как у структуры ниже:

struct FixedInline: Existential {
    var num: Int
    var num1: Int
    var num2: Int
}

func primitiveCopy(fixed: Existential) {
    let another = fixed
}

Здесь для копирования используется один из вариантов функции __swift_memcpy. Она принимает такие же аргументы и копирует поля из одного контейнера в другой. Например, для структуры из 3 полей компилятор создаст функцию с названием __swift_memcpy24_8, где 24 - размер структуры в битах, а 8 - выравнивание данных.

В процессе деинициализации existential container вызывается функция из value witness table. Обозначим эту функцию как destroy. В зависимости от конкретного типа внутри existential container будет сгенерирована соответствующая реализация этой функции. Рассмотрим еще раз такую структуру: 

struct Fixed: Existential {
    var num: Int
    var arr: [Int]
}

func copy(fixed: Existential) {
    let another = fixed
}

Здесь в конце функции copy компилятор генерирует вызов destroy из value witness table для типа FixedInline. Функция принимает два аргумента - объект для деинициализации и метаданные типа этого объекта. Внутри этого варианта функции destroy будем вызван деинициализатор для массива в поле arr. Это пример того, как деициниализируется inline existential container содержащий хотя бы один нетривиальный тип.

Если внутри inline контейнера все типы тривиальные, то будет сгенерирована и вызвана пустая функция-заглушка __swift_noop_void_return, Например, для такой структуры

struct FixedTrivial: Existential {
    var num: Double
    var index: Int
    var primary: Int
    var secondary: Int
}

Здесь компилятору ничего не нужно делать, ведь все поля хранятся в стеке и у них нет своих деинициализаторов.

Немного иначе дела обстоят с noninline existential container. Здесь для деинициализации вызывается функция swift_release, которая уменьшает счетчик ссылок внутри объекта с полями в куче на единицу.

struct FixedTrivial: Existential {
    var num: Double
    var index: Int
    var primary: Int
    var secondary: Int
}

Мы посмотрели на несколько вариантов деинициализации existential container и далее перейдем к следующей функции initializeWithCopy из value witness table. Рассмотрим такой пример:

func testInitWithCopy() {
    var test: TestExternalProto = ResilientWeakRef(Referent())

    var testOne = test
}

Структура ResilientWeakRef определена в другом модуле, который собран в режиме library evolution. В этом режиме публичный тип ResilientWeakRef считается устойчивым (resilient) и компилятор не может сгенерировать код прямой инициализации. Вместо этого он генерирует вызов initializeWithCopy из VWT конкретного типа. Эта функция принимает destination existential container, source existential container и метаданные типа в качестве аргументов и копирует один existential container в другой.

struct Test: TestExternalProto {    
    var ref: MyTestFramework.Referent?
    var num: Int = 5
    var double: Double = 2.0
    var str: String = "Test string"
}

func testInitWithTake(test: inout TestExternalProto) {
    var existential: any TestExternalProto = Test()

    existential = test
}

Функция initializeWithTake вызывается для создания нового existential container при помощи уже существующего.

Компилятор генерирует функцию assignWithTake если есть хотя бы одно поле нетривиального типа. Она перемещает значение из destination existential container в source existential container. После вызова этой функции destination existential container остается в невалидном состоянии и его больше нельзя использовать. По сути это move семантика внутри Swift.

Где и когда будет сгенерирован оставшийся из всех вызов assignWithCopy я так и не смог понять. Либо я что-то упустил, либо компилятор уже не генерирует ее вызов.

Мы рассмотрели разные функции из value witness table, обсудили разные варианты инициализации и деинициализации existential container и копирования одного в другой. Теперь познакомимся со следующим компонентом в existential container - с protocol witness table.

Protocol witness table

Protocol witness table (PWT) - это рантайм структура данных, которая содержит указатели на witness function - то есть на реализацию конкретного метода из протокола. Компилятор генерирует protocol witness table для каждой структуры, класса или перечисления, которые реализуют хотя бы один протокол. Например, для структуры Test, которая реализует протокол Existential компилятор сгенерирует protocol witness table:

protocol Existential {
    func firstFunction()
    func secondFunction()
    func thirdFunction()
}

struct Test: Existential {
    func firstFunction() { }
    func secondFunction() { }
    func thirdFunction() { }
}

В памяти protocol witness table для Existential у типа Test будет выглядеть так:

В первом поле лежит protocol conformance descriptor. Мы не будем глубоко рассматривать эту сущность. Нас больше интересуют указатели на реализацию (witness function) конкретных методов.

Для вызова нужного метода протокола компилятор вычислит смещение указателя на этот метод относительно начала protocol witness table и сгенерирует вызов соответствующей реализации. Так реализовал вызов метода протокола у всех соответствующих этому протоколу типов - это virtual table для протоколов.

Далее коротко сравним реализацию generics и protocol witness table.

Отличия от дженериков

Generics, в отличии от existential container, в теории использую статическую диспетчеризацию методов. Тип дженерика известен во время компиляции, тогда как конкретный тип в existential container скрыт от нас.

Для дженерик функций компилятор генерирует конкретную реализацию метода для разных типов. Это происходит только при сборке с оптимизацией по скорости (-O) и если функция не встроенная (non inline).

В тоже время комплиятор не знает про конкретный тип внутри existential container и будет использовать PWT для динамического вызова методов протокола. То есть existential container по своей сути динамический.

Existential container требует выделения памяти в куче, если размер значения больше 3-х байт. Для дженериков такой необходимости нет, если компилятор может сгенерировать реализацию функции для разных типов (specialized implementation). Но это в свою очередь может привести к увеличению размера бинарного файла.

Компилятор также может использовать value buffer и PWT для неспециализированных дженериков. В такой случае поведение будет похоже на existential container.

Выводы

Мы разобрались для чего нужен existential container и как он выглядит в памяти. Затем познакомились с value witness table и разобрали несколько функций из нее. Далее коротко обсудили protocol witness table и диспетчеризацию методов протокола. В конце разобрали отличия generics от existential container. Рекомендуется использовать дженерики по умолчанию, если нет необходимости в динамическом определении типа. Например, в массиве хранить конкретный тип, а не тип протокола. Надеюсь, что эта статья поможет глубже разобраться в природе existential container.

Видео про existential container с WWDC

Комментарии (0)


  1. Bardakan
    23.09.2025 12:39

    В swift есть механизм copy-on-write, и на Хабре есть отдельная статья о том, что структуры при обычном присваивании переменной НЕ копируются. Ссылочные типы по умолчанию не копируются вообще - вам нужно принудительно создавать их копию.

    Тогда откуда у вас взялись какие-то там copy-подобные вызовы?