В июле 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,

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

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

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

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

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

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

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

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


  1. Kelbon
    17.12.2021 13:22
    -2

    Боюсь проблема тут в самом существовании такого взаимодействия языков и исправлять это нужно просто написанием отдельных модулей на конкретных языках без всяческих связей через кривотню


    1. Woodroof
      17.12.2021 16:52

      Ну т.е. переписать все имеющиется библиотеки (а их много!) дважды для каждого языка. А при появлении новых - реализовывать ещё раз с нуля. И, конечно, по пути допускать разные баги в разных реализациях.

      Так себе решение :)