Ускоряем разработку: автоматический перевод 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 смогли бы единицы.

Какова оптимальная оболочка?

  1. Естественна для языка. Например, Int?, а не NSNumber?; enum, а не особый класс.

  2. Не вредит эффективности.

  3. Не скрывает возможности.

  4. Занимает минимум места в поставке.

Сопоставление сред может быть трудоёмким процессом. Промежуточный код может быть нетривиальным, а вручную писать его на все случаи практически невозможно. 

Нам нужно было решить проблему ручной работы: производить оболочки автоматически в большинстве случаев. При этом допускали, что в отдельных случаях будет нужна ручная поддержка. Например,

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

  2. Редкие, но трудные для автоматизации.

Автоматизация — единственный способ справиться с быстрым развитием 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,

  • пишет готовый публичный интерфейс,

  • а также внутренний интерфейс на целевом языке для доработок,

  • понимает командные соглашения на С++,

  • минимизирует накладные расходы,

  • применяется поэтапно — можно начать с переноса одной штучки,

  • совместимо со старым кодом,

  • покрыто тестами.


ссылка на оригинал статьи https://habr.com/ru/articles/595983/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *