Development for iOS
Development of mobile applications
Swift
May 14

Swift under the hood: Generic implementation

Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define. You can write code that avoids duplication and expresses its intent in a clear, abstracted manner. — Swift docs

Каждый, кто писал на Swift использовал дженерики. Array, Dictionary, Set — самые базовые варианты использования дженериков из стандартной библиотеке. Как они представлены внутри? Расмотрим, как данная основополагающая возможность языка реализована инженерами Apple.


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


Для реализации дженериков Swift использует два подхода:


  1. Runtime-way — generic код является обёрткой (Boxing).
  2. Compiletime-way — generic код преобразуется в код конкретного типа для оптимизации (Specialization).

Boxing


Рассмотрим простой метод с неограниченным протоколом дженериковым параметром:


func test<T>(value: T) -> T {
    let copy = value
    print(copy)
    return copy
}

Компилятор swift создаёт один единственный блок кода, который будет вызываться для работы с любым <T>. То есть, независимо от того, напишем мы test(value: 1) или test(value: "Hello"), будет вызван один и тот же код и дополнительно в метод будет передана информация о типе <T>, содержащая в себе всё необходимое.


Мало что можно сделать с такими неограниченными протоколами параметрами, но, уже для реализации этого метода, необходимо знать, как копировать параметр, необходимо знать его размер, чтобы выделить под него память в рантайме, необходимо знать, как его уничтожать, когда параметр уходит из области видимости. Для хранения этой информации используется Value Witness Table (VWT). VWT создаётся на этапе компиляции для всех типов и компилятор гарантирует, что в рантайме будет именно такой лэйаут объекта. Напомню, что структуры в Swift передаются по значению, а классы по ссылке, поэтому для let copy = value при T == MyClass и T == MyStruct будут сделаны разные вещи.


Value witness table

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


// Приблизительный псевдокод, параметр metadata добавляется компилятором
let myStruct = MyStruct()
test(value: myStruct, metadata: MyStruct.metadata)

Вещи становятся чуть сложнее, когда MyStruct сама является дженериковой структурой и принимает вид MyStruct<T>. В зависимости от <T> внутри MyStruct, метаданные и VWT будут разными для типов MyStruct<Int> и MyStruct<Bool>. Это два разных типа в рантайме. Но создавать метаданные для каждой возможной комбинации MyStruct и T крайне неэффективно, поэтому Swift идёт другим путём и для таких случаев конструирует метаданные в рантайме на ходу. Компилятором создаётся один metadata pattern для дженериковой струкруты, который можно комбинировать с конкретным типом и, в итоге, получать полную информацию по типу в рантайме с корректной VWT.


// Опять же псевдокод, параметр metadata добавляется компилятором
func test<T>(value: MyStruct<T>, tMetadata: T.Type) {
    // Комбинируем информацию и получаем итоговые метаданные
    let myStructMetadata = get_generic_metadata(MyStruct.metadataPattern, tMetadata)
    ...
}

let myStruct = MyStruct<Int>()
test(value: myStruct) // Исходный код
test(value: myStruct, tMetadata: Int.metadata) // Примерно вот в это компилируется

Когда мы комбинируем информацию, мы получаем метаданные, с которыми можно работать (копировать, перемещать, уничтожать).


Всё ещё немного сложнее, когда на дженерики добавляются ограничения в виде протоколов. К примеру, ограничим <T> протоколом Equatable. Пусть это будет очень простой метод, который сравнивает два переданных аргумента. Получится просто обёртка над методом сравнения.


func isEquals<T: Equatable>(first: T, second: T) -> Bool {
    return first == second
}

Для правильной работы программы необходимо иметь указатель на метод сравнения static func ==(lhs:T, rhs:T). Как его получить? Очевидно, что передачи VWT не хватит, она не содержит этой информации. Для решения этой проблемы существует Protocol Witness Table или PWT. Эта табличка похожа на VWT и создаётся на этапе компиляции для протоколов и описывает эти протоколы.


isEquals(first: 1, second: 2) // Исходный код

// Примерно во что превращается
isEquals(first: 1, // 1
         second: 2,
         metadata: Int.metadata, // 2
         intIsEquatable: Equatable.witnessTable) // 3

  1. Передаются два аргумента
  2. Передаём метаданные для Int, чтобы можно было копировать/перемещать/уничтожать объекты
  3. Передаём информацию и том, что Int реализует Equatable.

