Этот пост основан на презентации, с которой автор выступил на конференции EuroRust 2022 в Берлине. Доступны слайды и видеозапись.
Автор, работающий в компании Slint, участвует в создании UI-инструментария, написанного на Rust. Этот UI-инструментарий может использоваться и с другими языками и экосистемами, кроме той, для которой был написан, поэтому в Slint предусмотрены API для C++ и даже для Javascript. Естественно, эти API должны восприниматься как совершенно нативные для разработчиков, имеющих дело с этими языками. Именно поэтому ребром стоит вопрос о том, как создать нативно воспринимаемые API к коду Rust для пользователей, привыкших работать с C++.
В Slint предусмотрена возможность пользоваться имеющимся кодом на C++ для дальнейшей его интеграции в другие экосистемы для разных операционных систем. Это касается таких тем, как оформление виджетов, обеспечение доступности и пр. Вот почему не менее важно открывать из мира Rust удобный доступ к коду, уже написанному на C++.
В этом посте хотелось бы исследовать оба направления интеграции между Rust и C++ и представить некоторые инструменты, используемые в Slint.
Если вам требуется библиотека с открытым исходным кодом на C или C++, чтобы ввести её в ваш проект на Rust, посмотрите на crates.io или lib.rs: может быть, кто-нибудь уже сделал за вас нужную вам работу?
❯ Для читателей с бэкграундом из C++
Я как растовщик употребляю термин «safe» (безопасный) в Rust-смысле. Код безопасен, если компилятор Rust гарантировал, что обеспечены все свойства, необходимые для соблюдения безопасности памяти. Поскольку компилятор Rust не может разбирать код C++ и проверять, какие свойства в нём употребляются, то код C++ по определению небезопасен. Это не означает, что «небезопасный» код C++ провоцирует неопределённые поведения или выполняет недопустимые обращения к памяти – означает лишь, что это возможно.
Для изучения этого поста вам не обязательно знать Rust, но с чем здесь придётся разбираться – так это с макросами Rust. Они отличаются от макросов C. Макрос Rust – это функция, написанная на Rust и принимающая на вход поток токенов, а на выход также выдающая поток токенов. Компилятор выполняет эту функцию во время компиляции, всякий раз, когда встречает в коде макрос. Тогда он передаёт в код поток токенов и заменяет его сгенерированным потоком. Такой механизм располагает к созданию мощных, но в то же время «гигиеничных» макросов. Такие макросы не изменят смысл окружающего их кода.
❯ Интеграция на уровне языка
Сначала давайте рассмотрим интеграцию на уровне языка. Как заставить Rust вызывать код, написанный на C++, и наоборот.
Компилятор Rust неспособен понимать код C++. Поэтому необходимо рассказать компилятору Rust о том коде, который вы собираетесь использовать на стороне C++. Здесь потребуется немного склеивающего кода: языковые привязки. Привязки определяют, какие функции и типы данных будут доступны на стороне C++ в таком виде, чтобы они были понятны компилятору Rust. Как только привязки становятся доступны, код Rust может пользоваться этими привязками для вызова кода на стороне C++. Разумеется, то же самое действует и в обратную сторону: компилятору C++ также требуются языковые привязки, по которым можно было бы сориентироваться в коде, доступном на стороне Rust.
Таким образом, невозможно произвольно сочетать код C++ и Rust. Нужны строго определённые интерфейсы для перехода с одного языка на другой.
Проблемы
Значит, всё, что нам нужно – сгенерировать некоторое количество привязок, и всё пойдёт как по маслу? Насколько сложно этого добиться?
Приходится столкнуться с некоторыми проблемами:
- Два языка, которые мы хотим отобразить друг на друга, концептуально очень разные. Система макросов в Rust серьёзно отличается от аналогичной системы в C++. В C++ применяется наследование, а в Rust вместо этого используется система типажей (и две эти концепции напрямую несоотносимы друг с другом). В Rust предусматриваются сроки жизни, эта концепция чужда C++. Шаблоны C++ и дженерики Rust решают схожие проблемы, но подходят к ним по-разному. Из-за всех этих несовпадений сложно представить один язык средствами другого.
- В Rust не определяется двоичный интерфейс приложений (ABI). Таким образом, компилятор Rust волен менять в генерируемом двоичном выводе представление типов данных или вызовов функций. Разумеется, это сильно осложняет обмен данными в двоичном формате. На стороне C++ ситуация не сильно отличается: ABI определяется компилятором. Вот почему нельзя смешивать библиотеки, сгенерированные MSVC и GCC. Общим знаменателем в данном случае является применяемый в C интерфейс внешних функций (FFI). Он действительно служит стабильным двоичным интерфейсом, но ограничивает область взаимодействия только теми феноменами, которые выразимы на языке C. Несмотря на это ограничение, C FFI – хребет, на котором в основном и строится межъязыковая коммуникация (не только между Rust и C++).
- В обоих языках есть типы данных для представления текстовых строк, но на внутреннем уровне представление данных в этих типах отличается. Например, в обоих языках есть способы представить динамическую последовательность однотипных элементов, сохранённых бок о бок друг с другом. Это
std::vector
в C++ илиstd::Vec
в Rust. Оба определяют вектор, который служит указателем на некоторую область памяти, причём, вектор имеет мощность и длину. Но какой тип у этого указателя? Как те данные, на которые направлен указатель, должны быть выровнены в памяти? Какой тип представляет мощность и длину? В какой последовательности сохраняются указатель, мощность и длина? Достаточно любого несоответствия этих или других деталей – и будет невозможно отобразить тип одного языка на концептуально схожий тип другого языка. - Даже если окажется, что структура данных совпадает, в разных языках могут предъявляться разные требования к информации, хранимой в этих типах данных. Например, в Rust строка должна быть действительной последовательностью UTF-8, тогда как в C++ это просто последовательность байт – программист наверняка знает, в какой кодировке она записана. Таким образом, в любом случае безопасно передавать строку из Rust в C++ (предполагается, что все мелкие детали, касающиеся строкового типа, в стандартных библиотеках подходят друг другу), но передача строки из C++ в Rust может спровоцировать панику.
- Другая проблема проистекает из встраиваемого (inline) кода. Этот код нельзя напрямую вызвать, располагая только двоичными данными. Вместо этого код вставляется именно на месте встраивания. Поэтому требуется, чтобы компилятор мог скомпилировать интересующий нас код. Очевидно, что компилятор Rust не может встраивать код C++, равно как компилятор C++ не может встраивать код Rust. Эта техника настолько востребована, что все шаблоны в C++, в сущности, встраивают код.
По всем этим причинам сложно сгенерировать привязку, которая опосредовала бы взаимодействие между Rust и C++.
❯ Автоматическая генерация привязок
В идеальном мире никаких привязок не требуется. В комбинации Rust и C++ без них не обойтись, поэтому давайте рассмотрим такой вариант: почему бы не генерировать привязки автоматически из имеющихся rust-файлов или заголовочных файлов C++. Именно для этого и предусмотрена автоматическая генерация привязок.
Притом, насколько сложно автоматически создавать хорошие языковые привязки, всё равно полезно иметь генераторы. В качестве отправной точки. Есть варианты для работы в обоих направлениях: предоставить код Rust для использования в C++, а также наоборот.
Шире всего используются генераторы привязок bindgen и cbindgen.
bindgen
Bindgen разбирает заголовочные файлы и генерирует привязки Rust. Такой подход хорош с кодом C, но с кодом C++ не идеален. По умолчанию bindgen пропускает любую конструкцию, для которой не может сгенерировать привязки. Таким образом, он делает столько привязок, сколько может.
На практике bindgen требуется конфигурация, чтобы работать над любым реальным проектом на C++. По мере необходимости вы будете включать и исключать типы, либо помечать типы как непрозрачные: это будет значить, что их нельзя передавать в Rust из C++ и обратно из Rust в C++, но на стороне Rust с этими типами вообще никак не получится взаимодействовать. Возможно, вам потребуется добавить вспомогательные функции C(++), обеспечивающие доступ к тому функционалу, который по умолчанию не виден для bindgen.
Как правило, bindgen используется для генерации низкоуровневого крейта (поясню для пользователей C++: это библиотека в менеджере пакетов), чьё имя заканчивается на
-sys
. -sys-крейты обычно полны небезопасных (unsafe
) вызовов, направленных в обёрнутые ими библиотеки C или C++.Поскольку вся суть Rust – в заключении небезопасного кода в безопасные обёртки, как правило, приходится писать ещё один крейт с безопасными обёртками вокруг крейта
-sys
, а потом суффикс -sys
просто отбрасывается из имени нового крейта.Обратите внимание: этот процесс немного перекликается с тем, как C++-разработчики предоставляют заключают в безопасные обёртки библиотеки C. Разумеется, уровень
–sys
там не нужен, поскольку C++ может просто напрямую потреблять заголовки C.cbindgen
Cbindgen отвечает за другое направление: разбирает код Rust и генерирует из него заголовки C или C++.
Cbindgen рассматривает код, специально размеченный разработчиком как совместимый с интерфейсом C FFI. Это делается при помощи атрибута
#[repr(C)]
.Как правило, разработчики создают в своём проекте на Rust специальный модуль (часто именуемый
ffi
) и собирают все #[repr(C)]
, доступ к которым хотят открыть в этом модуле. Опять же, этот процесс напоминает, как C++-разработчики пишут на уровне C интерфейс для своего кода C++.Когда использовать генераторы привязок
Генераторы привязок лучше всего работают в случае, когда у вас есть стабильный интерфейс на одном языке, и вы хотите добиться, чтобы этот код был доступен на другом языке. Как правило, такой код существует в форме библиотеки.
Именно так генерация привязок построена в Slint: привязки генерируются на основе имеющегося стабильного API Rust. Затем сгенерированный код достраивается на стороне C++, чтобы с ним можно было более аккуратно взаимодействовать из C++ и (частично) скрывать сгенерированный код за фасадом, возведённым вручную.
Как пользоваться генераторами привязок
Генератор привязок можно запустить единожды, а затем поместить сгенерированные привязки в систему контроля версий. Правда, такой подход надёжно работает лишь с кодом, имеющим очень стабильные интерфейсы.
Генераторы привязок должны выполнять генерацию во время сборки. Естественно, для этого вам придётся интегрировать их в выбранную вами систему сборки.
❯ Полуавтоматическая генерация привязок
Чтобы наладить полуавтоматическую генерацию привязок, нужно подготовить специальный фрагмент кода или конфигурацию, которые бы определяли интерфейс между двумя языками. Затем этот материал преобразуется в набор привязок как для Rust, так и для C++, всё это – поверх автоматически сгенерированного интерфейса внешних функций C, скрытого между наборами привязок.
Преимущество в том, что открывается возможность выстраивать более широкие абстракции поверх интерфейса внешних функций C. Поэтому сгенерированные таким образом привязки получаются более удобными в использовании.
Крейт cxx
Есть популярный крейт cxx. Есть и другие варианты, которые либо надстраиваются над
cxx
, либо предлагают схожий функционал.cxx
– залог безопасных и быстродействующих привязок.Безопасность такого рода обеспечивается только на уровне самих привязок: код, вызываемый через эти привязки, конечно же, так и остаётся небезопасным. Такое ограничение – удобная вещь; благодаря нему вы можете быть уверены, что сам сгенерированный код не привносит проблем. Можно сосредоточиться на отладке «другой стороны» привязок, а не присматриваться к сгенерированному коду.
Чтобы гарантировать безопасность привязок, cxx генерирует статические утверждения и проверяет сигнатуры функций и типов.
Чтобы привязки работали быстро,
cxx
удостоверяется, что в привязке не копируются данные, а также не происходит никаких преобразований. В результате типы из одного языка протекают в другой. Например, std::string
со стороны C++ превращается в CxxString
в Rust. Поэтому сгенерированные привязки могут казаться разработчикам чужеродными.Как это выглядит? У вас в коде Rust должен быть модуль, в котором определяются обе стороны интерфейса. Ниже приведён пример, взятый из документации по cxx:
#[cxx::bridge]
mod ffi {
struct Metadata {
size: usize,
tags: Vec<String>,
}
extern "Rust" {
type MultiBuf;
fn next_chunk(buf: &mut MultiBuf) -> &[u8];
}
unsafe extern "C++" {
include!("demo/include/blob_store.h");
type Client;
fn new_client() -> UniquePtr<Client>;
fn put(&self, parts: &mut MultiBuf) -> u64;
}
}
- Необходимо пометить модуль как
#[cxx::bridge]
. Это – сигнал макросу Rust о том, что этот код нужно обработать. Внутри модуля (который в данном случае называетсяffi
), определяются типы данных, доступные как в C++, так и в Rust. - Далее идёт раздел
extern "Rust"
. В нём перечисляются те типы и функции, определяемые на стороне Rust, которые должны предоставляться для использования в C++.cxx
отмечает, что первый аргументnext_chunk
– это изменяемая ссылка на тип данныхMultiBuf
. Этот тип моделируетMultiBuf
как класс на стороне C++ и делаетnext_chunk
членом этого класса. - В разделе
unsafe extern "C++"
определяются такие типы данных и функции, расположенные на стороне C++, которые должны быть доступны для использования из Rust. Здесьcxx
ищет информацию, релевантную в Rust: тут необходимо выражать информацию о временах жизни, а также указывать, безопасно ли вызывать конкретную функцию. В данном случае какnew_client
, так иput
безопасны. Эта информация важна на стороне Rust, но никак не влияет на обёртываемый код C++.
Когда использовать cxx?
Лучше всего – в случаях, когда есть возможность контролировать обе стороны API. Например, если хотите вынести в новую библиотеку (написанную на Rust) какой-нибудь код из имеющейся реализации на C++, то cxx идеально вам подойдёт, так как за один шаг определяет подходящий набор привязок и интерфейс C FFI.
❯ Не генерируйте привязки
Третий вариант – использовать в Rust крейт cpp, чтобы писать код C++ в стиле встраивания. Рассмотрим (сокращённую) функцию экземпляра
notify
на Rust, взятую из исходного кода Slint:fn notify(&self) {
let obj = self.obj;
cpp!(unsafe [obj as "Object*"] {
auto data = queryInterface(obj)->data();
rust!(rearm [data: Pin<&A18yItemData> as "void*"] {
data.arm_state_tracker();
});
updateA18y(Event(obj));
});
}
Когда впервые видишь такое в коде Rust – голова пухнет. Что же делает этот фрагмент кода?
1. Создаётся локальная переменная
obj
, в которой содержится ссылка на переменную члена obj
(типа &c_void
).2. Макрос
cpp!
(в Rust все доступные для вызова макросы оканчиваются на `!`) обрабатывает весь код вплоть до закрывающих скобок, которыми завершается функция notify
.Этот макрос неявно объявляет небезопасную (
unsafe
) функцию C++, возвращающую void
, которая принимает один объект obj
типа Object*
. Макрос ожидает, что obj будет определён в окружающем коде Rust. Тело этой функции C++ — это код, заключённый в фигурные скобки.3. Действуя в мире C++ мы работаем с
obj
, желая извлечь некую информацию, которую затем сохраняем в локальной переменной data
. Конечно же, эти данные (data
) видимы только внутри той функции C++, которую мы только что неявно объявили. Окружающий код Rust её не видит.4. В следующей строке используется (псевдо)макрос
rust!
. С его помощью мы вновь переключаемся на язык Rust.5. Этот макрос
rust!
создаёт другую функцию (на Rust) под названием rearm
, принимающую аргумент data
типа type Pin. Этот аргумент обязательно должен присутствовать в окружающем коде C++, и мы ожидаем увидеть здесь тип void*
. Здесь нужно дать определения типов как для C++, так и для Rust, поскольку крейт cpp
, к сожалению, не сможет найти тип на стороне C++. Тело этой функции Rust будет содержатьdata.arm_state_tracker();
и возвращать void
. Также здесь будут созданы привязки, необходимые для вызова новой функции rearm из C++. Как только псевдомакрос rust!
сгенерирует этот код, он сам себя заменит кодом C++, вызывающим функцию rearm
через сгенерированные привязки C++.6. Ещё в функции C++, созданной cpp, мы вызываем другой код C++,
updateA11y(Event(obj));
и доходим до завершения тела неявно созданной функции C++. Как только макрос cpp сгенерирует весь код, за который отвечает, он заменяет себя на вызов функции C++, сгенерированной через специально созданную для этого привязку Rust.После того, как все макросы расширены таким образом, у нас сгенерированы две новые функции, а также привязки, необходимые для их вызова. Последняя функция
notify
, которую видит компилятор Rust – это просто определение переменной obj
с последующим вызовом к некоторой привязке, принимающей этот obj
как аргумент.При таком подходе не обойтись без генерации привязок, что противоречит заголовку этого раздела. Просто значительная часть генерации привязок обрабатывается неявно. Разумеется, вам так или иначе требуется генерировать привязки для тех типов данных, к которым вы хотите обращаться как в Rust, так и в C++. В крейте cpp есть и другие макросы, которые помогут вам с этим.
Как это работает?
Макросы, поставляемые в крейте
cpp
, действительно генерируют весь код. Вам придётся организовать интеграцию на уровне системы, чтобы собрать и связать весь сгенерированный код C++.В каком случае использовать крейт cpp?
В Slint крейт cpp применяется для взаимодействия с GUI-инструментариями C++, у которых есть стабильный API. В данном практическом случае это отличный вариант.
Резюме
Существуют самые широкие возможности для интеграции кода C++ и Rust, но всегда нужно генерировать языковые привязки. Такая опосредованность позволяет избежать тесного связывания между языками и открывает в Rust более широкие возможности проектирования. Правда, этот же механизм исключает всякую возможность бесшовной интеграции между Rust и C++.
❯ Интеграция сборочной системы
Когда у вас появляется проект, сочетающий код на Rust и C++, приходится опираться на обе эти языковые составляющие и объединять их в общую согласованную библиотеку. Давайте коротко рассмотрим, что необходимо для создания такого межъязыкового проекта.
cargo
, официальная сборочная система Rust – единственный инструмент для построения кода Rust. Для вашего кода на C++ также существует сборочная система. Как правило, она нетривиальна, не пытайтесь воспроизвести её в cargo
. Напротив, интегрируйте две сборочные системы в одну.Для начала рассмотрим Cargo
Cargo
Когда Cargo – основной сборочный инструмент, лежащий в основе вашего проекта, хорошо обзавестись небольшими вкраплениями кода C++ в более широком контексте Rust. Типичные варианты использования – генерация привязок вокруг кода на C и C++.
Во время сборки Cargo может выполнять произвольный код. Она ищет файл
build.rs
рядом с Cargo.toml.
Если файл build.rs
существует, то cargo компонует и выполняет этот файл в процессе сборки. Файл build.rs послужит информационной поддержкой для остального сборочного процесса, выводя инструкции для cargo в stdout. Подробнее об этом рассказано в документации по cargo.build.rs
– это обычный код Rust, который может использовать любой крейт, указанный в файле Cargo.toml
как build-dependency
!Если работаете с кодом C и C++, то вас заинтересует крейт cc. Он позволяет управлять компилятором C или C++ из
build.rs
. Идеально подходит, чтобы собрать несколько простых файлов. Для более крупных проектов на C или C++ вам, пожалуй, потребуется напрямую запускать систему сборки проектов. Здесь будет удобен крейт cmake. Он управляет типичным потоком задач, связанным с конфигурацией, сборкой и установкой CMake, а затем предоставляет cargo те цели, под которые собирается CMake.Для поддержки других сборочных систем существуют похожие крейты. Также ими можно управлять при помощи более низкоуровневых крейтов, позволяющих выполнять произвольные команды, например, xshell.
CMake
Я привёл CMake в качестве примера сборочной системы, широко применяемой в проектах на C и C++. Подобная поддержка доступна и для других сборочных инструментов, а в некоторых даже заявляется о нативной поддержке Rust, например, путём прямого запуска компилятора rust (в языке Rust это не поддерживается!).
Пользоваться имеющейся сборочной системой C++ для обеспечения всего процесса сборки в идеале нужно тогда, когда у вас в крупном проекте на C++ содержится сравнительно немного кода на Rust. Типичный практический случай – замена небольшой части проекта кодом, написанным на Rust, либо с использованием Rust-библиотеки.
Проект
corrosion
обеспечивает интеграцию cargo с CMake. Когда простой файл CMakeLists.txt
собирает примерную библиотеку Rust и связывается с ней, это выглядит так:cmake_minimum_required(VERSION 3.15)
project(MyCoolProject LANGUAGES CXX)
find_package(Corrosion REQUIRED)
corrosion_import_crate(MANIFEST_PATH rust-lib/Cargo.toml)
add_executable(cpp-exe main.cpp)
target_link_libraries(cpp-exe PUBLIC rust-lib)
- Всё начинается с обычных двух строк, присутствующих в любом проекте с CMake: определяется минимальная версия CMake, необходимая для сборки проекта, далее идёт имя проекта и указываются языки программирования, которые необходимы CMake при сборке. Обратите внимание: Rust там не упоминается.
- В строке
find_package(Corrosion REQUIRED)
мы просим CMake включить поддержку Corrosion, и, если Corrosion не найдена – следует отказ. Также можно приказатьFetchContent
скачать Corrosion в рамках производимой сборки. - Теперь, когда Corrosion доступна, можно запросить сборку кода Rust при помощи
corrosion_import_crate
, указав на имеющийся файлCargo.toml
. Corrosion собирает этот проект Rust и предоставляет CMake все цели для сборки. - В последних двух строках из этого примера собирается двоичный C++, который затем связывается с кодом Rust.
В Slint проект Corrosion используется для того, чтобы C++-разработчики могли пользоваться библиотекой Slint в коде C++, не слишком отвлекаясь на специфику Rust.
Надеюсь, этот пост послужит вам хорошим введением для проекта по интеграции кода C++ и Rust — или, как минимум, вы узнаете о каких-то вариантах, которые ранее были вам не известны. Также можете подключаться к дискуссии на github.