Ускоряем разработку: автоматический перевод C++ в Swift. Часть I
В июле 2021 года мы выпустили Mobile SDK для iOS и Android, позволяющий разработчикам использовать наши карты, поиск и навигацию в своих мобильных приложениях.
О его возможностях можно почитать на vc.ru. Эта же статья о том, как нам удалось автоматизировать превращение Mobile SDK из кроссплатформенной библиотеки на С++ в привычную свифтовую библиотеку. Иначе говоря, как мы соединяли Swift с C++.
C++ и Swift в приложении 2ГИС
Наши компоненты пишутся на C++ под несколько платформ и соединяются в разнообразные продукты. Не исключение и мобильное приложение: библиотеки поиска, карты и навигации написаны на C++. Пользовательский интерфейс — на Swift (и частично Objective-C).
Так как Swift не работает напрямую с C++, мы писали мост между двумя средами с помощью промежуточного языка. Objective-C подходит для этой цели: поддерживает все базовые типы, отображает классы, вызовы методов, перечисления.
Упрощённо мост выглядит так:
Swift может напрямую работать с интерфейсами на Objective-C;
интерфейс на Objective-C может иметь реализацию на Objective-С++;
Objective-C++ может работать с любым кодом на C++.
Главный минус подхода — ручная работа.
Написание моста через Objective-C++ приводит к переусложнениям. Нет единственного правильного способа передавать вещи между платформами. Возникает целый слой, в котором разработчики проявляют изобретательность, иногда пишут часть бизнес-логики. Весь этот код требует тестов.
Прочие минусы касаются не процесса разработки, а результата. Сам факт использования Objective-C имеет ряд последствий. Импортированный в Swift интерфейс получается менее качественным. Об этом подробнее ниже.
Теряется скорость. Например, в работе со строками: NSString хранится в UTF-16, тогда как C++ и Swift используют UTF-8. Отсюда одно лишнее преобразование при передаче строк. (Swift умеет использовать UTF-16 в режиме совместимости с NSString, но потери эффективности от преобразования в UTF-16 не избежать).
Увеличивается размер библиотек и приложений. Как динамический язык, Objective-C добавляет большой объём символьной информации (имена классов, строки селекторов и другого), от которой нельзя избавиться. Даже использование direct-методов Objective-C может решить проблему лишь частично.
Ограничивается свобода оптимизации. У Objective-C расхожая со Swift и С++ диспетчеризация вызовов. В местах, где нужна статическая диспетчеризация, Swift и С++ ведут себя одинаково. Но вызов через Objective-C разорвёт цепочку и предотвратит оптимизацию.
Получается, что мост на основе Objective-C и Objective-C++ — универсальный инструмент, но он взимает значительный «налог» на использование.
Теперь о качестве отображаемого интерфейса. Рассмотрим примеры передачи значений.
Необязательный целочисленный 32-битовый тип
// C++
using Int32Opt = std::optional<int32_t>;
// Swift
public typealias Int32Opt = Int32?
Оба языка тут здорово дружат, одно перекладывается в другое напрямую. Как теперь передать это через Objective-C?
Чистый int32_t
использовать нельзя, потому что не хватит одного бита для передачи nullopt/nil
. Можно попробовать NSNumber
.
// Obj-C
typedef NSNumber * _Nullable Int32Opt;
Но это не то же самое, потому что NSNumber
может хранить большое количество типов: double
, Bool
и др. Мы потеряли информацию о типе. Теперь их извлечение может приводить к ошибкам, а компилятор не сможет проверить.
Вариантный тип
Передадим std::variant<int, sts::string>
. Наиболее естественное преставление вариантных типов в Swift — enum
с ассоциированными значениями.
// C++
using Scalar = std::variant<int, std::string>;
// Swift
public enum Scalar {
case integer(Int32)
case string(String)
}
// Obj-C
???
Как это записать на Objective-C?
На этот раз нет никаких очевидных аналогов. Можно завести все необходимые поля и хранить индикатор сохранённого значения. Получится подобный код:
@objc public enum SDKScalarSelector: UInt8 {
case integer
case string
}
@objc public final class SDKScalar: NSObject {
/// Показатель текущего хранимого значения.
@objc public let selector: SDKScalarSelector
@objc public var integer: Int32 {
assert(self.selector == .integer)
return self._integer
}
@objc public var string: String {
assert(self.selector == .string)
return self._string
}
private let _integer: Int32
private let _string: String
@objc public init(integer: Int32) {
self.selector = .integer
self._integer = integer
self._string = .init()
super.init()
}
@objc public init(string: String) {
self.selector = .string
self._string = string
self._integer = .init()
super.init()
}
}
При этом все эти значения могли бы занимать одно общее место в памяти. Но мы будем пропускать эту оптимизацию, чтобы слишком не добавить «ручных» ошибок.
Получившийся тип SDKScalar работоспособен, но точно не удобен для работы на Swift. Удобный, вспомним, выглядит так:
public enum Scalar {
case integer(Int32)
case string(String)
}
Чтобы было всё-таки комфортно работать, необходимо добавить код, превращающий SDKScalar и Scalar друг в друга.
Примеры показывают, что даже несложные типы данных принуждают проявлять изобретательность и писать большое количество кода, что-то упуская по пути.
С++ и Swift в Mobile SDK
При подготовке к выпуску 2GIS Mobile SDK мы поставили себе обязательное условие, что будем поставлять наши компоненты (написанные на C++) в комплекте с оптимальным интерфейсом-оболочкой для целевой среды. Для iOS — на Swift, под Android — на Kotlin. Иначе разработчикам пришлось бы самостоятельно писать промежуточный код и пользоваться SDK смогли бы единицы.
Какова оптимальная оболочка?
Естественна для языка. Например,
Int?
, а неNSNumber?
;enum
, а не особый класс.Не вредит эффективности.
Не скрывает возможности.
Занимает минимум места в поставке.
Сопоставление сред может быть трудоёмким процессом. Промежуточный код может быть нетривиальным, а вручную писать его на все случаи практически невозможно.
Нам нужно было решить проблему ручной работы: производить оболочки автоматически в большинстве случаев. При этом допускали, что в отдельных случаях будет нужна ручная поддержка. Например,
Исключительные, требующие индивидуального подхода кейсы. Автоматизация подразумевает обобщённый подход.
Редкие, но трудные для автоматизации.
Автоматизация — единственный способ справиться с быстрым развитием SDK.
Начали с исследования готовых инструментов.
Инструменты генерации межъязыковых интерфейсов
C++ Interop
Первый вариант: С++ interoperability — взаимодействие Swift и С++ напрямую с помощью компиляторной опции. Эта технология находится в разработке прямо сейчас: её горячо одобряет команда Apple, но на деле разработка лежит на энтузиастах. Цель проекта: обеспечить широкую совместимость C++ со Swift, как с Objective-C.
Пример на C++ из тестов проекта:
namespace example {
template <typename T>
class Silly {
public:
T c;
};
using SillyInt = Silly<int>;
using AltInt = int;
class MyStruct : public Silly<int> {
public:
MyStruct(int a = 0, int b = 0) : a(a), b(b) {}
int a;
int b;
void dump() const override;
};
}
using SwiftMyStruct = example::MyStruct;
Поддерживаются классы с публичными и непубличными членами, виртуальные функции, конструкторы, пространства имен и так далее. Всё это импортируется в Swift.
C++ Interop встроен в компилятор и включается опцией -enable-cxx-interop
. Возможно включение одновременно с Objective-C -enable-objc-interop
, что даёт доступ к Objective-C++.
Режим совместимости предполагает уникальные оптимизации. Например: использование памяти без копирования при пересечении границ языков (например, для строк и массивов). Эти вещи невозможно реализовать без вмешательства в стандартную библиотеку языка.
Использовать этот режим полноценно пока невозможно. Нынешняя разработка опирается на устройство компилятора, а не потребности пользователей. Поэтому готова корневая функциональность: работа с разнообразными ссылками, функциями, некоторыми типами шаблонов. Но нет возможности работать со стандартной библиотекой (строки, массивы), что необходимо для любой задачи.
Как результат требований на гибкость и оптимизацию C++ Interop образует на выходе низкоуровневый Swift-интерфейс к C++-библиотеке.
Для продуктовой разработки этот результат нужно рассматривать как промежуточный этап. Этот интерфейс нужно дооборачивать в более качественный интерфейс на Swift и уже в таком виде поставлять конечному пользователю библиотеки.
Сейчас C++ Interop не является готовым инструментом автоматизации. Это рельсы для автоматизации. Мы сможем в будущем использовать этот промежуточный этап, чтобы получить и уникальные оптимизации, и высокое качество интерфейса.
Gluecodium
Инструмент, берущий на себя роль материала, скрепляющего разные языки (отсюда и название: glue — клей). Автоматизирует создание промежуточного слоя между C++ и рядом других: Swift, Kotlin, JavaScript, Dart.
Для описания интерфейса используется собственный язык LIME IDL (Interface Description Language). Промежуточный код генерируется на C, отлично подходящий мультиязычному инструменту благодаря своей универсальности.
LIME внешне похож на смесь Kotlin и Swift. Пример:
class SomeImportantProcessor {
constructor create(options: Options?) throws SomethingWrongException
fun process(mode: Mode, input: String): GenericResult
property processingTime: ProcessorHelperTypes.Timestamp { get }
internal static property secretDelegate: ProcessorDelegate?
enum Mode {
SLOW,
FAST,
CHEAP
}
@Immutable
struct Options {
flagOption: Boolean
uintOption: UShort
additionalOptions: List<String> = {}
}
exception SomethingWrongException(String)
}
Совместимость ограничена. Например, enum
может быть только простым целочисленным перечислением (Mode
в примере выше), как в Kotlin. Ассоциированных значений, как в Swift, нет. Получается, что если хотим использовать мощные свифтовые перечисления в интерфейсе, необходимо расширять LIME и писать соответствующую поддержку в инструменте.
Плюсы Gluecodium:
Использование IDL. Это отличный подход в общем случае. IDL создаёт единый язык пользователей и авторов интерфейса, быстрее указывает на ошибки.
Objective-C не используется. А значит, нет связанных дополнительных расходов.
Минусы:
Использование IDL. Это новый язык в проекте, которому необходимо обучать.
Отсутствие инструментов для работы с IDL. Примеры — автодополнение, подсветка синтаксиса (да, поэтому пример тоже без подсветки).
Ограниченная поддержка Swift и С++. Например, нет способа лаконично передать
std::variant
. Инструмент необходимо расширять.
Scapix
Зрелая среда, созданная для взаимодействия C++ с рядом целей: Java, Objective-C, Swift, Python, Javascript и C#. На удивление активно развивается.
Главная сила Scapix — использование C++ как языка описания интерфейсов. Пример:
#include <scapix/bridge/object.h>
class contact : public scapix::bridge::object<contact>
{
public:
std::string name();
void send_message(const std::string& msg, std::shared_ptr<contact> from);
void add_tags(const std::vector<std::string>& tags);
void add_friends(std::vector<std::shared_ptr<contact>> friends);
void notify(std::function<bool(std::shared_ptr<contact>)> callback);
};
При этом у Scapix, как видно из примера, строгие требования к интерфейсам на C++. Наиболее примечательно: интерфейсы обязаны наследоваться от типов scapix::bridge::object
, чтобы участвовать в генерации. Таким образом невозможно применять генерацию поверх существующего кода, не вмешиваясь в него.
Swift-интерфейс напрямую не производится; для него используется совместимость с Objective-C. Промежуточный код, в отличие от Gluecodium, генерируется не на C, а на Objective-C и Objective-С++. Удобный свифтовый уже придётся делать поверх самостоятельно.
Scapix работает по лицензии, ограничивающей развитие инструмента в собственных целях. Его можно спокойно использовать в продукте для построения моста, но если хочется добавлять новую функциональность — нужна дополнительная лицензия.
Плюсы:
Переиспользование С++-интерфейсов для описания результата.
Минусы:
Строгие требования на С++-интерфейс. Код SDK вынужден зависеть от Scapix.
Промежуточный слой на Objective-C и Objective-C++.
Ограничения лицензии.
Образ идеальной автоматизации
Каким мы видели идеальное решение:
нужен инструмент, создающий интерфейс на Swift (и Kotlin) для наших библиотек, имея на входе только C++;
необходимо, чтобы учитывались все наши командные соглашения о написании кода, поддерживались специализированные типы данных и примитивы работы с асинхронным кодом;
на выходе должен быть идиоматичный код на Swift, в большинстве случаев подходящий для публикации пользователю;
необходим задел для расширяемости и совместимости с уже существующим кодом. В частности, с кодом на Objective-C;
не должны заведомо ограничивать себя в возможности оптимизаций.
В качестве промежуточного языка нужно использовать С.
Почему так? У этого решения есть трудности и преимущества.
Трудности:
Пропуск всех вызовов через С подразумевает, что придётся реализовать все вещи, не существующие в C.
Поддержка полиморфизма через границу С.
Обработка исключений из С++.
Отсутствие автоматической системы управления временем жизни объектов, аналогичной ARC в Objective-C.
Преимущества:
Написанный на С код превосходно оптимизируется компилятором. В отличие от Objective-C, язык лишён динамизма по умолчанию. Написанный код будет вызываться так, как и написан: нет неявной виртуальности методов, есть максимальная предсказуемость и легкая отладка.
Снижение веса продукта. Промежуточный код — это всё равно код, который компилятор оставит в библиотеке, однако код на C не вносит никаких новых символов. Нет классов, которые добавляли бы метаданные; нет селекторов, которые добавляли бы строковые данные; нет таблиц виртуальных функций.
Следовательно, С как промежуточный язык — единственный бескомпромиссный вариант.
Велосипед
В следующей части этой статьи расскажу о нашем собственном решении, которое:
даёт на выходе Swift и Kotlin,
пишет готовый публичный интерфейс,
а также внутренний интерфейс на целевом языке для доработок,
понимает командные соглашения на С++,
минимизирует накладные расходы,
применяется поэтапно — можно начать с переноса одной штучки,
совместимо со старым кодом,
покрыто тестами.