[Перевод] Небольшая хитрость для простого взаимодействия Rust и C++

06fb37b8da2c41b5a8a4e888e0011651.jpg

На работе я переписываю запутанный C++ код на Rust.

Из‑за активного использования коллбеков (вздох), Rust иногда вызывает C++, а C++ иногда вызывает Rust. Все это благодаря тому, что оба языка предоставляют C API для функций, которые можно вызывать со стороны противоположного языка.

Это касается функций;, но как быть с методами C++? Представляю вам небольшую хитрость, благодаря которой можно переписать, без головной боли, один метод C++ за раз. И, кстати, это работает независимо от языка, на который вы переписываете проект, это не обязательно должен быть Rust!

Хитрость

  1. Создайте standard‑layout класс C++. Он определен стандартом C++. Проще говоря, это делает класс C++ похожим на обычную структуру C с некоторыми допущениями: например, класс C++ по‑прежнему может использовать наследование и некоторые другие особенности. Но, что особенно важно, виртуальные методы запрещены. Меня это ограничение не волнует, потому что я никогда не использую виртуальные методы и это моя наименее любимая функция в любом языке программирования.

  2. Создайте структуру Rust с точно таким же layout, как у класса C++.

  3. Создайте функцию Rust с соглашением о вызовах C, где первым аргументом будет созданная структура Rust. Теперь вы можете получить доступ к каждому члену класса C++!

Примечание: в зависимости от кода C++, с которым вы работаете, первый шаг может быть тривиальным или вообще неосуществимым. Это зависит от количества используемых виртуальных методов и других факторов.

В моем случае было несколько виртуальных методов, которые можно было с успехом сделать невиртуальными.

Звучит слишком абстрактно? Давайте рассмотрим пример!

Пример

Вот наш класс C++ User. Он хранит имя, UUID и количество комментариев. Пользователь может писать комментарии (просто строка), которые мы выводим на экран:

// Path: user.cpp

#include 
#include 
#include 
#include 

class User {
  std::string name;
  uint64_t comments_count;
  uint8_t uuid[16];

public:
  User(std::string name_) : name{name_}, comments_count{0} {
    arc4random_buf(uuid, sizeof(uuid));
  }

  void write_comment(const char *comment, size_t comment_len) {
    printf("%s (", name.c_str());
    for (size_t i = 0; i < sizeof(uuid); i += 1) {
      printf("%x", uuid[i]);
    }
    printf(") says: %.*s\n", (int)comment_len, comment);
    comments_count += 1;
  }

  uint64_t get_comment_count() { return comments_count; }
};

int main() {
  User alice{"alice"};
  const char msg[] = "hello, world!";
  alice.write_comment(msg, sizeof(msg) - 1);

  printf("Comment count: %lu\n", alice.get_comment_count());

  // This prints:
  // alice (fe61252cf5b88432a7e8c8674d58d615) says: hello, world!
  // Comment count: 1
}

Давайте сначала убедимся, что класс соответствует standard‑layout. Добавим эту проверку в конструктор (можно разместить его где угодно, но конструктор — вполне подходящее место):

// Path: user.cpp

    static_assert(std::is_standard_layout_v);

Иии… проект успешно собирается!

Теперь переходим ко второму шагу: давайте определим эквивалентный класс на стороне Rust.

Создадим новый библиотечный проект на Rust:

$ cargo new --lib user-rs-lib

Разместим нашу структуру Rust в src/lib.rs.

Нам нужно быть внимательными к выравниванию и порядку полей. Для этого мы помечаем структуру как repr(C), чтобы компилятор Rust использовал такой же layout, как и в C:

// Path: ./user-rs/src/lib.rs

#[repr(C)]
pub struct UserC {
    pub name: [u8; 32],
    pub comments_count: u64,
    pub uuid: [u8; 16],
}

Обратите внимание, что если вы хотите, поля в структуре Rust можно назвать по другому.

