Реализация полиморфизма без virtual на C++: концепты, трейты и Ref

от автора

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

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

Одним из вопросов был: «почему так много 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 живёт по этому адресу. Подключайтесь, собирайте свои системы, делитесь опытом.


ссылка на оригинал статьи https://habr.com/ru/articles/926282/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *