специфика C++ — как компилятор реализует virtualC++-specific — how the compiler implements virtual
Ключевое слово virtual — не языковая магия, а конкретный, полностью механический приём кодогенерации: скрытый указатель в каждом объекте плюс одна статическая таблица указателей на функции для каждого полиморфного класса. Стандарт C++ не требует именно такой реализации, но vptr/vtable — это то, что делают абсолютно все распространённые компиляторы (GCC, Clang, MSVC), с незначительными различиями в деталях layout.The virtual keyword isn't language magic — it's a concrete, fully mechanical code-generation technique: a hidden pointer in every object plus one static table of function pointers per polymorphic class. The C++ standard doesn't mandate this exact implementation, but vptr/vtable is what every mainstream compiler (GCC, Clang, MSVC) actually does, with minor differences in layout details.
vtable (virtual table) — это статический массив указателей на функции, по одному указателю на каждую виртуальную функцию класса. Он генерируется компилятором один раз на класс (не на объект) и живёт в бинарнике как обычные данные только для чтения. vptr (virtual pointer) — скрытое поле, которое компилятор добавляет в начало каждого объекта полиморфного типа; оно указывает на vtable того класса, которым объект реально является.vtable (the virtual table) is a static array of function pointers, one per virtual function the class declares. The compiler generates it once per class (not per object), and it lives in the binary as ordinary read-only data. vptr (the virtual pointer) is a hidden field the compiler inserts at the start of every object of a polymorphic type; it points at the vtable of whatever class the object actually is.
Itanium ABI: layout объекта и vtable (упрощённо, x86-64)Itanium ABI: object and vtable layout (simplified, x86-64)
В Itanium C++ ABI (GCC, Clang, все не-Windows платформы) vptr указывает не на самое начало массива указателей, а сразу на первую виртуальную функцию — служебные поля (смещение до вершины иерархии для множественного наследования и указатель на type_info для RTTI/dynamic_cast) лежат перед тем местом, куда указывает vptr, по отрицательным смещениям. MSVC ABI устроен немного иначе: RTTI-информация хранится не в самой vtable, а в отдельной Complete Object Locator структуре, на которую указывает ячейка перед vtable.In the Itanium C++ ABI (GCC, Clang, every non-Windows platform), the vptr doesn't point at the very start of the pointer array — it points straight at the first virtual function; the housekeeping fields (the offset to the top of the hierarchy, needed for multiple inheritance, and a pointer to type_info for RTTI/dynamic_cast) sit before where the vptr points, at negative offsets. The MSVC ABI is laid out slightly differently: RTTI information isn't stored in the vtable itself but in a separate Complete Object Locator structure, pointed to by a slot just before the vtable.
struct Shape {
virtual double area() const = 0;
virtual void draw() const {}
virtual ~Shape() = default;
};
struct Circle : Shape {
double r;
double area() const override { return 3.14159 * r * r; }
// draw() не переопределён — слот в vtable Circle указывает на Shape::draw// draw() isn't overridden — Circle's vtable slot for it points at Shape::draw
};
Компилятор присваивает каждой виртуальной функции класса стабильный индекс слота (по порядку объявления: area=0, draw=1, ~Shape=2). Этот индекс — контракт между базовым классом и всеми его наследниками: Circle обязан положить свою area в тот же слот 0. Если Circle не переопределяет draw, компилятор просто копирует в слот 1 указатель на Shape::draw — переопределение не обязательно для каждого слота.The compiler assigns each virtual function a stable slot index (by declaration order: area=0, draw=1, ~Shape=2). That index is the contract between the base class and every descendant: Circle must place its own area in that same slot 0. If Circle doesn't override draw, the compiler simply copies a pointer to Shape::draw into slot 1 — overriding isn't required for every slot.
Именно фиксированный индекс слота и есть весь механизм разрешения виртуального вызова: shape->area() компилируется в «прочитать vptr объекта, взять слот 0, вызвать по этому адресу» — компилятору вообще не нужно знать, что такое Circle, чтобы сгенерировать этот код в totalArea(). Отсюда и требование override в C++11 — оно не меняет ABI, а лишь просит компилятор проверить на этапе компиляции, что сигнатура действительно совпадает с какой-то виртуальной функцией базы.That fixed slot index is the entire mechanism behind resolving a virtual call: shape->area() compiles down to "read the object's vptr, grab slot 0, call through that address" — the compiler doesn't need to know anything about Circle to generate that code inside totalArea(). That's also why C++11's override exists — it doesn't change the ABI at all, it just asks the compiler to check at compile time that the signature genuinely matches some virtual function in a base.
Если класс наследует от нескольких полиморфных базовых классов, объект получает по одному vptr на каждую базу (кроме первой — она использует общий с производным классом vptr в начале объекта). Это прямое следствие того, что каждая база должна допускать корректный виртуальный вызов через указатель именно на неё.When a class inherits from several polymorphic base classes, the object gets one vptr per base (except the first one, which shares the vptr at the start of the object with the derived class). This is a direct consequence of the fact that each base must support a correct virtual call through a pointer to exactly that base.
struct Drawable { virtual void draw() const = 0; virtual ~Drawable() = default; };
struct Serializable { virtual std::string serialize() const = 0; virtual ~Serializable() = default; };
struct Widget : Drawable, Serializable {
void draw() const override {}
std::string serialize() const override { return ""; }
};
// sizeof(Widget) содержит 2 vptr: один для Drawable-части, один для Serializable-части// sizeof(Widget) holds 2 vptrs: one for the Drawable slice, one for the Serializable slice
приведение к Serializable* меняет значение указателя, не только его типconverting to Serializable* changes the pointer's value, not just its type
Отсюда важное следствие: static_cast<Serializable*>(&w) — это не «просто перетолкование того же адреса», а реальная арифметика указателя (this-adjustment): компилятор прибавляет к адресу w смещение второго подобъекта. Это же смещение зашито в специальные thunk-функции — крошечные обёртки в vtable, которые перед прыжком к настоящей реализации корректируют this обратно на начало полного объекта Widget.This has an important consequence: static_cast<Serializable*>(&w) isn't "just reinterpreting the same address" — it's real pointer arithmetic (a this-adjustment): the compiler adds the second subobject's offset to w's address. That same offset is baked into special thunk functions — tiny wrapper stubs inside the vtable that adjust this back to the start of the full Widget object before jumping to the real implementation.
При виртуальном наследовании (см. страницу про наследование и ромбовидную проблему) смещение общего виртуального базового подобъекта не фиксировано — оно зависит от того, кто именно наследуется от класса дальше. Поэтому это смещение (vbase offset) хранится прямо в vtable, рядом с указателями на функции, и читается в рантайме при каждом обращении к полю виртуальной базы или при dynamic_cast к ней.Under virtual inheritance (see the inheritance page and the diamond problem), the offset of the shared virtual base subobject isn't fixed — it depends on whoever ends up inheriting from the class further down. So that offset (the vbase offset) is stored right in the vtable, alongside the function pointers, and read at runtime on every access to a virtual base's field or every dynamic_cast to it.
Это именно то, что делает виртуальное наследование измеримо дороже обычного: не просто «ещё один указатель», а лишнее чтение из памяти на каждое обращение к данным виртуальной базы, потому что компилятор физически не может подставить константное смещение во время компиляции промежуточного класса.This is precisely what makes virtual inheritance measurably more expensive than ordinary inheritance: not just "one more pointer," but an extra memory read on every access to the virtual base's data, because the compiler is physically unable to bake in a constant offset while compiling the intermediate class.
vtable — это статические данные только для чтения, известные полностью на этапе компиляции (адреса функций разрешает линковщик). Поэтому она попадает в ту же секцию, что и другие константы: .rdata в PE (Windows) или .rodata в ELF (Linux) — подробный разбор секций на странице про PE-файл.The vtable is static, read-only data, fully known at compile time (the linker resolves the function addresses). So it lands in the same section as other constants: .rdata in PE (Windows) or .rodata in ELF (Linux) — see the PE file page for the full section breakdown.
| где физически лежитwhere it physically lives | почемуwhy | |
|---|---|---|
| vtable | .rdata / .rodata | статический, известный на этапе линковки массив указателей — не меняется в рантаймеa static array of pointers, fully known at link time — never modified at runtime |
| код виртуальных функцийthe virtual functions' code | .text | обычные машинные инструкции, как и у невиртуальных функцийordinary machine instructions, exactly like non-virtual functions |
| type_info (RTTI) | .rdata / .rodata | тоже статичен — имя класса, указатель на type_info родителяalso static — the class name, a pointer to the parent's type_info |
| vptr | нигде на дискеnowhere on disk | это поле внутри объекта — существует только в памяти рантайма, записывается конструкторомit's a field inside an object — it exists only in runtime memory, written by the constructor |
Важная деталь про то, что vptr — не константа: сама vtable статична и лежит в файле, но значение vptr (адрес этой vtable) — это указатель на область памяти, которая из-за ASLR (см. страницу про PE) на каждом запуске процесса разная. Поэтому запись vptr в поле объекта происходит не «сама собой» — за неё явно отвечает код конструктора, сгенерированный компилятором: каждый конструктор класса с виртуальными функциями начинается с присваивания vptr, прежде чем выполнится тело, написанное программистом.One subtlety about vptr not being a constant: the vtable itself is static and lives in the file, but the vptr's value (that vtable's address) is a pointer into memory that, because of ASLR (see the PE page), differs on every process launch. So writing the vptr into an object's field doesn't happen "automatically" — it's explicitly handled by compiler-generated constructor code: every constructor of a class with virtual functions begins by assigning the vptr, before the programmer-written body runs.
Это прямое объяснение, почему виртуальный вызов из конструктора базы не виртуален (см. грабли на странице про полиморфизм): к моменту выполнения тела конструктора Shape vptr объекта уже установлен конструктором Shape на vtable именно Shape, а перезапишет его на vtable Circle только конструктор Circle, который выполнится позже.This directly explains why a virtual call from a base constructor isn't virtual (see the gotcha on the polymorphism page): by the time Shape's constructor body runs, the object's vptr has already been set by Shape's own constructor to Shape's vtable — it only gets overwritten with Circle's vtable by Circle's constructor, which runs afterward.
Создание миллиона объектов Circle не создаёт миллион vtable — она одна, статическая, и её вклад в размер бинарника фиксирован: примерно (число виртуальных функций + служебные слоты) × размер указателя. Для класса с 3 виртуальными функциями на x86-64 это около 40–56 байт — величина, которую практически невозможно заметить рядом с самим кодом функций.Creating a million Circle objects doesn't create a million vtables — there's exactly one, static, and its contribution to binary size is fixed: roughly (number of virtual functions + housekeeping slots) × pointer size. For a class with 3 virtual functions on x86-64, that's around 40–56 bytes — a size that's practically unnoticeable next to the functions' own code.
Главный вклад virtual в размер бинарника — косвенный: раз функция может быть вызвана виртуально, компилятор обязан сгенерировать для неё полноценное тело функции в .text с прологом/эпилогом вызова, даже если внутри программы её всегда вызывают на объекте известного типа (где могла бы сработать devirtualization + инлайнинг). Простая однострочная area(), будь она невиртуальной, могла бы вообще не породить отдельного символа — целиком раствориться в местах вызова.The main way virtual grows a binary is indirect: once a function can be called virtually, the compiler must emit a full-fledged function body for it in .text, complete with call prologue/epilogue, even if inside the program it's always called on an object of a known type (where devirtualization + inlining could otherwise kick in). A trivial one-line area(), if it were non-virtual, might never produce a separate symbol at all — it could dissolve entirely into its call sites.
Полиморфный класс несёт с собой type_info объект — строку с именем класса (в GCC/Clang часто искажённое/mangled имя) и указатели для построения иерархии наследования, нужные dynamic_cast и typeid. При компиляции с -fno-rtti (GCC/Clang) эти данные не генерируются вовсе — типичная опция для embedded/игровых движков, где RTTI не нужен, а каждый байт критичен.A polymorphic class carries a type_info object along with it — a string with the class's name (often name-mangled in GCC/Clang) and pointers describing the inheritance hierarchy, needed by dynamic_cast and typeid. Compiling with -fno-rtti (GCC/Clang) skips generating this data entirely — a common option for embedded systems/game engines, where RTTI isn't needed and every byte counts.
Класс с N полиморфными базами получает N отдельных vtable (по одной на подобъект) и, для каждой переопределённой функции второй и далее базы, дополнительный thunk — крошечную функцию корректировки this. При глубоких иерархиях множественного наследования это заметно увеличивает и число символов, и суммарный размер .rdata/.text.A class with N polymorphic bases gets N separate vtables (one per subobject) and, for every function overridden from the second base onward, an additional thunk — a tiny this-adjusting stub function. In deep multiple-inheritance hierarchies this noticeably grows both the symbol count and the total .rdata/.text footprint.
Копирование сырых байт объекта через memcpy копирует и vptr — что в большинстве случаев безопасно (копия того же типа), но обнуление через memset или запись поверх памяти объекта чужими байтами превращает vptr в мусор: следующий же виртуальный вызов уйдёт по случайному адресу.Copying an object's raw bytes with memcpy also copies the vptr — usually harmless (it's a copy of the same type) — but zeroing it with memset or overwriting the object's memory with foreign bytes turns the vptr into garbage: the very next virtual call jumps to a random address.
Пустой класс без виртуальных функций может занимать 1 байт (Empty Base Optimization), но как только появляется хотя бы одна виртуальная функция, добавляется vptr — sizeof вырастает минимум до размера указателя (8 байт на x86-64), даже если у класса нет никаких полей данных.An empty class with no virtual functions can occupy just 1 byte (the Empty Base Optimization), but the moment even one virtual function appears, a vptr is added — sizeof grows to at least the pointer size (8 bytes on x86-64), even if the class has no data fields at all.
Добавление новой виртуальной функции в середину (а не в конец) базового класса динамической библиотеки сдвигает индексы всех виртуальных функций, объявленных после неё, — уже собранный код, слинкованный со старой версией библиотеки, продолжит вызывать «слот 2», а туда теперь попадает другая функция. Классическое правило бинарной совместимости shared-библиотек: новые виртуальные функции — только в конец.Inserting a new virtual function into the middle (not the end) of a shared library's base class shifts the slot indices of every virtual function declared after it — code already compiled and linked against the old library version keeps calling "slot 2," but a different function now sits there. The classic binary-compatibility rule for shared libraries: new virtual functions only ever go at the end.
Если класс или конкретный метод помечен final, компилятор знает, что переопределений больше не будет, и может девиртуализировать вызов в прямой (а затем и заинлайнить) там, где статический тип объекта известен — это единственный надёжный способ вернуть себе часть производительности статического полиморфизма, не переписывая архитектуру.If a class or a specific method is marked final, the compiler knows no further overrides can exist and can devirtualize the call into a direct one (and then inline it) wherever the object's static type is known — this is the one reliable way to claw back some of static polymorphism's performance without rearchitecting the code.