Я занимаюсь разработкой С++ фреймворка для построения торговых систем. Идейно, он предоставляет строительные блоки, на основе которых можно реализовать свой сборщик маркет‑данных, торговую стратегию, систему маркет‑мейкера или любой вспомогательный сервис.

Месяц назад я выложил первую минорную версию и отправился собирать фидбэк на профильных ресурсах. Отклик оказался живым, проект оброс новыми сущностями и ближе подошёл к требованиям индустрии.

Одним из вопросов был: «почему так много virtual?».
Действительно, при проектировании я выбрал классическое ОО‑наследование с виртуальными функциями, ради скорости прототипирования и читаемости иерархий. К тому же для некоторых подсистем (например, коннекторы) фреймворк оставляет только интерфейсы, а реализации полностью отдаёт на сторону пользователя.

Но у каждой абстракции есть своя цена. В случае виртуальных таблиц это ухудшение кеш‑локальности, рост промахов кеша и дополнительные проблемы с предсказателем переходов. Мне требовалась альтернатива, которая реально улучшала бы ключевые метрики. Доказательством улучшения должны были стать замеры на демо‑приложении, включённом в репозиторий.

Первая идея, которая пришла мне в голову - монотипы с ручными vtable. Это не новый приём, его можно встретить в folly::poly, LLVM и Unreal Engine. До C++20 у подхода был главный минус, слабая типовая безопасность. Для решения этой проблемы я решил использовать концепты.

Каждая сущность в новой модели описывается триадой «концепт, трейт, хендл». Концепт формулирует требования, трейт генерирует статическую vtable, а хендл, как монотип, играет роль универсальной полиморфной ссылки. Но где хранить конкретный объект?

Первая реализация и проблема «протухающих» view

В первом варианте реализации я поделил хранение на две политики: либо объект лежит во внутреннем буфере хендла, если влезает, либо аллоцируется во внешней памяти и передаётся в хендл снаружи. Всё шло хорошо, пока не возникла необходимость в view - невладеющем дескрипторе на тот же объект. Я хотел обращаться с хендлом как с value‑типом, чтобы прозрачно контролировать время жизни, но при inline‑хранении после каждого перемещения view терял актуальность. Например, шины данных должны были получать view на стратегии, чтобы передавать в них рыночные события, но при текущем подходе это не представлялось возможным.

Итерация 2: fat pointer + аллокатор

Для решения описанной проблемы в итоге я отказался от внутреннего буфера. Теперь каждый хендл содержит using Allocator = ..., а создание идёт через фабричную функцию, которая знает, как этот Allocator использовать. Сейчас в качестве аллокатора используется простой фри‑лист. Сам хендл я назвал Ref: по сути это fat-pointer - пара void* + указатель на vtable.

Реализация Ref
template <typename Trait>
class Ref {
 public:
  using VTable = typename Trait::VTable;

  template <typename Impl>
  static Ref from(Impl* ptr) {
    static constexpr VTable vt = Trait::template makeVTable<Impl>();
    return Ref{ptr, &vt};
  }

  template <typename T> T& get() const { return *static_cast<T*>(_ptr); }
  const VTable* vtable() const noexcept { return _vtable; }
  void* raw() const noexcept { return _ptr; }

 private:
  Ref(void* p, const VTable* v) : _ptr(p), _vtable(v) {}
  void* _ptr{};
  const VTable* _vtable{};
};

Как выглядят трейт и vtable

Внутри Flox VTable это constexpr-структура, в которой каждый элемент - обычный указатель на функцию. Для любого метода интерфейса хелпер meta::wrap<&T::method>() порождает свободную функцию вида R (*)(void* self, Args...). Этот метод статически каррирует this: внутри выполняется static_cast<T*>(self)->method(args...), что превращает метод класса в C-style функцию нужной сигнатуры.

Пример простейшей триады, описывающей тип подсистемы
template <typename T>
concept Subsystem = requires(T t) {
 { t.start() } -> std::same_as<void>;
 { t.stop() } -> std::same_as<void>;
};
struct SubsystemTrait {
  struct VTable {
    void (*start)(void*);
    void (*stop)(void*);
  };

  template <typename T>
    requires concepts::Subsystem<T>
  static constexpr VTable makeVTable() {
    return { meta::wrap<&T::start>(), meta::wrap<&T::stop>() };
  }
};

class SubsystemRef : public RefBase<SubsystemRef, SubsystemTrait> {
 public:
  using RefBase::RefBase;
  void start() const { _vtable->start(_ptr); }
  void stop()  const { _vtable->stop(_ptr);  }
};

Агрегация трейтов: когда одного интерфейса мало

Полиморфизм между несколькими интерфейсами реализован композицией таблиц. Составная vtable хранит адрес вложенной таблицы, фактически вкладывая один интерфейс в другой. Поскольку Ref<Trait> это простая пара {void* object, const VTable* table}, достаточно вернуть вложенный указатель через SomeTrait::VTable::as<OtherTrait>(), чтобы та же самая память интерпретировалась как Ref<OtherTrait> без копирования или преобразований. Такое соглашение действует во всех трейтах и унифицирует переход между слоями абстракции по всему фреймворку.

