Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В первой статье рассказали, почему нам потребовалась автоматическая кодогенерация свифтового интерфейса для C++ в Mobile SDK. Описали инструменты, которые есть в нашем распоряжении, и сделали вывод: лучший промежуточный слой для преобразования на сегодняшний день — это C.
Там же рассмотрели существующие решения: встроенный режим совместимости с C++, Scapix, Gluecodium.
Отмечу, что работа над прямым взаимодействием с C++ продолжается, следите на форумах. Как только эта возможность станет зрелой, именно использование C++, а не C, станет самым эффективным решением.
В первой части описали ключевые принципы, которыми должен обладать генератор интерфейсов. Повторю наиболее важные:
Интерфейсы на C++ на входе.
Идиоматичный Swift на выходе.
Минимизация накладных расходов. Пути к оптимизации не отсекать.
Возможность поэтапного внедрения и взаимодействия с Objective-C++.
Поскольку полностью аналогичные требования существуют и для 2GIS Mobile SDK для Kotlin, инструмент должен уметь работать с обоими языками поверх общей платформы.
Мы примерили наши требования к существующим решениям и сделали своё. О нём и пойдёт речь в этой статье.
Свой велосипед — Codegen
Наше решение называется Codegen. В наш век максимально изобретательных имён проектов на Гитхабе пусть прозвучит постно, однако все всегда понимают, о чём речь.
Поддерживает Swift и Kotlin
Мы поставляем SDK в двух наиболее популярных вариантах: для iOS и Android. При этом создаём продукт не по кальке, а с учётом особенностей каждого из языков.
Например, Swift гордится своими перечислениями с ассоциированными значениям. В Kotlin такой способности нет. Поэтому для Swift std::variant
превращается в привычный enum
, а для Kotlin применяется альтернативное решение.
На выходе — публичный и внутренний интерфейс
Цель кодогенератора — создать интерфейс на Swift на основе интерфейсов на С++. Часть этого интерфейса может подходить пользователям без какой-либо доработки. Чем больше эта часть, тем лучше — это повышает степень автоматизации, ускоряет разработку. Поэтому кодогенератор разрабатывается так, чтобы как можно большая часть интерфейса становилась автоматически публичной.
Части сгенерированного интерфейса, требующие доработки, пользователям недоступны. Они образуют внутренний интерфейс. Поверх них разработчики SDK вручную создают недостающий публичный интерфейс (поверх интерфейса на Swift, а не C++). В Mobile SDK сюда попадает небольшая часть API — несколько процентов.
Например, карта на C++ ничего не знает об UIView. Но каждый пользователь UIKit ожидает именно UIView с отрисованной картой. В этом месте на Swift пишется реализация UIView-карты, а в момент рисования используется внутреннее C++ API.
Codegen учитывает командные соглашения о написании кода
Вообще говоря, превратить C++-интерфейс в Swift можно по-разному. В самом общем виде это огромная задача масштаба всего компилятора — над чем и работает группа C++ Interoperability.
В нашем случае интерфейсы подчиняются определённым правилам и имеют строгий вид. Эти правила вносят множество ограничений и добавляют много информации, поэтому мы можем решать не общую задачу, а узкоспециализированную.
На какие вопросы дают ответы правила написания кода:
Как могут называться типы, функции методы?
Чем отличается тип-значение от ссылочного типа?
Как работать со ссылочные типами?
Как гарантировать незануляемость указателей?
Возможны ли ошибки/исключения и являются ли они частью интерфейса?
Какие возможны иерархии наследования?
Какие типы используются для предметных задач: сырые данные, асинхронность, JSON?
Эти правила строят примерную общую модель всего API, которую можно использовать для перестроения в Swift и Kotlin.
Минимизация накладных расходов
Используется C как самый эффективный промежуточный слой — позволяет не создавать лишних символьных данных и не препятствует оптимизациям (подробнее об этом — в первой части).
Поэтапное применение
Codegen не вынуждает разработчиков переформатировать весь проект под него. Новый интерфейс добавляется в проект с помощью файлов, задающих, какие типы нужно подключить к процессу генерации. Выглядит это так:
namespace dgis_bindings::directory
{
using dgis::directory::Attribute;
using dgis::directory::ContactInfo;
using dgis::directory::DirectoryFilter;
using dgis::directory::DirectoryObjectId;
using dgis::directory::FormattedAddress;
using dgis::directory::FormattingType;
using dgis::directory::IsOpenNow;
}
Генерация произойдёт только с типами, перечисленными в пространстве dgis_bindings
. Это значит, что в Swift-интерфейсе появятся типы Attribute
, ContactInfo
и другие (структуры, классы, перечисления). Все эти типы станут по умолчанию публичными, так как нет указания сделать их внутренними.
Явное перечисление компонентов позволяет точно настроить генерацию: попадают только те объекты, которые должны попасть в Swift. Переход на использование генератора можно делать с точностью до типа или функции.
Существующий код на Obj-C беспрепятственно работает с промежуточным кодом на C. Это позволяет работать со сгенерированным кодом из обоих миров без атрибутов
Тестовое покрытие
Естественно, генератор покрыт тестами. Тесты проверяют алгоритмы преобразования C++-интерфейсов в Swift.
Что проверяется:
Компилируемость полученного результата.
Правильная работа сгенерированного кода.
Отсутствие утечек ресурсов при преобразованиях.
Скорость работы проверяется совместно с кодом SDK, однако было бы правильно расширить тесты и на эти проверки.
Ручное создание интерфейса подразумевало бы написание тестов на каждый кусочек API. Тесты на генератор дают системную проверку качества обработки всех эквивалентных частичек API.
Избежание компромисса
Самая важная особенность собственного решения: codegen использует плюсы и исключает недостатки существующих решений (см. пункт «Образ идеальной автоматизации» в первой части).
Архитектура Codegen
Техническая основа работы с C++ напрямую — ClangTool. Этот инструмент использует компиляторный фронтенд Clang и позволяет работать с кодом на С++ в виде конкретизированных структур данных. Без него трудно было бы вообразить, что работать с С++ на входе может быть сколько-нибудь рентабельно.
Интерфейсы на C++ подаются в ClangTool. Это даёт модель интерфейса в терминах Clang AST (дерева абстрактного синтаксиса). После этого наши утилиты преобразуют AST в общую для целевых языков абстрактную модель. В нашем случае речь о Swift и Kotlin.
Эта модель — отражение того, что строят С++-интерфейсы, но пока это грубое приближение того, что получим на Swift.
На этом этапе уже делаются некоторые преобразования, общие для Swift и Kotlin:
Переименования типов, функций, полей и методов из С++.
Добавление новых полей на основе многофункциональных сущностей.
Превращения функций во вспомогательные конструкторы и методы расширений.
Выделение «свойств» среди групп геттеров и сеттеров (в самом С++ нет такой штуки).
Переписывание комментариев.
Стандартные шаблонные сущности записываются не в терминах конкретных типов, а концептуально:
std::optional<T> — Optional<T>
;std::vector<T> — Array<T>
;std::unordered_map<T> — Map<T>
;pc::future<T> — Future<T>
.
Дальнейшее имеет отношение только к Swift. С этого места Kotlin-генератор использует абстрактную модель по другому пути.
На основе абстрактной модели строится аннотированная С-модель.
Это специализированная модель, которая описывает нужные интерфейсы с учётом особенностей C. Иначе говоря, описываем интерфейс, доступный из чистого C.
Какие у С особенности:
Все методы являются свободными функциями.
Неявный параметр this метода становится первым параметром обычной функции.
Все типы или примитивны (например, числа), или являются структурами.
Все шаблонные типы должны быть инстанциированы (
vector<int>
иvector<string>
— теперь совершенно разные типы).Новые типы в тексте должны быть предварительно объявлены.
Возможность возврата ошибки (т.е.
throws
в Swift) означает, что функция может вернуть значение более чем одним способом.Для типов с конструкторами необходимы парные функции-деструкторы.
Все внутренние С++-типы должны быть скрыты с помощью инкапсуляции.
О чём говорит аннотированность модели? Хотя она и описывает интерфейс в терминах C, у сущностей остаются дополнительные пометки из изначальной абстрактной модели.
Например, std::vector<std::string>
превращается в абстрактный Array<String>
. В C это становится типом CArray_CString
— самостоятельный тип, не имеющий ничего общего с CArray_int32_t
. Но в модели сохраняется пометка, что CArray_CString
— это концептуальный Array. Эта пометка ещё пригодится в будущем при пробросе данных в Swift.
Процесс следует дальше.
На основе С-модели пишется текст С-интерфейса. Это совершенно прямолинейный процесс, так как в модели содержатся все необходимые типы, функции и комментарии в нужном порядке. Всё содержимое сваливается в один файл CInterface.h (преимущества нескольких файлов обнаружено не было). Этот файл целиком задаёт интерфейс модуля и может быть импортирован в Swift.
Создаются два вспомогательных файла: внутренний интерфейс CInterfacePrivate.h и реализация CInterface.cpp.
Внутренний интерфейс CInterfacePrivate.h написан на C++, содержит определения структур, включающих в себя C++-типы.
Файл CInterface.cpp включает реализации всех функций из C-интерфейса. Именно через эти точки сможет код на Swift напрямую вызвать функцию из С++.
Повторяющиеся действия из CInterface.cpp вынесены в библиотеку поддержки
c_support
. Библиотека написана с использованием шаблонов, тем самым позволяет до минимума снизить объём генерируемого кода. Таким образом механические действия по вызову конкретных функций, инициализации структур и перечисления аргументов содержатся в .cpp-файле; а содержательный код преобразования ключевых типов вынесен вc_support
, используется отовсюду и шлифуется отдельно до блеска. Так мы обеспечиваем генерацию только очень простого однообразного кода.Завершающий элемент — modulemap-файл. Он тоже генерируется, хотя в этой части вообще ничего примечательного нет. Файл необходим, чтобы включить модуль в систему компиляции Swift.
Получили полноценный C-интерфейс для нашего C++-SDK. Полный состав:
CInterface.h
module.modulemap
CInterfacePrivate.h
CInterface.cpp
Следом поверх аннотированной C-модели строится свифтовая модель. В этом месте ключевую роль сыграют аннотации, позволяющие вернуть разрозненным сущностям из C-интерфейса типизацию на основе стандартной библиотеки Swift.
Например, CArray_CString
и CArray_int32_t
превращаются в [String]
и [Int32]
. На выходе получили родственные типы.
Сгенерированный на следующем шаге свифтовый код помещается в файл SwiftInterface.swift. Это реализация всех описанных функций и типов поверх импортированных из модуля CInterface C-функций и C-типов.
Как и на прошлом шаге, весь общий код вынесен в отдельную библиотечку поддержки SwiftSupport. Вновь в силе правило: генерируется только простой однообразный код. Все тонкости и улучшения преобразований разрабатываются в SwiftSupport, упрощая ход генерации до предела.
Почему Swift-модель не делается на основе абстрактной модели? Из-за тесной связи с C-моделью. Поскольку преобразование в C вводит новые сущности, размножает шаблонные типы, нужно генерировать кусочек свифтового кода поверх каждого элемента C-интерфейса.
На этом заканчивается описание архитектуры.
Вернёмся к примеру из первой части (см. пункт «Вариантный тип»). Вариантный тип с int
или std::string;,
но на этот раз покажем результат для C.
// C++
using Scalar CODEGEN_FIELD_NAMES(integer, string) = std::variant<int, std::string>;
// Swift
public enum Scalar {
case integer(Int32)
case string(String)
}
// C
typedef struct CScalar CScalar;
struct CScalar
{
union CScalarImpl {
int32_t integer;
CString string;
} impl;
uint8_t index;
};
void CScalar_release(CScalar self);
В деле сразу несколько соображений.
Union используем для оптимального хранения различных вариантов — это может быть целое число, это может быть строка. Одновременно может быть только что-то одно, поэтому память может (и должна) пересекаться.
Index используется, чтобы отличать, какое значение можно прочесть.
CScalar_release
— это вспомогательная функция — деструктор полученного значения. Так как CString содержит выделенный ресурс, который нужно освободить после использования значения, а CScalar может быть строкой, то CScalar_release также необходимо вызывать для уничтожения сохранённого значения. Если бы внутри были только примитивные типы, то необходимость в такой функции отпала бы.
Видно, что представление для std::variant
чрезмерно сложное для ручной реализации, нужно преследовать соображения эффективности и корректности данных. Если захочется сделать что-то более эффективное, то достаточно будет обновить алгоритм генератора, а не все реализации и тесты.
Далее опишу ход генерации и систему типов более подробно.
Как устроена система типов
Опишем подробно состав системы типов модели.
Есть примитивные типы:
Целые (
int8_t, int32_t, uint64_t, bool,
…).Плавающие (
float, double, long double
).void
.
Составные типы:
Optional (
std::optional
).Array (
std::vector, std::array
).Dictionary (
std::map, std::unordered_map
).Set (
std::set, std::unordered_set
).
Прочие базовые типы (не обязательно стандартные):
Строка (
std::string, std::string_view
).Сырые данные (
std::vector<std::byte>
).Временные (
std::chrono::duration, std::chrono::time_point
).OptionSet — битовая маска.
JSON (
rapidjson::GenericValue
).Future — отложенное значение (
portable_concurrency::future
).Channel / BufferedChannel / StatefulChannel — поток значений во времени (
channels::channel
и др.).
Сложные типы на основе базовых:
Struct — значение с полями данных.
Class — ссылочный тип с методами и свойствами.
Enum — простое перечисление и с ассоциированными значениями.
Protocol — доступный для реализации пользователем интерфейс.
Особые типы:
Any — произвольное значение.
Empty — отсутствие значения (случай сложного enum без ассоциированного значения).
Error — ошибка (например, из throws-функции).
Void | Bool | Int… / UInt... | Float / Double |
Struct | Enum | Class | Protocol |
Optional | Array | Dictionary | Set |
String | Data | TimeInterval | Date |
OptionSet | JSON | Future | Channel... |
Any | Error | Empty |
Также существуют свободные функции и методы расширений.
Any и Protocol
Это единственные типы в текущей системе, которые позволяют передать свифтовый объект из Swift в С++ и сохранить его в C++ на неопределённое время.
В Protocol это позволяет реализовать интерфейс на Swift и вызывать реализацию в коде на C++.
Any позволяет принять в С++ произвольный объект и вернуть обратно в Swift без изменений.
Во всех остальных случаях типы перекодируются в собственные типы C++.
Шаблонные типы
Параметризуются типом: Vector<T>
, Optional<T>
. В С ничего подобного нет. Все типы на основе шаблонов С++ должны быть представлены индивидуально. Рассмотрим пример с необязательной строкой и геоточкой — std::optional<std::string>
и std::optional<GeoPoint>
на входе.
// std::optional<std::string>
typedef struct COptional_CString COptional_CString;
struct COptional_CString
{
CString value;
bool hasValue;
};
// std::optional<GeoPoint>
typedef struct COptional_CGeoPoint COptional_CGeoPoint;
struct COptional_CGeoPoint
{
CGeoPoint value;
bool hasValue;
};
Строка кодируется с помощью CString
(C-тип). Тогда необязательный CString
можно представить как значение в паре с флагом наличия значения. Можем читать value
только тогда, когда hasValue
— true
.
Аналогично GeoPoint
— это простая структура, описывающая координаты на карте. Мы точно так же подставляем GeoPoint
и можем читать его, только если hasValue
равно true
.
У двух полученных типов нет ничего общего с точки зрения C.
Далее эти типы приходят в Swift. Рассмотрим COptional_CString
.
extension Optional where Wrapped == String {
fileprivate init(_ c: COptional_CString) {
self = Self(_cOptional: c)
}
}
extension COptional_CString: _FromSwiftConvertible {
init(_ s: String?) {
self.init(_optional: s)
}
}
Расширение на Optional позволяет связать конкретный тип COptional_CString
с обобщённым типом Optional. Добавляется конструктор из C-значения.
Конструкторы с подчёркиванием (init(_optional:)
и init(_cOptional:)
) находятся в библиотеке SwiftSupport со всеми базовыми преобразованиями.
Чтобы преобразовывать значения в обратную сторону, из Swift в С++, нужен отдельный набор функций.
extension COptional_CString: _COptionalProtocol {
typealias _CWrapped = CString
typealias _SwiftType = String?
static func _create(_ cValue: CString) -> COptional_CString {
COptional_CString(value: cValue, hasValue: true)
}
static func _create(_: Never?) -> COptional_CString {
COptional_CString()
}
func _releaseIntermediate() {
COptional_CString_release(self)
}
func _makeSwiftValue() -> String? {
String?(self)
}
}
Функции _create
создают новое значение в C-формате.
Функция _releaseIntermediate
удаляет промежуточное значение, если оно занимает ресурсы. Явные вызовы в коде необходимы, чтобы восполнить отсутствие ARC (или аналога) в C.
Array
Пример списка отличается от Optional. Так выглядит интерфейс std::vector<Color>
на C.
// std::vector<Color>
typedef struct CArray_CColor CArray_CColor;
struct CArray_CColor
{
struct CArray_CColorImpl * _Nonnull impl;
};
CArray_CColor CArray_CColor_createWithItems(
size_t size,
void (CS_NOESCAPE ^ _Nonnull next)(void (CS_NOESCAPE ^ _Nonnull add)(CColor))
);
void CArray_CColor_release(CArray_CColor self);
size_t CArray_CColor_getSize(CArray_CColor self);
void CArray_CColor_forEach(
CArray_CColor self,
void (CS_NOESCAPE ^ _Nonnull nextItem)(CColor item)
);
В интерфейсе используется нестандартное расширение — C-блоки. Это позволило сократить интерфейс, но не было необходимостью. В контексте работы со Swift их использование ничем не ограничено (работают в том числе под Линуксом).
Важное отличие этого примера от Optional в том, что если у Optional всё открыто кодировалось структурой с целевым типом, то в случае списка хотим скрыть способ хранения элементов. Это позволяет реализовать список целиком на C++.
Поле impl
типа CArray_CColorImpl
представляет указатель на скрытую реализацию. Чтобы безопасно передать значение, нужно аллоцировать его в памяти при создании значения и уничтожить с помощью функции CArray_CColor_release
после использования.
Этот набор функций позволяет выполнить все необходимые преобразования из C++ в Swift и обратно.
Структуры
Под структурами в модели codegen мы понимаем типы данных с семантикой значения.
По нашему внутрикомандному соглашению, в C++-структурах содержатся доступные снаружи хранимые поля. Структура полностью эквивалентна другой структуре того же типа (и с теми же значениями полей). Говоря иначе, любая структура может быть воссоздана точным перечислением её содержимого. Этот факт используется при генерации, так как структура копируется при пересечении границы Swift/C++.
Это очень простые типы. Для них прямолинейно генерируется сравнимость (Equatable
) и хешируемость (Hashable
).
Пример структуры в С++:
struct Address
{
std::vector<AdminDivision> drill_down;
std::vector<AddressComponent> components;
std::optional<std::string> building_name;
std::optional<std::string> post_code;
std::optional<std::string> building_code;
std::optional<std::string> address_comment;
};
В C превращается переписыванием всех полей и добавлением деструктора:
typedef struct CAddress CAddress;
struct CAddress
{
CArray_CAddressAdminDivision drillDown;
CArray_CAddressComponent components;
COptional_CString buildingName;
COptional_CString postCode;
COptional_CString buildingCode;
COptional_CString addressComment;
};
// Необходим деструктор, так как обладает полями с деструкторами.
void CAddress_release(CAddress self);
Деструктор необходим для вызова соответствующих деструкторов некоторых полей.
В Swift:
public struct Address: Hashable {
public var drillDown: [AddressAdmDiv]
public var components: [AddressComponent]
public var buildingName: String?
public var postCode: String?
public var buildingCode: String?
public var addressComment: String?
public init(
drillDown: [AddressAdmDiv],
components: [AddressComponent],
buildingName: String?,
postCode: String?,
buildingCode: String?,
addressComment: String?
) {
self.drillDown = drillDown
self.components = components
self.buildingName = buildingName
self.postCode = postCode
self.buildingCode = buildingCode
self.addressComment = addressComment
}
}
// MARK: - Address <-> CAddress
extension Address {
fileprivate init(_ c: CAddress) {
self.init(
drillDown: c.drillDown._makeSwiftValue(),
components: c.components._makeSwiftValue(),
buildingName: c.buildingName._makeSwiftValue(),
postCode: c.postCode._makeSwiftValue(),
buildingCode: c.buildingCode._makeSwiftValue(),
addressComment: c.addressComment._makeSwiftValue()
)
}
}
extension CAddress: _FromSwiftConvertible {
init(_ s: Address) {
self.init(
drillDown: CArray_CAddressAdmDiv(s.drillDown),
components: CArray_CAddressComponent(s.components),
buildingName: COptional_CString(s.buildingName),
postCode: COptional_CString(s.postCode),
buildingCode: COptional_CString(s.buildingCode),
addressComment: COptional_CString(s.addressComment)
)
}
}
Помимо полей с преобразованными типами, добавляется поэлементный конструктор и преобразования из или в C-тип.
Генерация обоих преобразований заключается в вызове конструктора целевой структуры, инициализируя каждое поле его преобразованным значением.
Future и Channel
Для типов Future и Channel нужны особые соображения. В Swift модель асинхронности на уровне языка появилась недавно. До этого выразить асинхронное вычисление можно было через передачу замыкания в функцию.
Тем не менее, в наших C++-интерфейсах используются конкретные решения:
portable_concurrency::future
— для единственного отложенного значения (https://github.com/VestniK/portable_concurrency);channels::channel
— для потока произвольного количества значений во времени (https://github.com/rikdev/channels).
Чтобы покрыть эти случаи, решили добавить в SwiftSupport новые типы: Future<T>
и Channel<T>
.
Почему сразу не использовать async
из Swift 5.5 и Combine
? Мы хотим поддерживать старые версии платформ. Например, в iOS 12 всё ещё нет Combine
(iOS 13); нет async/await
(iOS 13). Как только мы решим отбросить поддержку старых версий, то сможем перевести генерацию на исключительное использование новых возможностей.
Для многих написание собственной реализации отложенного значения или потока значений на Swift становится развлечением сродни спорту.
В связи с этим мы преследовали две цели:
Позволить легко превращать наши типы в аналогичные типы любой другой системы.
Не дублировать функциональность, которая и так есть на стороне C++-прообразов наших типов.
Итого, мы заводим простейший тип. Не добавляем обвес в виде всевозможных функций (map
, filter
, reduce
, ...). Если необходимо, пользователь превращает наши типы в привычные (будь то Combine, RxSwift, ReactiveSwift
или MeinKuchenChannel
), обладающие всеми стандартными функциями. Наша библиотека не добавит дублирующих реализаций.
Так как
portable_concurrency
иchannels
уже обрабатывают все нюансы, касающиеся многопоточности, безопасности и множественных подписок, наша реализация напрямую транслирует их функциональность. Получаем очень тонкий слой, полную базовую функциональность и минимальный вес.
Расширения для Combine
и популярных библиотек можно поставлять пользователям отдельно.
Классы
У классов семантика ссылочного типа. По нашему внутреннему соглашению, в классах нет хранимых полей, а только методы и вычисляемые свойства (геттеры, сеттеры).
Классам доступно наследование. Так как Swift не допускает множественное наследование, в C++-интерфейсах codegen его также не допускает.
Генерируемые классы не могут быть отнаследованы пользователем. (Для этого существуют протоколы).
Пример класса на C++:
struct IDirectoryObject
{
virtual ~IDirectoryObject() = default;
[[nodiscard]] virtual std::vector<ObjectType> types() const = 0;
[[nodiscard]] virtual std::string title() const = 0;
[[nodiscard]] virtual std::string subtitle() const = 0;
[[nodiscard]] virtual std::optional<DirectoryObjectId> id() const = 0;
};
Это абстрактный интерфейс. Этот тип можно использовать только по ссылке. В нашем случае интерфейсы возвращаются всегда через ссылку, shared_ptr
или unique_ptr
.
В С генерируем подобный объект (комментарии для пояснения и не являются частью процесса генерации):
typedef struct CDirectoryObject CDirectoryObject;
struct CDirectoryObject
{
// CDirectoryObjectImpl хранит std::shared_ptr<IDirectoryObject>.
struct CDirectoryObjectImpl * _Nonnull impl;
};
// Служебные функции.
void CDirectoryObject_release(CDirectoryObject self);
CDirectoryObject CDirectoryObject_retain(CDirectoryObject self);
// Функции — методы.
CArray_CObjectType CDirectoryObject_types(CDirectoryObject self);
CString CDirectoryObject_title(CDirectoryObject self);
CString CDirectoryObject_subtitle(CDirectoryObject self);
COptional_CDirectoryObjectId CDirectoryObject_id(CDirectoryObject self);
void * _Nonnull CDirectoryObject_cg_objectIdentifier(CDirectoryObject self);
Реализация промежуточного объекта хранит shared_ptr
на нужный нам объект. Удерживая временный объект (CDirectoryObject
), класс имеет контроль над временем жизни объекта.
Все методы экземпляра представляются функциями, принимающими self
в качестве первого параметра. Остальные параметры идут следом в том же порядке.
Статические методы тоже поддерживаются, self
они не принимают, работают как свободные функции.
В Swift это выглядит следующим образом:
public final class DirectoryObject: Hashable {
fileprivate let _impl: OpaquePointer
public var types: [ObjectType] {
get {
let _cr: CArray_CObjectType = CDirectoryObject_types(CDirectoryObject(impl: self._impl))
defer {
_cr._releaseIntermediate()
}
return _cr._makeSwiftValue()
}
}
public var title: String {
get {
let _cr: CString = CDirectoryObject_title(CDirectoryObject(impl: self._impl))
defer {
_cr._releaseIntermediate()
}
return _cr._makeSwiftValue()
}
}
public var subtitle: String {
get {
let _cr: CString = CDirectoryObject_subtitle(CDirectoryObject(impl: self._impl))
defer {
_cr._releaseIntermediate()
}
return _cr._makeSwiftValue()
}
}
public var id: DirectoryObjectId? {
get {
let _cr: COptional_CDirectoryObjectId = CDirectoryObject_id(CDirectoryObject(impl: self._impl))
return _cr._makeSwiftValue()
}
}
deinit {
CDirectoryObject(impl: _impl)._release()
}
fileprivate init(_ impl: OpaquePointer) {
self._impl = impl
}
public static func == (lhs: DirectoryObject, rhs: DirectoryObject) -> Bool {
let lhsIdentifier = CDirectoryObject_cg_objectIdentifier(CDirectoryObject(impl: lhs._impl))
let rhsIdentifier = CDirectoryObject_cg_objectIdentifier(CDirectoryObject(impl: rhs._impl))
return lhsIdentifier == rhsIdentifier
}
public func hash(into hasher: inout Hasher) {
let identifier = CDirectoryObject_cg_objectIdentifier(CDirectoryObject(impl: _impl))
hasher.combine(identifier)
}
}
extension CDirectoryObject: _FromSwiftConvertible {
init(_ s: DirectoryObject) {
self = CDirectoryObject(impl: s._impl)._retain()
}
}
extension CDirectoryObject: _CRepresentableRef {
typealias SwiftType = DirectoryObject
func _retain() -> CDirectoryObject {
CDirectoryObject_retain(self)
}
func _release() {
CDirectoryObject_release(self)
}
func _makeSwiftValue() -> DirectoryObject {
return DirectoryObject(self._retain().impl)
}
}
Уникальная способность класса — вызов deinit
. Именно в deinit
мы отпускаем shared_ptr
на объект, который был захвачен в конструкторе. Таким образом ARC (Automatic Reference Counting) от Swift сопрягается с системой управления памяти в C++.
Сама C-реализация держится с помощью OpaquePointer
. Все неполные типы Swift превращает в OpaquePointer
.
При вызове функции (в том числе в вычислимых свойствах) все параметры превращаются во временные С-объекты, передаются в вызов, после чего результат превращается в Swift-объект. Пример:
public func search(
query: SearchQuery
) -> Future<SearchResult> {
let _a1: CSearchQuery = CSearchQuery(query)
let _cr: CFuture_CSearchResult = CSearchManager_search_CSearchQuery(CSearchManager(impl: self._impl), _a1)
_a1._releaseIntermediate()
defer {
_cr._releaseIntermediate()
}
return _cr._makeSwiftValue()
}
Аннотации
Вернёмся к вариантному типу — перечислению с связанными значениями.
// С++
using Scalar CODEGEN_FIELD_NAMES(null, boolean, integer, string) = std::variant<std::monostate, bool, int, std::string>;
// Swift
public enum Scalar: Hashable {
case null
case boolean(Bool)
case integer(Int32)
case string(String)
}
У std::variant
нет имён для отдельных случаев. Автоматически придумать их тоже нельзя. Как легко заметить из первой части примера, недостающая информация подаётся с помощью аннотации, перечисляющей все имена.
CODEGEN_FIELD_NAMES(null, boolean, integer, string)
Аннотации реализованы с помощью синтаксиса C++ вида [[clang::annotate(“...”)]]
. Эта часть полностью остаётся в рамках нормального С++, который остаётся корректным без codegen.
Для других подобных случаев у нас есть множество аннотаций:
CODEGEN_GETTER
CODEGEN_SETTER
CODEGEN_FIELD_NAMES
CODEGEN_INTERNAL
CODEGEN_SKIP
CODEGEN_CONVERTER
CODEGEN_CONSTRUCTOR
CODEGEN_EXTENSION
CODEGEN_IMPLEMENTABLE
CODEGEN_GETTER/CODEGEN_SETTER. В С++ нет явной концепции геттера и сеттера. Это просто пара методов. Поэтому для идиоматичного интерфейса на выходе методы приходится дополнительно размечать. В большинстве случаев пары геттеров-сеттеров определяет автоматика.
CODEGEN_INTERNAL. В С++ нет различия public
и internal
, как в Swift. Это различие обеспечивается фактической видимостью символов при включении заголовков. Чтобы убирать части из публичного интерфейса, но оставить для внутреннего использования на Swift, используется подобная аннотация.
CODEGEN_SKIP позволяет пропускать вещи, которые нужно пропустить, чтобы они не попадали в генерацию. Например, потому что генератор не может их обработать или если они нужны только в C++.
CODEGEN_CONVERTER решает вопрос типов, которые напрямую генератором не поддерживаются. Вместо того, чтобы реализовать поддержку, можно указать здесь функцию преобразования из исходного типа в поддерживаемый (и обратно, если необходимо).
CODEGEN_EXTENSION указывает на функции, являющиеся расширением существующего типа. В C++ интерфейс класса нельзя расширять, все методы задаются в одном месте и один раз. Из других мест остаётся только возможность добавлять функции над этим объектом. Так как в Swift часто применяется расширение для этой же цели, помеченные этой аннотацией функции превращаются в методы расширения.
CODEGEN_IMPLEMENTABLE указывает, что указанный тип может быть определён пользователем. Мы ограничиваем возможность наследования интерфейсов: ни один из генерируемых классов не помечается как open
. В тех случаях, когда пользователю даётся возможность определить свою реализацию, используется эта аннотация. Отмеченный тип превращается в протокол.
Таким образом аннотации закрывают необходимость в тонкой настройке генератора.
Итог
Создание своего кодогенератора — не настолько трудная работа, как может показаться. Он уже себя окупил — количество функций C++ SDK растет с каждым днём, а разработчикам не нужно поддерживать каждую новую функцию на стороне платформы.
Это несказанно ускоряет раскатку новой функциональности при кроссплатформенной разработке: готовность функции на C++ сразу означает готовность её для поставки клиенту — в большинстве случаев.
Мы изначально обеспечили, чтобы инструменты работали и под macOS, и под Linux, так как многие С++-разработчики работают под Linux. Они тоже могут разрабатывать свифтовое SDK и сам генератор.
Используя промежуточной слой на С, мы уверены, что наша библиотека не таскает с собой лишний вес, с которым мы ничего не можем поделать. Архитектура codegen позволит нам мягко перейти на использование C++ Interoperability, то есть C++ в качестве промежуточного слоя, когда поддержка в компиляторе созреет.
В перспективе хотим использовать codegen не только для SDK, но и на других наших iOS-проектах. Потребуются доработки, но с учётом экономии времени и исключения однообразной работы, это не такая большая проблема.