Ускоряем разработку: автоматический перевод 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 и Objective С++.

Режим совместимости предполагает уникальные оптимизации. Например: использование памяти без копирования при пересечении границ языков (например, для строк и массивов). Эти вещи невозможно реализовать без вмешательства в стандартную библиотеку языка.

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

Как результат требований на гибкость и оптимизацию 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/company/2gis/blog/595983/


Интересные статьи

Интересные статьи

Автор: Forbidden WorldДумаете, что и так знаете про успехи США, Германии и особенно Великобритании в области криптографии?“Я же смотрел “Игру в имитацию”!” - распростране...
Круг DevOps – катаем квадратное, таскаем круглое Всем привет! Это третья, завершающая, часть нашего рассказа о том, как «Ростелеком ИТ» внедряет CI/CD & DevOps в энте...
Это вторая часть статьи, посвященной автоматизации системного тестирования на основе виртуальных машин. Первую часть можно найти здесь. В этой части статьи мы будем использовать навы...
Docker Swarm, Kubernetes и Mesos являются наиболее популярными фреймворками для оркестровки контейнеров. В своем выступлении Арун Гупта сравнивает следующие аспекты работы Docker, Swa...
Привет, Хабр! Год назад мы проделали отличную работу. Корявенько, наполовину, но всё же отличную. Ноосфера послала мне сигнал, что пришла пора доделать её до конца. Я думал, что эта работа...