Если бы ограничение требовало реализации ещё одного протокола, к примеру, T: Equatable & MyProtocol, то информация о MyProtocol добавилась бы следующим параметром:


isEquals(...,
        intIsEquatable: Equatable.witnessTable,
        intIsMyProtocol: MyProtocol.witnessTable)

Использование обёрток для реализации дженериков позволяет гибко реализовывать все необходимые фичи, но имеет оверхед, который можно оптимизировать.


Специализация дженериков


Чтобы устранить излишнюю необходимость получения информации во время выполнения программы, был использован так называемый подход специализации дженериков. Он позволяет заменить дженериковую обёртку конкретным типом с конкретной реализацией. К примеру, для двух вызовов isEquals(first: 1, second: 2) и isEquals(first: "Hello", second: "world"), помимо основной "обёрточной" реализации, будут скомпилированы две дополнительные абсолютно разные версии метода для Int и для String.


Исходный код


Для начала создадим generic.swift файл и напишем небольшую generic функцию, которую будем рассматривать.


func isEquals<T: Equatable>(first: T, second: T) -> Bool {
    return first == second
}
isEquals(first: 10, second: 11)

Теперь необходимо понять, во что это в итоге превращается компилятором.
Это можно наглядно посмотреть, скомпилировав наш .swift файл в Swift Intermediate Language или SIL.


Немного о SIL и процессе компиляции


SIL является результатом одним из нескольких этапов компиляции swift.


Compiler pipeline

Исходный код .swift передаеётся Lexer, который создаёт абстрактное синтаксическое дерево (AST) языка, на основе которого проводится проверка типов и семантический анализ кода. SilGen преобразует AST в SIL, называемый raw SIL, на основе которого происходит оптимизация кода и получается оптимизированный canonical SIL, который передаётся в IRGen для преобразования в IR — специальный формат, понятный LLVM, который будет преобразован в `.oфайлы, собранные под конкретную архитектуру процессора. Во что превращается наш дженериковый код можно посмотреть как раз на этапе созданияSIL`.


И снова к дженерикам


Создадим SIL файл из нашего исходного кода.


swiftc generic.swift -O -emit-sil -o generic-sil.s

Получим новый файл с расширением *.s. Заглянув вовнутрь, мы увидим гораздо менее читаемый код, чем исходный, но, всё равно, относительно понятный.


Raw SIL

Найдём строку с комментарием // isEquals<A>(first:second:). Это и есть начало описания нашего метода. Заканчивается он комментарием // end sil function '$s4main8isEquals5first6secondSbx_xtSQRzlF'. У вас название может немного отличаться. Немного разберём описание метода.


  • %0 и %1 на 21 строке являются first и second параметрами соответственно
  • На 24 строке получаем информацию о типе и передаём в %4
  • На 25 строке получаем указатель на метод сравнения из информации о типе
  • на 26 строке Вызываем метод по указателю, передавая ему оба параметра и информацию о типе
  • На 27 строке отдаём результат.

В итоге мы видим: чтобы выполнить необходимые действия в реализации дженерикового метода, нам нужно во время выполнения программы получать информацию из описания типа <T>.


Перейдём непосредственно к специализации.


В скомпилированном SIL файле сразу после объявления общего метода isEquals следует объявление специализированного для типа Int.



Specialized SIL

На 39 строке вместо получения метода в рантайме из информации о типе сразу вызывается метод сравнения целых чисел "cmp_eq_Int64".


Чтобы метод "специализировался", необходимо включить оптимизацию. Также, необходимо знать, что


The optimizer can only perform specialization if the definition of the generic declaration is visible in the current Module (Источник)

То есть, метод не может быть специализирован между разными модулями Swift (например, дженериковый метод из Cocoapods библиотеки). Исключением является стандартная библиотека Swift, в которой такие базовые типы, как Array, Set и Dictionary. Все дженерики базовой библиотеки специализируются в конкретные типы.


Note: В Swift 4.2 были реализованы аттрибуты @inlinable и @usableFromInline, которые позволяют оптимизатору видеть тела методов из других модулей и вроде как есть возможность их специализировать, но данное поведение мной не тестировалось (Источник)


Ссылки


  1. Описание дженериков
  2. Оптимизация в Swift
  3. Более подробная и глубокая презентация по теме
  4. Статья на английском
+6
1.1k 30
Leave a comment