Привет, Хабр! Меня зовут Илья Андреев, я старший программист в компании Syntacore. Вы, наверно, слышали, что виртуальные функции в C++ пользуются дурной славой — а может, и сами придерживаетесь о них не самого лучшего мнения. В этой статье, подготовленной совместно с Константином Владимировым, я в некоторой степени выступлю адвокатом виртуализации.
Мы начнем с вводной части о статическом и динамическом полиморфизме, рассмотрим факторы, влияющие на девиртуализацию, и ее примеры разной сложности — в том числе те, что мы используем в реальной разработке. А напоследок познакомим вас со спекулятивной девиртуализацией и дадим рекомендации, как подходить к виртуальным функциям в разработке на C++.

Статический и динамический полиморфизм
Начнем с определения полиморфной функции. Полиморфной функцией называется функция, у которой есть хотя бы один полиморфный аргумент. Полиморфным называется аргумент, который для разных вызовов функции может иметь разные типы.
Статический полиморфизм разрешается во время компиляции: в зависимости от типов аргументов инстанцируется соответствующая функция. В примере ниже foo(T x)
— это шаблонная функция, x
принимает разные типы в зависимости от разных инстанцирований функции:
template <typename T> int foo(T x); // полиморфный аргумент это x
При динамическом полиморфизме мы создаем виртуальную функцию. В ней полиморфным аргументом выступает указатель this
, который явно не указывают в аргументах метода. В зависимости от того, для типа Base
или Derived
вызвали метод, у него будет разный динамический тип.
struct Base {
virtual int foo(); // полиморфный аргумент это this
virtual ~Base();
};
struct Derived : Base { int foo() override; }; // this может быть Derived*
Представим, что одну и ту же проблему вы можете решить и статическим, и динамическим полиморфизмом. По умолчанию выбор должен быть в пользу полиморфизма статического, поскольку динамический полиморфизм — это сложное решение, которое надо аргументировать. Но есть ряд сценариев, где обосновано использование именно динамического полиморфизма. Первые три из них могут вызвать справедливые возражения, четвертый же — самый убедительный.
Переиспользовать интерфейс базового класса для разных производных классов для вызова через косвенность. Но, кажется, мы тут можем выкрутиться — например, через CRTP, — так что динамический полиморфизм обоснован лишь в некоторой степени.
Вернуть что-то из функции в зависимости от условия времени исполнения. Предположим, функция внутри должна создать некий виджет, но вы на этапе компиляции еще не знаете какой. Вы знаете только список того, что функция возвращает. Здесь логично вернуть base-указатель, но можно выкрутиться с помощью std::variant
.
Переиспользовать реализацию (особенно в ромбовидной схеме). Без динамического полиморфизма вы обречены на попадание подобъекта по всем путям в производный класс. Динамический полиморфизм же позволяет построить единый общий подобъект ценой того, что на этапе компиляции будет неизвестно его смещение. Так вы будете вынуждены потратить чуть больше времени на каждый вызов функции и местами на неприятную работу с dynamic_cast
, но зато гарантированно получите одно вхождение общей переиспользуемой базы, что иногда очень важно.
Сложить в один контейнер много разнородных объектов. В C++ контейнеры обычно хранят объекты одного типа. Но что, если нужно поместить в std::vector
элементы разных типов? Здесь на помощь приходит наследование. Если все типы наследуются от базового класса Base
, их можно хранить в контейнере как указатели на базовый класс — std::vector<Base*>
. Хотя статический тип в контейнере только один, Base*
, во время выполнения каждый элемент можно привести к его исходному типу с помощью динамического приведения, dynamic_cast
, или вызвать виртуальную функцию.
Когда мы создаем унаследованный объект класса Derived
или объект базового типа Base
, и в том и в другом классе лежит указатель на vptr — таблицу виртуальных методов — который заполняется в конструкторе. В таком случае конструктор при создании сможет сообщить: «Я Derived
, даже если вы обращаетесь ко мне как к Base
-указателю».

