Проблема

Виртуальные функции — удобный инструмент. Но удобство не бывает бесплатным.

В одном из проектов (обычный бекенд, обработка событий) профилировщик показал, что около 30% времени уходит на вызовы виртуальных методов. Горячий цикл, миллиарды итераций. Каждый вызов — косвенный прыжок через vtable, плюс объекты раскиданы по куче.

Обычно на это забивают. Но когда 30% времени — уже не забить.

Типичная реализация

cpp

struct IEvent {
    virtual void handle() = 0;
    virtual ~IEvent() = default;
};

struct MouseEvent : IEvent { void handle() override { /* ... */ } };
struct KeyboardEvent : IEvent { void handle() override { /* ... */ } };

std::vector<std::unique_ptr<IEvent>> events;
for (auto& e : events) e->handle();

Красиво, расширяемо, но медленно.

Что предлагает C++17

std::variant + std::visit. Вместо наследования — набор независимых типов.

cpp

struct MouseEvent { void handle() { /* ... */ } };
struct KeyboardEvent { void handle() { /* ... */ } };

using Event = std::variant<MouseEvent, KeyboardEvent>;

void handle(const Event& e) {
    std::visit([](const auto& event) { event.handle(); }, e);
}

std::vector<Event> events;
for (auto& e : events) handle(e);

Как это работает

std::visit генерирует switch по индексу типа, который хранится внутри variant. Никаких виртуальных таблиц. Компилятор видит все возможные вызовы и может инлайнить их без проблем.

Плюс объекты теперь лежат в векторе непрерывно — кэш процессора доволен.

Бенчмарки

100 000 объектов, GCC 13.2, -O2.

Подход

ns на вызов

virtual

6.8

variant + visit

0.65

Разница — порядок. Я перепроверял на разных компиляторах, цифры стабильные.

Ограничения, которые стоит знать

Перед тем как переписывать всё подряд, посмотрите на минусы:

  • Компиляция. Да, дольше. На проекте с 10 типами в варианте и 20 вызовами visit сборка замедлилась на 30-40%.

  • Диагностика. Ошибка внутри visit выдаёт тонну шаблонного текста. Привыкать придётся.

  • Рекурсия. std::variant не может содержать сам себя. Для деревьев нужен std::unique_ptr в обёртке.

  • Набор типов фиксирован. Если типы добавляются во время выполнения (плагины, загрузка из DLL) — этот подход не для вас.

Когда применять

variant + visit имеет смысл, если:

  • Типов немного, и их набор стабилен (до 10-15).

  • Вызовы в горячих циклах, на счету каждая наносекунда.

  • Вы готовы пожертвовать временем компиляции и удобством диагностики.

В остальных случаях виртуальные функции остаются хорошим выбором.

Альтернативы

Иногда проще использовать std::get_if:

cpp

if (auto m = std::get_if<MouseEvent>(&e)) m->handle();
else if (auto k = std::get_if<KeyboardEvent>(&e)) k->handle();

Но это неудобно при росте количества типов, и компилируется хуже.

Итог

Подход не нов, но до сих пор недооценён. 10-кратное ускорение без смены архитектуры — серьёзный аргумент, чтобы хотя бы попробовать. Главное — не фанатизировать и смотреть на задачу трезво.