Ниже фрагмент, показывающий, как MarketDataSubscriberTrait объединяет базовый SubscriberTrait с методами для тиковых событий. Благодаря полю subscriber внутри собственной vtable ссылка легко понижает себя до базового интерфейса без лишней логики.

Реализация MarketDataSubscriberTrait
template <typename T>
concept MarketDataSubscriber =
   Subscriber<T> &&
   requires(T t, const BookUpdateEvent& b, const TradeEvent& tr, const CandleEvent& c) {
     { t.onBookUpdate(b) } -> std::same_as<void>;
     { t.onTrade(tr) } -> std::same_as<void>;
     { t.onCandle(c) } -> std::same_as<void>;
   };

struct MarketDataSubscriberTrait {
  struct VTable {
    const SubscriberTrait::VTable* subscriber;
    void (*onBookUpdate)(void*, const BookUpdateEvent&);
    void (*onTrade)(void*, const TradeEvent&);
    void (*onCandle)(void*, const CandleEvent&);

    template <typename Trait>
    const typename Trait::VTable* as() const {
      if constexpr (std::is_same_v<Trait, SubscriberTrait>)
        return subscriber;
      static_assert(sizeof(Trait) == 0, "Trait not supported");
    }
  };

  template <typename T>
    requires concepts::MarketDataSubscriber<T>
  static constexpr VTable makeVTable() {
    static constexpr auto sub = SubscriberTrait::makeVTable<T>();
    return {
        &sub,
        meta::wrap<&T::onBookUpdate>(),
        meta::wrap<&T::onTrade>(),
        meta::wrap<&T::onCandle>()
    };
  }
};

class MarketDataSubscriberRef : public RefBase<MarketDataSubscriberRef, MarketDataSubscriberTrait> {
 public:
  using RefBase::RefBase;
  SubscriberId   id() const { return _vtable->subscriber->id(_ptr); }
  SubscriberMode mode() const { return _vtable->subscriber->mode(_ptr); }
  void onTrade(const TradeEvent& ev) const { _vtable->onTrade(_ptr, ev); }
  void onBookUpdate(const BookUpdateEvent& e) const { _vtable->onBookUpdate(_ptr, e); }
  void onCandle(const CandleEvent& ev) const { _vtable->onCandle(_ptr, ev); }
};

Цифры, к которым всё велось

Если совсем коротко: +19% к количеству обрабатываемых событий на демо за то же время (30 секунд прогон).
Ниже приведены усреднённые результаты десяти прогонов того же демо-приложения (старый virtual-подход против нового Ref).

Показатель

Старая версия

Новая версия

Δ

Δ, %

публикация события

2760 нс

800 нс

−1960 нс

−71

onTrade стратегии

960 нс

330 нс

−630 нс

−65

end‑to‑end за тик

4600 нс

2450 нс

−2150 нс

−47

обработано сообщений

140k

167k

+27k

+19

L1‑D miss ratio

3.69%

2.72 %

−0.97 п. п.

−26

instr / msg

360k

280k

−80k

−22


Более синтетический тест (микробенч, ссылка на gist внизу) показывает 17.09 циклов против 14.82 циклов на один вызов, это примерно 13% экономии.

Эпилог

Концепты плюс ручные vtable позволяют добиться полиморфизма без виртуальных методов. Возможность агрегировать трейты даёт compile‑time композицию интерфейсов. Если проводить параллели, решение похоже на dyn Trait в Rust.

Кому интересно покопаться глубже, ссылки ниже.

Сам Flox живёт по этому адресу. Подключайтесь, собирайте свои системы, делитесь опытом.

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


  1. Kelbon
    09.07.2025 12:28

    к сожалению сбился со счёта сколько раз это было реализовано, что ж, кину ещё раз ))
    https://github.com/kelbon/AnyAny


    1. wataru
      09.07.2025 12:28

      А не моглиу бы вы сказать пару слов о том, как это работает и почему это эффективнее вирутальных функций? Какой-то аналог vtable-то все-равно должен быть, ведь программа же должна по объекту как-то в реалтайме выбрать, какую же функцию вызвать. Т.е. указатель на метод в объекте будет.


      1. Kelbon
        09.07.2025 12:28

        эффективнее виртуальных функций

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

        Эффективность тоже можно получить за счёт того что не выделяется память под маленькие объекты и что указатель на значение лежит не в объекте, а рядом с указателем на vtable, что позволяет их грузить одновременно, а не по очереди


      1. rukhi7
        09.07.2025 12:28

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

        Класический пример когда вы создаете какой-нибудь TenantStore

        как здесь например, будет ли вас заботить эффективность виртуальных функций? Очень сомневаюсь!


  1. Melirius
    09.07.2025 12:28

    О, Rust в C++ завезли!


  1. goglom
    09.07.2025 12:28

    Не AnyAny едины, есть еще похожая реализация этого подхода, которая вроде как обещает избавить от ручного заполнения VTable на каждый трейт (не ииспользовал в бою, но выглядит интересно на первый взгляд):

    https://github.com/microsoft/proxy

    Но вообще, с рефлексией C++26 это дело можно будет здорово так автоматизировать, осталось только дождаться реализаций))