Также важно отметить, что std::string здесь представлен как непрозрачный массив размером 32 байта. Это потому, что на моей машине, с моей стандартной библиотекой, sizeof(std::string) равен 32. Это не гарантируется стандартом, поэтому такой подход делает код не очень переносимым. Мы рассмотрим возможные варианты обхода этого ограничения в конце. Я хотел показать, что использование типов стандартной библиотеки не мешает классу быть standard‑layout классом, но также создает определенные сложности.

На данный момент забудем об этом препятствии.

Теперь мы можем написать заглушку для функции Rust, которая будет эквивалентом метода C++:

// Path: ./user-rs-lib/src/lib.rs

#[no_mangle]
pub extern "C" fn RUST_write_comment(user: &mut UserC, comment: *const u8, comment_len: usize) {
    todo!()
}

Теперь давайте используем инструмент cbindgen для генерации C‑заголовка, соответствующего этому коду на Rust.

$ cargo install cbindgen
$ cbindgen -v src/lib.rs --lang=c++ -o ../user-rs-lib.h

И мы получаем следующий C-заголовок:

// Path: user-rs-lib.h

#include 
#include 
#include 
#include 
#include 

struct UserC {
  uint8_t name[32];
  uint64_t comments_count;
  uint8_t uuid[16];
};

extern "C" {

void RUST_write_comment(UserC *user, const uint8_t *comment, uintptr_t comment_len);

} // extern "C"

Теперь вернемся к C++, включим этот C‑заголовок и добавим несколько проверок, чтобы убедиться, что layout действительно совпадают. Снова помещаем эти проверки в конструктор:

#include "user-rs-lib.h"

class User {
 // [..]

  User(std::string name_) : name{name_}, comments_count{0} {
    arc4random_buf(uuid, sizeof(uuid));

    static_assert(std::is_standard_layout_v);
    static_assert(sizeof(std::string) == 32);
    static_assert(sizeof(User) == sizeof(UserC));
    static_assert(offsetof(User, name) == offsetof(UserC, name));
    static_assert(offsetof(User, comments_count) ==
                  offsetof(UserC, comments_count));
    static_assert(offsetof(User, uuid) == offsetof(UserC, uuid));
  }

  // [..]
}

Благодаря этому мы уверены, что layout в памяти класса C++ и структуры Rust совпадают. Мы могли бы сгенерировать все эти проверки с помощью макроса или генератора кода, но в рамках этой статьи можно сделать это вручную.

Теперь давайте перепишем метод C++ на Rust. На данный момент мы опустим поле name, так как оно немного проблематичное. Позже мы увидим, как мы можем все же использовать его из Rust:

// Path: ./user-rs-lib/src/lib.rs

#[no_mangle]
pub extern "C" fn RUST_write_comment(user: &mut UserC, comment: *const u8, comment_len: usize) {
    let comment = unsafe { std::slice::from_raw_parts(comment, comment_len) };
    let comment_str = unsafe { std::str::from_utf8_unchecked(comment) };
    println!("({:x?}) says: {}", user.uuid.as_slice(), comment_str);

    user.comments_count += 1;
}

Мы хотим собрать статическую библиотеку, поэтому укажем это cargo, добавив следующие строки в Cargo.toml:

[lib]
crate-type = ["staticlib"]

И теперь соберем библиотеку:

$ cargo build
# This is our artifact:
$ ls target/debug/libuser_rs_lib.a

Мы можем использовать нашу функцию Rust из C++ в функции main, но с некоторыми неудобными привидениями:

// Path: user.cpp

int main() {
  User alice{"alice"};
  const char msg[] = "hello, world!";
  alice.write_comment(msg, sizeof(msg) - 1);

  printf("Comment count: %lu\n", alice.get_comment_count());

  RUST_write_comment(reinterpret_cast(&alice),
                     reinterpret_cast(msg), sizeof(msg) - 1);
  printf("Comment count: %lu\n", alice.get_comment_count());
}

