Проблема
Виртуальные функции — удобный инструмент. Но удобство не бывает бесплатным.
В одном из проектов (обычный бекенд, обработка событий) профилировщик показал, что около 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-кратное ускорение без смены архитектуры — серьёзный аргумент, чтобы хотя бы попробовать. Главное — не фанатизировать и смотреть на задачу трезво.