Цена виртуальных функций
В разработке на C++ распространен миф, что виртуальные функции «дороги» сами по себе из-за своего косвенного вызова: на микроархитектурном уровне его сложнее предсказать, что приводит к дополнительным затратам. Это не совсем так, о чем у Константина Владимирова был отдельный доклад «Цена абстракции». В докладе он осветил некоторые заблуждения о дороговизне виртуальных функций: определенная цена есть, но именно микроархитектурные эффекты на нее оказываются не столь существенны. Если просто вставить в виртуальную функцию shuffle-массив из десяти элементов, это полностью съест весь оверхед косвенного вызова.
Тем не менее все еще кажется, что виртуальные функции — это дорого, пусть и по другой причине. Где могут быть эти причины? На цену любой абстракции влияют трое: сама микроархитектура, компиляторы и библиотеки, а также семантика языка программирования.
Сказанное выше исключает первый фактор дороговизны, но остается еще два. И дороговизна виртуальных функций проявляется на втором: они мешают компиляторам делать очень важные оптимизации — например, inline. Это дает чудовищный проигрыш в производительности, поскольку inline разблокирует другие оптимизации, которые компилятор мог бы сделать вокруг заинлайненной функции. Для иллюстрации приведу пример, в котором GCC 15.1 генерирует 314, а Clang 20.1 — 26 ассемблерных инструкций.
Вот еще пример подобного проигрыша.
Derived *vd = откуда-то получили;
int sum = 0;
for int i = 0; i < NBMK; ++i);
sum += vd->bar(NCALL); // виртуальный вызов
Здесь мы не можем проинлайнить Derived::bar
, так как не уверены, что это не один из пока неизвестных нам наследников. Видим просадку производительности:

Чтобы улучшить положение дел, современные компиляторы, где могут, пытаются применить девиртуализацию.
Теперь рассмотрим третий фактор цены абстракции — семантику языка программирования. Если бы в C++ на уровне языка не было такой абстракции, как виртуальная функция, то у компилятора не было бы данных для девиртуализации. Для него это был бы некий вызов по указателю на функцию. С «неким» вызовом компилятор, понятное дело, не может сделать ничего: это полный points-to-analyse, очень сложная задача.
int Base::calc(); // виртуальная
int Derived::calc(); // виртуальная
int call() {
Base* A = new Derived();
// res = call vtable [type(A)]
int res = A ->calc();
return res;
}
У виртуальной функции есть конечное число точек вызова в пределах одного модуля, которые компилятор может определить. Если в компиляторе мы можем взять вызов по виртуальной таблице, используя тип A как индекс, и заменить его просто на вызов Derived::calc
, то дальше мы разблокируем остальные оптимизации. Это легко сделать, если тип объекта A
, например, известен статически.
int Base::calc();
int Derived::calc();
int call() {
Base* A = new Derived();
// res = call Derived::calc
int res = A ->calc();
return res;
}
Где компилятор проводит девиртуализацию? И во фронтенде, и в миддленде. Для справки: фронтенд компилятора — это та его часть, где проходит лексический анализ, препроцессинг, синтаксический анализ и семантический анализ. Миддленд — где проходит машинно-независимая оптимизация на разных уровнях промежуточного представления (ниже выделено красным).