И слинковать (вручную) нашу новую библиотеку Rust с нашей программой на C++:

$ clang++ user.cpp ./user-rs-lib/target/debug/libuser_rs_lib.a
$ ./a.out
alice (336ff4cec0a2ccbfc0c4e4cb9ba7c152) says: hello, world!
Comment count: 1
([33, 6f, f4, ce, c0, a2, cc, bf, c0, c4, e4, cb, 9b, a7, c1, 52]) says: hello, world!
Comment count: 2

Вывод немного отличается для UUID, потому что в реализации на Rust мы используем трейт Debug по умолчанию для вывода среза, но содержимое остается тем же.

Несколько мыслей:

  • Вызовы alice.write_comment(..) и RUST_write_comment(alice, ..) строго эквивалентны, и на самом деле компилятор C++ преобразует первый вызов во второй в чистом коде C++, если вы посмотрите на сгенерированный ассемблерный код. Таким образом, наша функция Rust просто имитирует то, что компилятор C++ сделал бы в любом случае. Однако мы можем размещать аргумент User в любой позиции в функции. Другими словами, мы полагаемся на совместимость API, а не ABI.

  • Реализация на Rust может свободно читать и изменять закрытые члены класса C++, например, поле comment_count, которое доступно в C++ только через геттер, но Rust может получить к нему доступ, как если бы оно было публичным. Это происходит потому, что public/private модификаторы — просто правила, которые налагает компилятор C++. Однако ваш процессор не знает и не заботится об этом. Байты — это просто байты. Если вы можете получить доступ к байтам во время выполнения, не имеет значения, что они были помечены как «приватные» в исходном коде.

Мы вынуждены использовать утомительные приведения типов, что в общем то нормально. Мы действительно переинтерпретируем память из одного типа (User) в другой (UserC). Это допускается стандартом, поскольку класс C++ является standard‑layout классом. Если бы это было не так, это привело бы к неопределенному поведению и, вероятно, работало бы на некоторых платформах, но ломалось бы на других.

Доступ к std: string из Rust

std::string следует рассматривать как непрозрачный тип с точки зрения Rust, потому что его представление может отличаться в зависимости от платформы или даже версий компилятора, поэтому мы не можем точно описать его layout.

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

Сначала Rust. Мы определяем вспомогательный тип ByteSliceView, который представляет собой указатель и длину (аналог std::string_view в последних версиях C++ и &[u8] в Rust), и наша функция Rust теперь принимает дополнительный параметр, name:

#[repr(C)]
// Akin to `&[u8]`, for C.
pub struct ByteSliceView {
    pub ptr: *const u8,
    pub len: usize,
}


#[no_mangle]
pub extern "C" fn RUST_write_comment(
    user: &mut UserC,
    comment: *const u8,
    comment_len: usize,
    name: ByteSliceView, // <-- Additional parameter
) {
    let comment = unsafe { std::slice::from_raw_parts(comment, comment_len) };
    let comment_str = unsafe { std::str::from_utf8_unchecked(comment) };

    let name_slice = unsafe { std::slice::from_raw_parts(name.ptr, name.len) };
    let name_str = unsafe { std::str::from_utf8_unchecked(name_slice) };

    println!(
        "{} ({:x?}) says: {}",
        name_str,
        user.uuid.as_slice(),
        comment_str
    );

    user.comments_count += 1;
}

Мы повторно запускаем cbindgen, и теперь C++ имеет доступ к типу ByteSliceView. Таким образом, мы пишем вспомогательную функцию для преобразования std::string в этот тип и передаем дополнительный параметр в функцию Rust (мы также определяем тривиальный геттер get_name() для User, поскольку name все еще является приватным):

// Path: user.cpp

ByteSliceView get_std_string_pointer_and_length(const std::string &str) {
  return {
      .ptr = reinterpret_cast(str.data()),
      .len = str.size(),
  };
}

