Search
Write a publication
Pull to refresh

Fail Case: Реализация полиморфизма без virtual на C++: концепты, трейты и Ref (и почему я отказался от этого подхода)

Level of difficultyMedium
Reading time6 min
Views4.5K

!!! ВНИМАНИЕ, БЫЛА ПРОИЗВЕДЕНА ПЕРЕПРОВЕРКА ЗАМЕРОВ
Я провёл повторные замеры на корректно сконфигурированной сборке и с ужасом обнаружил, что реализация на Ref проигрывает ~20–30 % в end-to-end-тесте. Флеймграф показал, что вызовы через Ref::_vtable не инлайнатся, и лишняя
индирекция съедает предполагаемую выгоду. Первоначальные цифры были получены из-за некорректной конфигурации. Статья остаётся в блоге как fail case, пример того, как попытка переиграть компилятор оборачивается регрессом. Если вам критична сырая задержка, используйте обычные virtual; 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).

UPDATE: значения некорректные. При правильной конфигурации end-to-end для старой версии оказывается быстрее на 20-30%. Остальные замеры оказываются сопоставимы.

Показатель

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

Новая версия

Δ

Δ, %

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

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

Tags:
Hubs:
+5
Comments12

Articles