На работе я переписываю запутанный 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 <cstdint>
#include <cstdio>
#include <cstring>
#include <string>

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<User>);

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

Теперь переходим ко второму шагу: давайте определим эквивалентный класс на стороне 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 <cstdarg>
#include <cstdint>
#include <cstdlib>
#include <ostream>
#include <new>

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<User>);
    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<UserC *>(&alice),
                     reinterpret_cast<const uint8_t *>(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<const uint8_t *>(str.data()),
      .len = str.size(),
  };
}

// In main:
int main() {
    // [..]
  RUST_write_comment(reinterpret_cast<UserC *>(&alice),
                     reinterpret_cast<const uint8_t *>(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<User>); Если проверка не проходит, пропускаем этот класс — он требует ручного вмешательства.

  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++, которые были переписаны.

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

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


  1. lazy_val
    25.09.2024 17:33
    +13

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

    Зачем? )) Почему просто не переписать запутанный C++ код на распутанный?


    1. domix32
      25.09.2024 17:33

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


      1. KanuTaH
        25.09.2024 17:33

        похожего уровня безопасности

        Какой безопасности? Тут как бы стиль с реинтерпрет кастами одной структуры в другую (aliasing rules? не, не слышал) уже о многом говорит. И никакие стандарт лэйауты тут не гарантия ничего. Да, в safe rust чувак не сможет так писать, но как только он начнёт писать unsafe код тут только держись, он и там будет рассуждать "ну байты и есть байты, ачотакова".


        1. domix32
          25.09.2024 17:33

          Откуда у вас появляются касты, когда вы переписываете с плюсов на плюсы? В случае с Rust там только FFI и остаётся с приколами, но это закономерно. В какой-то момент FFI исчезнет, либо превратится в небезопасные обёртки над безопасными функциями и количество проблем, которые решаются из коробки несопоставимы с тем что есть у C++ даже с каким-нибудь gsl.


          1. KanuTaH
            25.09.2024 17:33

            Откуда у вас появляются касты, когда вы переписываете с плюсов на плюсы?

            ??? Вы о чем? При переписывании с плюсов на плюсы такие стремные касты ни к чему.

            превратится в небезопасные обёртки над безопасными функциями

            Которые в свою очередь являются "безопасными" обертками над небезопасными функциями, написанными неизвестно кем, и не исключено, что очередным чудаком, мыслящим в стиле "байты - они и в Африке байты".


  1. Apoheliy
    25.09.2024 17:33
    +6

    Lazy_val, тоже подумал, что автор сам что делал и предлагает простое решение сложной проблемы. Пару идей докинул. Потом всё удалил.

    Причина:

    Это перевод. Какой-то чел топит за мега-глючный переход с C++ на Rust. Особенно доставило, что в оригинальной статье есть ссылка на донат.

    В общем, всё как обычно :(.


    1. lazy_val
      25.09.2024 17:33
      +1

      класс C++ может содержать сотни методов

      Для начала, как мне кажется, стоит задать себе вопрос - а надо ли это куда-то переписывать?

      Понятно что в мире к сегодняшнему дню написаны тонны г@внокода. Его точно переносить куда-то надо?


  1. playermet
    25.09.2024 17:33
    +2

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

    Тем временем современный идиоматический ООП: существует ради полиморфизма подтипов, который реализуется виртуальными методами.

    И еще этот ужасный непереносимый type punning с переходом от плюсовых строк в массив непойми чего. Можно же просто дописать сишную обертку передаваемую как непрозрачный указатель, функции доступа к полям, и уже их прибиндить в требуемый язык, в данном случае в Rust.


    1. SpiderEkb
      25.09.2024 17:33
      +1

      Я работаю с платформой IBM i где есть такая штука как ILE - интегрированная языковая среда. В двух словах - можно написать кусок кода на одном языке, кусок на другом, а потом все это собрать в один программный объект (бинарник). Равно как программа на том же С линкуется из нескольких .obj файлов, только каждый .obj (здесь это называется модуль - module) написан на разных языках.

      Естественно, встает проблема стыковки интерфейсов - соответствие типов параметров, манглинг имен и т.п. Основные языки у нас - RPG, на котором пишется работа с БД и основная бизнес-логика и С/С++, на которых пишется всякое низкоуровневое ну и просто когда на С/С++ это написать проще и удобнее (RPG нельзя назвать универсальным языком).

      Так вот, RPG не поддерживает ООП ни в каком виде. По уровню это что-то классического Паскаля. И проблема стыковки с тем, что написано на С++ решается через extern "C" врапперы для нужных вызовов.

      В принципе, тут вполне реализуется даже работа с объектами - внутри С++ модуля делаем таблицу созданных объектов, враппер, создающий объект, вызывает конструктор, помещает указатель на созданный объект в таблицу и возвращает индекс в таблице (handle объекта). Ну а врапперы для методов вызываются с передачей этого самого handle и потом уже вызывают нужный метод для соответствующего объекта из таблицы.

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


      1. domix32
        25.09.2024 17:33

        И в реализации это проще чем описанное в статье шаманство (ну мне так кажется).

        Да на самом деле ровно та же магия. Линковка в итоге все посклеит независимо в каком языке оно было скомпилировано, если лэйаут совпадает. Просто ржавых нюансов получается несколько больше. Возьми условный паскаль/аду, там тоже будут похожие приколы.


        1. SpiderEkb
          25.09.2024 17:33

          Тут дело в том, что не надо руками layout'ы совмещать. Все намного проще, враппер просто согласует интерфейсы.

          Ну вот есть плюсовая функция

          bool createQueObj(char* UsrQName, char* UsrQLib, eQueueType eSeq, 
                            char* UsrQDescr, int nMsgSize, int nKeyLen, 
                            int nInitMsgs, int nAddMsgs, int nMaxMsgs,
                            char* ErrorStr)

          К ней есть враппер

          extern "C" _RPG_ind USRQ_Create(char* __ptr128 Name, char* __ptr128 Lib, eQueueType eSeq, 
                                          char* __ptr128 Desc, int nMsgSize, int nKeyLen, int nInitMsgs, 
                                          int nExtMsgs, int nMaxMsgs, char* __ptr128 pError)
          {
            _RPG_ind rslt = _ind_on_;
            char UsrQLib[11];
            char UsrQName[11];
            char UsrQDescr[51];
            char ErrorStr[37];
          
            if (pError) memset(pError, ' ', 37);
          
            if (Desc == NULL || strlen(Desc) == 0) strcpy(UsrQDescr, "*BLANKS");
            rpg2sz(UsrQName,  Name, 10);
            rpg2sz(UsrQLib,   Lib,  10);
            rpg2sz(UsrQDescr, Desc, 50);
          
            if (!createQueObj(UsrQName, UsrQLib, eSeq, UsrQDescr, nMsgSize, nKeyLen, 
                              nInitMsgs, nExtMsgs, nMaxMsgs, ErrorStr)) {
              if (pError) memcpy(pError, ErrorStr, 37);
              sendMessageToMSGQ((PStructError)ErrorStr, DEFMSGQUE);
              rslt = _ind_off_;
            }
          
            return rslt;
          }

          И вот враппер уже вызывается из RPG - там его прототип описан уже на RPG как некая "внешняя процедура"

          dcl-pr USRQ_CreateQueue ind extproc(*CWIDEN : 'USRQ_Create') ;
            Name      char(10)                   const;                                  // Имя очереди
            Lib       char(10)                   const;                                  // Библиотека
            eSeq      int(10)                    value;                                  // Тип очереди queKeyd/queLIFO/queFIFO
            Desc      char(50)                   const;                                  // Описание
            nMsgSize  int(10)                    value;                                  // Макс. размер сообщения
                                                                                         // Максимально допустимое значение - 64000 байт
            nKeyLen   int(10)                    value;                                  // Размер ключа (игнорируется для не queKeyd)
                                                                                         // Максимально допустимое значение - 256 байт
            nInitMsgs int(10)                    value;                                  // Начальное количество сообщеий
            nExtMsgs  int(10)                    value;                                  // Колчество сообщений в приращении
            nMaxMsgs  int(10)                    value;                                  // Максимальное количество сообщений
            Error     char(37)                   options(*omit);                         // Ошибка
          end-pr;

          Аналогично и для врапперов, которые работают с объектами. Из RPG он будет вызываться с параметром hObj, а внутри вызовет нужный метод как objTbl[hObj]->func(...)

          И никакой возни с layouts (если у нас что-то изменилось внутри С++ части, то пока это не влияет на контракты внешних интерфейсов, на RPG части это вообще никак не скажется).

          Но тут еще вопрос изоляции (инкапсуляции) - все, что написано на С++ работает с теми объектами, которые характерны для С++. А врапперы вызываются из RPG и работают в терминах RPG. Таже std::string есть в С++, но ее нет в RPG. Там свои строки и они иначе релизованы. И со своими строками RPG работает весьма эффективно. А если мы попытаемся тащить туда std::string, то кроме кучи гимора не получим ничего. Поэтому из RPG во врапер приходит строка RPG, а С++ метод из врапера уже вызывается с std::string (условно говоря).

          Иными словами, все эти layouts в RPG напрочь никому не нужны - что оно с ними будет делать. Нужны только конкретные интерфейсы для выполнения конкретных действий с теми объектами, с которыми работает RPG часть.


          1. domix32
            25.09.2024 17:33

            То есть оно до кучи ещё и виртуальные методы резолвит? Или только плоские типы?

            А рядом с типом параметра это максимальный размер по указателю указан? или что значит char(10) в контексте декларации?


      1. playermet
        25.09.2024 17:33

        Ну так это и есть стандартный способ биндинга. В свое время я кучу сишных и пару плюсовых библиотек к luajit через ffi прибиндил. Практически нигде не требовалось прокидывать лейаут структуры. Из исключений сходу могу только Windows API вспомнить, но там структуры изначально сишные и их код просто в неизменном виде копировался в текст биндинга.


  1. orefkov
    25.09.2024 17:33
    +3

    Вот потому что не используете виртуальные функции, у вас и "сотни колбеков вздох". Такой подход я часто видел в C-программах, за неимением интерфейсов весь полиморфизм реализуется передачей колбеков. Часто даже делают аналог vtable, "закат солнца вручную".


  1. Siemargl
    25.09.2024 17:33
    +2

    Так вот что такое "без головной боли"... Не знал.

    Ага, это даже без виртуальных функций(автору они не нужны) и тем более без множественного [и виртуального] на следования.