// In main:
int main() {
    // [..]
  RUST_write_comment(reinterpret_cast(&alice),
                     reinterpret_cast(msg), sizeof(msg) - 1,
                     get_std_string_pointer_and_length(alice.get_name()));
}

Мы снова собираем и перезапускаем, и, о чудо, реализация на Rust теперь выводит имя:

alice (69b7c41491ccfbd28c269ea4091652d) says: hello, world!
Comment count: 1
alice ([69, b7, c4, 14, 9, 1c, cf, bd, 28, c2, 69, ea, 40, 91, 65, 2d]) says: hello, world!
Comment count: 2

В качестве альтернативы, если мы не можем или не хотим изменять сигнатуру Rust, мы можем сделать вспомогательную функцию C++ get_std_string_pointer_and_length с соглашением C и принять указатель на void, чтобы Rust мог вызывать эту вспомогательную функцию самостоятельно, с затратами на многочисленные приведения типов в и из void*.

Улучшение ситуации с std: string

  • Вместо того чтобы моделировать std::string как массив байтов, размер которого зависит от платформы, мы могли бы переместить это поле в конец класса C++ и полностью удалить его из Rust (поскольку оно не используется там). Это нарушило бы равенство sizeof(User) == sizeof(UserC), теперь будет sizeof(User) - sizeof(std::string) == sizeof(UserC). Таким образом, layout будет точно таким же (до последнего поля, что вполне нормально) между C++ и Rust. Однако это приведет к нарушению ABI, если внешние пользователи зависят от точного layout класса C++. Конструкторы C++ также придется адаптировать, так как они полагаются на порядок полей. Этот подход по сути аналогичен функции гибкого массива в C.

  • Если выделение памяти дешевое, мы можем хранить имя как указатель: std::string *name; на стороне C++, а на стороне Rust — как указатель на void: name: *const std::ffi::c_void, так как указатели имеют гарантированный размер на всех платформах. Преимущество в том, что Rust может получить доступ к данным в std::string, вызвав вспомогательную функцию C++ с соглашением о вызовах C. Однако некоторым может не понравиться использование «голого» указателя в C++.

Заключение

Мы успешно переписали метод класса C++. Это отличная техника, потому что класс C++ может содержать сотни методов в реальном коде, и мы можем переписывать их по одному, не нарушая и не касаясь других.

Большое замечание: чем больше специфичных для C++ функций и стандартных типов использует класс, тем сложнее применять эту технику, потому что требует вспомогательных функций для преобразования из одного типа в другой и/или многочисленных утомительных приведений типов. Если класс C++ по сути является структурой C и использует только типы C, то это будет очень просто.

Тем не менее, я часто применял эту технику на работе и очень ценю ее относительную простоту и возможность инкрементального подхода.

Все это также можно теоретически автоматизировать, например, с помощью tree‑sitter или libclang для работы с AST C++:

  1. Добавить проверку в конструктор класса C++, чтобы убедиться, что он является standard-layout классом, например: static_assert(std::is_standard_layout_v); Если проверка не проходит, пропускаем этот класс — он требует ручного вмешательства.

  2. Сгенерировать эквивалентную структуру Rust, например, структуру UserC.

  3. Для каждого поля класса C++/структуры Rust добавить проверку, чтобы убедиться, что layout одинаковый: static_assert(sizeof(User) == sizeof(UserC)); static_assert(offsetof(User, name) == offsetof(UserC, name)); Если проверка не проходит, то завершаем работу.

  4. Для каждого метода C++ сгенерировать эквивалентную пустую функцию Rust, например, RUST_write_comment.

  5. Разработчик реализует функцию Rust. Или ИИ. Или что-то еще.

  6. Для каждого места вызова в C++ заменить вызов метода C++ на вызов функции Rust: alice.write_comment(..); становится RUST_write_comment(alice, ..);

  7. Удалить методы C++, которые были переписаны.

И вуаля, проект переписан!

© Habrahabr.ru