Есть простые случаи девиртуализации, когда справится даже фронтенд:
struct Base {
virtual int foo();
virtual ~Base();
};
struct Derived : public Base {
int foo() override final { return 42; }
int bar(Derived *vd) {
// девиртуализуется не доходя до IR
return vd->foo();
}
Программист поставил final
, и теперь компилятор знает, что это последнее переопределение foo
. Ставить public
не нужно, потому что это наследование от структуры, но поставить public
не будет ошибкой. Ставить override
тоже не нужно, потому что final
это контролирует, но поставить, опять же, не ошибка.
Вызов vd->foo
девиртуализуется, не доходя до промежуточного представления. Фронтенд на уровне синтаксических деревьев все видит. Если скомпилировать это на уровне оптимизации O0
, мы увидим прямой вызов.
bar (Derived*)
addi sp, sp, -32
sd ra, 24(sp)
sd s0, 16(sp)
addi s0, sp, 32
sd a0, -24(s0)
ld a0, 24(s0)
call Derived::foo() // прямой вызов
ld ra, 24(sp)
ld s0, 16(sp)
addi sp, sp, 32
ret
Но в некоторых случаях фронтенду становится сложно. В примере ниже требуется девиртуализовать вызов функции foo
внутри структуры B
:
struct B {
virtual int foo() { return foo() + 1 ; } // фронтенд не справляется
virtual ~B();
};
struct D: public B {
int foo() override { return 1; }
};
int main() {
struct D d; // нужен inline конструктора
return d.B::foo(); // нужен inline B:foo
}
Здесь из B
в main
происходит вызов B::foo
, а дальше из него — вызов foo
, но уже виртуальный. Он зависит от динамического типа. В данном случае динамическим типом будет D
. И вызов foo
внутри функции foo
в структуре B
— это вызов функции foo
из структуры D
.
Нам со стороны это понятно, но не фронтенду: для него это слишком сложная цепочка рассуждений. Нужно сделать это на уровне промежуточного представления, где должны отработать очень много оптимизаций. Один девиртуализатор здесь не справится.
Вот более простой пример того, как совместно работает много оптимизаций в компиляторе:
namespace {
struct X {
virtual int foo() {
return 42;
}
virtual ~X();
};
} // анонимное пространство имен
int test() {
struct X a, *b = &a;
return b->foo();
}
В этом случае анонимное пространство имен не хуже, чем ключевое слово final
, гарантирует отсутствие неожиданных наследников в других единицах трансляции. В других модулях наследников уже не будет. В этом простом примере мы создаем объект a
, указатель b
как адрес a и возвращаем вызов b->foo
.
Чтобы это сделать, в компиляторе взаимодействуют почти все оптимизации:
Inline подставляет конструктор.
Copy propagation заменяет
b
на&a
.Девиртуализатор заменяет вызов на директный.
DCE убирает механику косвенного вызова.
Inline снова включается и подставляет тело директного вызова.
DCE убирает остатки, оставляя от тела метода
test
одну строчку:return 42
.
Главная задача девиртуализатора — собрать возможные VCT (Virtual Call Targets), то есть цели для виртуального вызова.
struct B {
virtual int foo() {
return foo() + 1 ; // VCT[B::foo] = { B::foo, D::foo }
}
virtual ~B();
};
struct D final : public B {
int foo() override { return 1 ; } // VCT[D::foo] = {}
};
В этом примере структура B
содержит функцию foo
, возвращающую foo + 1
. Надев «волшебные очки», мы видим, что этот вызов на самом деле полиморфный: неявный аргумент this
может иметь разный динамический тип, и в зависимости от этого будет вызвана либо B::foo
, либо D::foo
.
Для B::foo
таблица VCT состоит из двух вхождений, а для D::foo
— из нуля вхождений, потому что D::foo
ничего не вызывает. В каком случае мы можем девиртуализовать? Если после всех операций у нас в VCT остался один target. Это очень интересная тема, подробно описанная в книге Константина Владимирова «Оптимизирующие компиляторы: структура и алгоритмы», включая базовый алгоритм, который строит VCT как структуру.
Далее мы выйдем за рамки этой книги и остановится на алгоритмах более совершенной девиртуализации. Все описываемые далее подходы уже опробованы и включены в LLVM-часть Syntacore DevToolkit — можно скачать и ознакомиться бесплатно.
Более сложная девиртуализация
Посмотрим пример более сложной девиртуализации:
struct Base {
virtual int foo();
virtual ~Base();
};
struct Derived : public Base {
int foo() override { return 42 ; }
};
int bar(Derived *vd) {
return vd->foo();
}
Поскольку программист не указал модификатор final
, компилятор должен учитывать потенциальное существование производных классов в других единицах трансляции. Это создает неограниченное множество возможных целевых вызовов (call targets), блокируя возможность простой девиртуализации. Из вызова vd->foo()
компилятор строит следующий ассемблер:
bar (Derived*):
ld a1 , 0(a0)
ld a5 , 0(a1)
jr a5
В примере наблюдаем два последовательных обращения к памяти: сначала считывается адрес виртуальной таблицы, затем содержимое таблицы (адрес функции). Только после загрузки адреса функции в регистр a5 становится возможным выполнить переход jr a5
. Если в работе придерживаться только базовых алгоритмов, описанных в книге Константина, то с этим уже ничего не сделать.
Как решить проблему? Попробуем полностью убрать виртуальные функции:
struct Base { // просто тип, а не база
int foo() { /* … */ }
};
struct Derived { // просто тип, а не наследник
int foo() { /* … */ }
};
template <typename T> int bar(T&& vd) {
return vd.foo();
}
Теперь у нас есть обычная структура Base
с методом foo
, структура Derived
с методом foo
и шаблонная функция, которая в зависимости от типа вызывает соответствующий метод. Казалось бы, мы решили все проблемы: нет косвенности, нет виртуальных функций. Но вместе с этим мы утратили свойство динамического полиморфизма: построили множество перегрузок для каждого типа, Base
или Derived
, и статически знаем, какую функцию хотим вызвать.
Вспомним четыре сценария из начала статьи. Что, если в данном случае мы хотим сложить в один контейнер много разнородных объектов и функционально получить такой код:
int call(std::vector<Base*> arr) {
int sum = 0;
for (auto &&x : arr)
sum += x->foo();
return sum;
}
С виртуальными функциями это реализуется просто: мы используем вектор указателей на базовый класс, итерируемся по нему и выполняем необходимые операции. Но как организовать аналогичную логику без использования наследования?
using var = std::variant<Derived, Base>;
int call(std::vector<var>& arr) {
int sum = 0;
for(auto &&x : arr) {
auto lambda = [](auto &&x) { return x.foo(); };
sum += std::visit(lambda, x);
}
return sum;
}
Существует замечательный библиотечный тип std::variant
— аналог union, безопасный относительно типов. Мы можем объявить alias
на вариант из интересующих нас типов — на Derived
или на Base
. Тогда мы передаем в функцию vector
из наших вариантов, можем по нему итерироваться, с помощью lambda + std::visit
построить множество перегрузки и вызвать необходимый метод.
auto lambda = [](auto &&x) {
return x.foo();
};
return std::visit(lambda, x);
В ассемблере пара lambda + std::visit
выглядит довольно просто:
# ...
bne a5,zero,.L2
tail Base::foo()
.L2:
tail Derived::foo()
# ...
Компилятор генерирует switch
по возможному индексу типа. Если этот индекс совпадает с индексом динамического типа, происходит условный переход на метку, где мы видим прямую подстановку функции, то есть прямой вызов.
Кажется, мы решили все проблемы. Нет виртуальных функций, нет наследования. Если мы хотим переиспользовать интерфейс, то используем CRTP, тоже без виртуальных методов. Также научились «складывать все игрушки в один ящик». У нас вроде бы получился «настоящий» динамический полиморфизм.
Виртуальные функции vs. std::variant
В приведенном выше примере мы принципиально изменили подход: заменили механизм виртуальных функций (языковые средства) на библиотечное решение с использованием std::variant
. Между этими подходами существуют фундаментальные различия.
Как я писал выше, когда мы передаем lambda
в std::variant
, то формируем множество перегрузок для каждого типа. Таким образом, std::variant
у нас открыт для расширения множества операций над ним, но закрыт для расширения множества типов. В противовес этому виртуальные функции открыты для расширения множества типов, но закрыты для расширения множества операций.
Это тонкое различие хорошо видно на примере двух компилируемых модулей ниже.
// A.cc
#include “interface.hpp”
struct A {...};
…
// B.cc
#include “interface.hpp”
struct B {...};
using Var = std::variant<B, A??>;
В модуле A.cc
определена структура A
, а в модуле B.cc
— структура B
. Мы хотим написать функцию, которая одновременно может работать и со структурой A
, и со структурой B
. Кажется, можно сделать alias
на std::variant
из типов B и A. Но что такое A
? В модуле B.cc
нет никакой информации про тип A
.
Со мы используем всю информацию о паре «тип и метод»: во множестве перегрузок эта информация указана явно. Мы в любой момент вызова знаем, из какого типа какой метод вызвали. Когда мы используем виртуальные функции, то эта информация недоступна программисту явно. Но на самом деле она есть — и хранится в таблице виртуальных функций.

Стандарт Itanium C++ VTT ABI определяет, как компиляторы могут конструировать эти таблицы. Например, что для любого объекта в памяти с виртуальными функциями в начале его памяти хранится указатель на таблицу виртуальной функции — в частности, на список всех его виртуальных методов. Также там хранится Offset
и typeinfo
, например, для dynamic_cast.
В LLVM IR таблицы виртуальных функций (VTable) удобно представлены в виде глобальных констант:
@vtable for Base = constant { [3 x ptr ] } { [3 x ptr]
[ptr null, ptr @typeinfo for Base, ptr @Base::foo() ] }
@typeinfo for Base = ...
@vtable for Derived = constant { [3 x ptr ] } { [3 x ptr]
[ptr null, ptr @typeinfo for Derived, ptr @Derived:: foo() ] }
@typeinfo for Derived =
Видно, что виртуальная таблица для Base
представляет собой некое множество указателей: указатель на offset == null
, typeinfo
для базы. Именно эта виртуальная таблица предназначена для Base
. В списке виртуальных функций в данном случае — только метод foo
. Виртуальная таблица для Derived
конструируется таким же способом, то есть имеет информацию для типа Derived
и список своих виртуальных методов Derived:: foo
.
Так как указатель в памяти объекта на виртуальную таблицу хранится в начале этого объекта, то логично предположить, что он записывается туда при конструировании. Как информация из виртуальных таблиц используется при конструировании? Очень просто. Мы загружаем эту информацию напрямую по указателю this
.
define void @Base::Base()(ptr %this) {
store ptr getelementptr ({ [3 x ptr ] },
ptr @vtable for Base, i32 0, i32 0, i32 2), ptr %this
ret void
}
define void @Derived::Derived()(ptr %this) {
call void @Base::Base()(ptr %this)
store ptr getelementptr ({ [3 x ptr ] },
ptr @vtable for Derived, i32 0, i32 0, i32 2), ptr %this
ret void
}
Здесь мы загрузили в конструкторе Base
указатель из виртуальной таблицы для базы по указателю this
. Что происходит при конструировании наследуемого класса? Вначале мы конструируем все базы — в нашем случае одну. После вызова call void @Base::Base()(ptr %this)
по указателю this
хранится указатель на виртуальную таблицу базы. Но потом мы перезаписываем эту информацию в соответствующей нам виртуальной таблице для Derived
. Так указатель на соответствующую виртуальную таблицу записывается в память объекта.
А как информация используется непосредственно при вызове виртуального метода?
int call(Base *b) { // сниппет C++
return b->foo();
}
define i32 @call(ptr %b) { ; LLVM IR part
%vtable = load ptr, ptr %b ; @vtable for Base or @vtable for Derived
%0 = load ptr, ptr %vtable
%call = call i32 %0(ptr %b)
ret i32 %call
}
Никак не используется! На сниппете C++ у нас выполняется вызов виртуальной функции по указателю. А в миддленде, в части LLVM IR, ничто, кроме имени переменной, не указывает, что произошло обращение в память именно к виртуальным таблицам. На этом этапе мы никак не сможем отличить две загрузки в память и вызов от любых двух загрузок в память и вызова по указателю на функцию, что распространено в языке C.
Здесь на помощь приходит фронтенд. Именно фронтенд знает семантическое обращение в память: что это обращение не просто в память, а к виртуальной таблице. И если мы пометим все эти обращения специальными метаданными, используя информацию с фронтенда, то сможем предположить, что это обращение к виртуальной таблице для Base
или к виртуальной таблице для Derived
.
Спекулятивная девиртуализация
Допустим, программист может явно посмотреть виртуальную таблицу. Что можно сделать с этой информацией? Можно проверить указатели на функции: например, проверить указатель на пришедшую функцию и сравнить его с возможными кандидатами, известными нам. Ниже представлен псевдокод возможной проверки.
int call(B *b) {
if (&b->foo == &D::foo) {
auto *d = static_cast<D*>(b);
return d->D::foo();
}
if (&b->foo == &B::foo)
return b->B::foo();
return b->foo();
}
Если есть совпадение с прямым методом из D
, то есть D::foo
, то мы приводим указатель к нужному нам типу через static_cast
и напрямую вызываем метод D::foo
. Если же совпадение с методом из Base
, то мы напрямую вызываем метод из Base
. Для сохранения полной функциональности кода мы все еще оставляем вызов функции по адресу, если проверки на предыдущих шагах не увенчались успехом.
# ... a1 addr D::foo
beq a5, a1, .L3
# ... a1 addr B::foo
beq a5, a1, .L4
jr a5
.L3:
tail D::foo()
.L4:
tail B::foo()
В ассемблере мы видим switch
уже не по индексу типа, как в std::variant
, а по адресу на метод. В случае успеха — соответствующий условный переход на прямой вызов функции. Именно этого мы добивались с использованием std::variant
. Код в итоге получился почти такой же. Напомним, как выглядел ассемблер при невиртуальном полиморфизме:
# ...
bne a5, zero,
tail B::foo()
.L2:
tail D::foo()
// ...
При этом с «ручным» написанием кода мы также сохранили возможность для расширения множества типов. В эту функцию можно подать другой наследованный тип, не совпадающий ни с Base
, ни с Derived
. И он все еще выполнится ожидаемо, потому что мы оставили косвенный переход по виртуальному методу.
Это мог бы сделать программист, если у него есть информация о виртуальной таблице. Но у него ее нет, а есть она у компилятора. И это именно то, что и делает компилятор за программиста: проводит спекулятивную девиртуализацию.
Как устроена спекулятивная девиртуализация? Для каждого обращения в памяти, которое фронтенд пометил как обращение к виртуальным таблицам, мы восстанавливаем иерархию типов — кто от кого наследуется. Далее вычисляем смещение указателей на вызов, чтобы определить, какую именно функцию, какой метод мы вызываем — foo
, bar
или вообще деструктор. И собираем множество кандидатов на прямой вызов: переиспользуем ту же самую Virtual Call Targets, о которой я писал выше.
Для простой девиртуализации Virtual Call Target (VCT) должен содержать ровно одного кандидата, тогда как в наших последних примерах присутствует неограниченное множество потенциальных кандидатов. Однако для спекулятивной девиртуализации это не является ограничением, а, напротив, становится преимуществом. Используя внутренние эвристики, компилятор может упорядочивать кандидатов (например, по глубине наследования, помещая most-derived типы в начало очереди проверки), может ограничивать множество проверяемых кандидатов, избегая избыточных сравнений указателей, и проводить дополнительные оценки на множестве VCT.
При выборе кандидата для замены виртуального вызова на прямой необходимо проверить его ключевое свойство — возможность инлайнинга. Как отмечалось ранее, основная сложность заключается в невозможности прямой подстановки виртуальных вызовов, поэтому мы проверяем, допускает ли кандидат инлайнинг при прямом вызове. Проблемы могут возникнуть во многих случаях:
Кандидат определен в другом модуле.
Кандидат помечен как
noinline
.Реализация кандидата слишком сложна для получения преимуществ от инлайнинга.
Если нам удается заинлайнить этого кандидата, то добавляется рантайм-проверка на равенство указателей, после чего подставляется вызов функции. После инлайнинга открываются возможности для дополнительных оптимизаций компилятора, которые и обеспечивают основной прирост производительности исполняемого кода. Таким образом, из кода ниже с виртуальными функциями и не расставленным нигде final
мы можем получить почти такой же код, как на std::variant
, и ровно те же преимущества. Без дополнительных усилий и переписывания кодовой базы на std::variant
или другой подход.
struct B {
virtual int foo { return 42; }
virtual ~B();
};
struct D : B {
int foo () override { return 21; }
};
int call(B *b) {
return b->foo();
}
// ...инстанцирование B и D …
Вот как это выглядит в ассемблере:
# ... a1 addr D::foo
beq a5, a1, .L3
# ... a1 addr B::foo
beq a5, a1, .L4
jr a5
.L3
tail D::foo()
.L4
tail B::foo()
Но здесь есть нюансы. Рассмотрим простейший случай — структуру базы с виртуальным методом foo
. У нас есть кандидат, есть foo
, и мы его можем легко проверить на множестве кандидатов. Код ниже — это весь компилируемый модуль.
struct Base {
virtual int foo() {
return 42;
}
virtual ~Base();
};
int call(Base *b) {
return b->foo();
}
Если же посмотреть на ассемблер функции call
, спекулятивной и вообще какой-либо девиртуализации не происходит:
call(Base*):
ld a1, 0(a0)
ld a5, 0(a1)
jr a5
Что пошло не так? Я утверждаю, что смогу починить это с помощью простого советского одного символа: подчеркивания.
struct Base {
virtual int foo() {
return 42;
}
virtual ~Base();
} _; // вызвали конструктор
int call(Base *b) {
return b->foo();
}
В ассемблере:
call(Base*):
ld a1 ,0(a0)
ld a5, 0(a1)
#... a1 addr Base::foo
beq a5, a1, .L 3
jr a5
.L3
tail Base::foo
В этом модуле мы вызываем конструктор. Виртуальные таблицы используются только при конструировании объекта и его размещении в памяти. Если конструктор не вызывается, то и виртуальные таблицы не используются и не генерируются. А без виртуальных таблиц отсутствуют кандидаты для девиртуализации, поскольку анализировать нечего.
Спекулятивная девиртуализация уже работает в Syntacore DevToolkit по умолчанию. Во всех интересующих тестах и программах ухудшения производительности не наблюдалось. На тестах производительности SPEC INT 2006 во всех сценариях, где использовались виртуальные функции, мы наблюдали существенный прирост производительности просто «из коробки» — до 12%. Без дополнительных усилий со стороны программиста.

Итоги и рекомендации
Не бойтесь виртуальных функций. Часто программисты из страха перед виртуальными функциями начинают их заменять на какие-то хитро построенные библиотечные методы. Каждый раз они делают этим только хуже, потому что убирают из семантики языка важную информацию, которая может быть использована далее в оптимизирующем компиляторе.
Будьте осторожны, если вы заменяете языковые средства библиотечными. Причина здесь та же. Если используемые вами средства находятся внутри языка, то язык о них знает. Если о них знает язык, то компилятор тоже знает о них и может их использовать. У вас появляется третий игрок в цене абстракции — семантика языка — в дополнение к микроархитектуре и компилятору. Другими словами, у вас есть целых три руки, чтобы делать нужные вещи, не стоит отрубать одну из них. Кроме того, заменяя языковые средства библиотечными — динамический полиморфизм на что-то типа visitor pattern — вы рискуете сильно изменить архитектуру кода.
Обязательно сверьтесь с документацией вашего компилятора — относительно опций, которые управляют как минимум межпроцедурными оптимизациями. Посмотрите опции, которые управляют inline и девиртуализацией, если они у вас есть. Посмотрите на простых примерах, что из этого работает «из коробки». Это всегда очень полезно.
В крайнем случае не бойтесь заглянуть в ассемблер. Даже если это неизвестный вам ассемблер RISC-V. Сложных ассемблеров не бывает, там содержатся только очень простые вещи, которые легко читаются. Иногда одного вдумчивого взгляда в ассемблер на то, с чем компилятор не справился, достаточно, чтобы понять, ваша ли это ошибка или нужно ждать следующего апдейта компилятора.
Сейчас мы в YADRO активно ищем разработчиков на C++ в команду OpenBMC. Подробно описали задачи команды, роли и вакансии на отдельной странице.
aamonster
А компилятор может вначале применить спекулятивную девиртуализацию, а потом сказать "не пригодилось" (благодаря ей не открылись новые оптимизации) и откатить обратно?
XViivi
jit-компилятор мб смог бы, а вот aot только если в несколько проходов. ну или сделать какую-то статическую переменную на случай "ладно, не хочу". но не думаю, что aot-компилятороделы будут использовать такой динамический подход.
cpud47
Не очень понятно зачем. Если девиртуализацию сделать получилось — то её имеет смысл сделать. А если не получилось, то она просто некорректно.
Можно, конечно, заменять виртуальный вызов на if по vtable. Но для этого нужна статистика, а какие здесь vtable-ы вообще случаются. Это либо jit, либо pgo.