Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Подводные камни регулярных выражений: катастрофический возврат, ReDoS-атаки и выявление уязвимостей - 30.04

>
>
>
Тихий враг или молчаливый союзник?...

Тихий враг или молчаливый союзник? Коротко о выравнивании в C++. Часть 3

30 Апр 2026

Мы уже прошли через базовое выравнивание полей и изучили, как наследование наслаивает данные друг на друга. Казалось бы, теперь-то всё, все ловушки изучены. Но не тут-то было! Есть у этой темы ещё одна, по-настоящему тёмная сторона, про которую не так часто говорят. Одно короткое слово virtual полностью переписывает "геометрию" класса, внося в выравнивание свои коррективы, которые трудно игнорировать. Давайте разберёмся, что на самом деле происходит под капотом, когда выравнивание сталкивается с виртуальностью.

Введение

Продолжаем наше глубокое погружение в механику памяти. Если вы только присоединились к нам, рекомендую сначала ознакомиться с фундаментом. В первой части были разобраны основы и "магия" простого выравнивания данных, а во второй части мы посмотрели, как на раскладку памяти влияет обычное наследование.

Однако до сих пор объект рассматривался как статическая, жёстко детерминированная структура данных. Здесь адрес каждого поля был известен ещё на этапе компиляции, а наследование было простым "наслоением" родительских и дочерних атрибутов. К сожалению, объектно-ориентированное программирование невозможно без динамического полиморфизма, который реализуется в C++ через механизм RTTI и виртуальные функции.

Что такое виртуальность?

Зайду издалека. Давайте задумаемся над вопросом: как компилятор понимает, какой именно фрагмент машинного кода нужно исполнить в конкретной строчке программы? Ответ на этот вопрос даст нам возможность понять, что же такое виртуальность.

Представьте, что мы написали вызов object.print(). Для процессора это не просто какое-то абстрактное действие, а команда совершить переход по определённому адресу в памяти, где лежат инструкции этой функции. Но откуда берётся этот адрес?

При компиляции каждая строка кода превращается в одну или несколько команд машинного языка. Каждая из них получает свой уникальный порядковый адрес в памяти. Это справедливо и для функций. Компилятор переводит их в машинный код и присваивает им следующий доступный адрес. Процесс "сшивания" вызова функции в коде с её фактическим адресом в памяти называется связыванием (binding).

В зависимости от того, в какой момент происходит "сцепление" адреса вызова с адресом самой функции, выделяют два сценария.

1. Статическое связывание (static/early binding).

По умолчанию работает статическое (раннее) связывание. В этом случае компилятор ещё на этапе сборки жёстко "прошивает" конкретный адрес функции в месте её вызова. Особенность заключается в том, что решение принимается исключительно на основе типа указателя или ссылки, а не фактического объекта в памяти. Например, если компилятор видит указатель Base*, то он выбирает адрес метода из базового класса. Это максимально быстро, так как не требует дополнительных вычислений и прыжок происходит по заранее известному адресу. Быстро и эффективно, но лишает программу гибкости в runtime.

2. Динамическое связывание (dynamic/late binding).

В отличие от статического, динамическое (позднее) связывание позволяет отложить выбор функции до того момента, когда программа уже запущена. В этом случае компилятор не может заранее вписать конкретный адрес функции в код. Вместо этого он генерирует специальную инструкцию: "В момент вызова загляни внутрь объекта, найди там актуальный адрес нужной функции и только тогда совершай прыжок". Это даёт огромную гибкость: программа принимает решение на лету, основываясь на том, какой именно объект сейчас находится перед ней (наследник или базовый класс), а не на том, какого типа указатель мы используем.

Теперь, когда мы разобрались с механикой связывания, можем ответить на вопрос, что же такое виртуальность. Виртуальность — это механизм реализации динамического полиморфизма, основанный на позднем связывании. Помечая метод ключевым словом virtual, мы переводим его из режима статического связывания в режим позднего связывания. С этого момента адрес точки входа в функцию перестаёт быть константой этапа компиляции и становится переменной, значение которой извлекается из контекста конкретного объекта во время выполнения (runtime).

Реализация виртуальности в C++ не закреплена в стандарте языка, но де-факто подчиняется правилам конкретных ABI (Itaniun ABI, MSVC ABI). Центральным звеном здесь выступает пара vtable (Virtual Method Table) и vptr (Virtual Pointer).

Виртуальные функции

Концепцию виртуальности мы разобрали, но на практике основным инструментом этой реализации является виртуальная функция. Давайте посмотрим на её работу.

Возьмём простую иерархию классов, где попытаемся у наследника перекрыть метод родителя:

class Base
{
public:
  std::string_view NameClass() const {return "Base";}  
};

class Derived : public Base
{
public:
  std::string_view NameClass() const {return "Derived";}
};
#include <iostream>
#include <format>
#include <string_view>

class Base
{
public:
  std::string_view NameClass() const {return "Base";}  
};

class Derived : public Base
{
public:
  std::string_view NameClass() const {return "Derived";}

};

int main()
{
  Derived derived {};
  Base& base {derived};

  std::cout << "=== Name class ===\n";
  std::cout << "Base has static type " << base.NameClass() <<"\n";
}

Compiler Explorer

=== Name class ===
Base has static type Base

Результат может показаться странным. Мы создали Derived, но программа упорно твердит, что это Base. Всё дело в раннем связывании. Компилятор видит ссылку Base& и определяет вызов метода соответствующего класса прямо в момент сборки. С его точки зрения это безопасная и быстрая оптимизация, ведь он не обязан проверять, на объект какого типа указывает ссылка в процессе работы программы.

Перенесём принятие решения из этапа компиляции в этап выполнения, добавив всего одно слово — virtual:

class Base
{
public:
  virtual std::string_view NameClass() const {return "Base";}  
};

class Derived : public Base
{
public:
  std::string_view NameClass() const override {return "Derived";}
};
#include <iostream>
#include <format>
#include <string_view>

class Base
{
public:
  virtual std::string_view NameClass() const {return "Base";}  
};

class Derived : public Base
{
public:
  std::string_view NameClass() const override {return "Derived";}

};
int main()
{
  Derived derived {};
  Base& base {derived};

  std::cout << "=== Name class ===\n";
  std::cout << "Base has static type " << base.NameClass() <<"\n";
}

Compiler Explorer

=== Name class ===
Base has static type Derived

Мы добились, чего хотели. Теперь, несмотря на ссылку, программа видит реальный тип объекта в памяти. Здесь уже сработало динамическое связывание: адрес выбора функции откладывается до момента выполнения (runtime).

Теперь можно сформулировать определение. Виртуальная функция — метод класса, вызов которого разрешается не по типу ссылки или указателя, а по фактическому типу объекта, находящегося в памяти. Ключевое слово virtual инструктирует компилятор заменить прямое обращение функции на динамическую диспетчеризацию. Так производный класс может замещать реализацию базового класса, сохраняя идентичную структуру.

Плюсы и минусы виртуальных функций рассмотрим чуть позже, а пока поговорим вот о чём. Стандарт C++ описывает ожидаемое поведение виртуальных функций, оставляя детали реализации на усмотрение разработчиков компиляторов. Однако в подавляющем большинстве современных систем (GCC, Clang, MSVC) используется механизм, ставшим стандартом по умолчанию — таблицы виртуальных функций.

Таблица виртуальных функций (vtable)

Виртуальная таблица (vtable, virtual method table, dispatch table) — статический массив указателей, который компилятор создаёт для каждого класса, использующего виртуальные функции (или наследующего от них).

Немного теории. Как устроена vtable?

  • Таблица создаётся один раз на этапе компиляции для каждого класса, а не для каждого объекта.
  • Каждая запись в таблице — указатель на адрес наиболее производной функции, доступной этому классу. Если наследник переопределил функцию, в его таблице будет адрес его версии, если нет — адрес версии базового класса.
  • Каждый класс в иерархии наследования получает свою уникальную версию vtable.
  • Сама по себе таблица — лишь статическая структура в памяти. Чтобы конкретный объект знал, какой таблицей пользоваться, компилятор неявно добавляет в каждый экземпляр класса скрытое поле — vptr. Инициализируется оно в конструкторе и связывает объект с его vtable. Но об этом чуть позже.
  • Порядок функций в таблице строго определён на этапе компиляции. К примеру, если funcA() идёт первой в базовом классе, она будет занимать первый индекс во всех таблицах наследников. Благодаря этому программа находит нужный адрес за константное время O(1), просто прибавляя смещение к адресу таблицы.
  • Помимо адресов функций, vtable часто содержит указатель на структуру с информацией о типе объекта (Run-Time Type Information, RTTI). Это необходимо для корректной работы операторов, которые проверяют реальный тип объекта прямо во время выполнения программы.
  • Если полиморфный класс правильно спроектирован, то в нём одна из записей в vtable всегда зарезервирована под деструктор. Так мы можем дать гарантию, что при удалении объекта через указатель на базовый класс будет вызвана цепочка деструкторов всех наследников для предотвращения утечек памяти.

Это всё можно представить в виде такой схемы:

Что у нас тут? Тут изображена архитектура динамического полиморфизма на уровне памяти. Связующим звеном здесь выступает vptr — скрытый 8-байтный указатель в начале класса, который перенаправляет вызовы к vtable. В этой таблице под отрицательным индексом хранятся метаданные типа (RTTI), а под нулевым — адрес виртуального деструктора. Остальные записи содержат физические адреса функций. Такой фиксированный порядок записей важен, так как вызов любого метода сводится к двум операциям: чтения и записи из памяти.

Когда в коде встречается вызов виртуальной функции через указатель или ссылку на базовый класс, то выполнятся следующий алгоритм действий:

  • Программа обращается к экземпляру класса и через его скрытый указатель находит соответствующую виртуальную таблицу для его фактического типа.
  • В таблице выбирается нужная запись, так как индекс функции известен ещё на этапе компиляции.
  • Программа извлекает адрес функции из этой записи.
  • Осуществляется косвенный вызов функции по найденному адресу.

Теория всегда выглядит красочно и радужно, а теперь давайте посмотрим на практике:

class Base
{
public:
  virtual ~Base() {};
  virtual void func1() {}
  virtual void func2() {}
};

class Derived : public Base
{
public:
  void func1() override {}
};

class Derived1 : public Base
{
public:
  void func2() override {}
};

Перед нами стандартная ситуация: мы спроектировали базовый интерфейс Base и создали его наследников. Каждый из дочерних классов переопределяет виртуальные функции. С точки зрения логики всё просто и понятно, но давайте посмотрим, что у нас под капотом.

Примечание: все примеры будут рассмотрены на компиляторе Clang.

/* vtable has 3 entries: {
       [0] = ~Base((null)), 
       [2] = func1((null)), 
       [3] = func2((null)), 
    } */

На первый взгляд кажется странным, что первый индекс пропускается, но здесь всё правильно. Дело тут в деструкторе, который может быть вызван в двух разных контекстах:

  • полное удаление (complete object destruction) — удаление объекта через delete;
  • размещающее удаление (deleting destruction) — удаление объекта через указатель на базовый класс.

Компилятору нужно различать эти две ситуации: он создаёт две точки входа в виртуальной таблице. Поэтому нет первого индекса — он занят деструктором. Такое поведение обусловлено Itanium ABI. После двух слотов для деструктора следуют обычные виртуальные функции.

Но если мы в Compiler Explorer добавим флаг -Xclang -fdump-record-layouts, то сможем увидеть вот такой вывод таблицы:

 vtable for Derived:
        .quad   0
        .quad   typeinfo for Derived
        .quad   Derived::~Derived() [base object destructor]
        .quad   Derived::~Derived() [deleting destructor]
        .quad   Derived::func1()
        .quad   Base::func2()

И вот как раз тут видно, что создан деструктор в двух контекстах.

Compiler Explorer

#include <iostream>

class Base
{
public: 
  virtual ~Base() {};
  virtual void func1() {}
  virtual void func2() {}
};

class Derived : public Base
{
public:
  void func1() override {}
};

class Derived1 : public Base
{
public:
  void func2() override {}
};

Base bs;
Derived dr;
Derived1 dr1;

С тем, как компилятор проектирует таблицу функций и фиксирует индексы в статическом массиве, мы разобрались. Сама по себе виртуальная таблица — всего лишь пассивная структура данных, существующая в единственном экземпляре для всего класса. Она просто "лежит" в памяти. Чтобы полиморфизм заработал, нам нужен виртуальный указатель.

Виртуальный указатель (vptr)

vptr (virtual methods table pointer) — это скрытый член данных (указатель), автоматически добавляемый компилятором в базовый класс, содержащий хотя бы одну виртуальную функцию. Он указывает на статическую таблицу адресов функций (vtable), соответствующую конкретному типу объекта во время выполнения программы.

Механизм работы указателя

Углубимся немного в механику работы. По сути, работа vptr — это фундамент динамического полиморфизма в объектно-ориентированных языках.

class Base
{
public:
  virtualTable *vptr;
  virtual void func1() {}
  virtual void func2() {}
};

class Derived : public Base
{
public:
  void func1() override {}
};

class Derived1 : public Base
{
public:
  void func2() override {}
};

Итак, возьмём все тот же код, только в самое начало добавим виртуальный указатель. Его работа строится на трёх фундаментальных этапах.

  • подготовка инфраструктуры на этапе компиляции;
  • динамическая инициализация при конструировании объекта;
  • диспетчеризация вызова в режиме реального времени.

Наш код можем представить в виде такой схемы:

При компиляции кода с виртуальными функциями компилятор добавляет в каждый объект скрытый указатель vptr, который ссылается на таблицу виртуальных функций. Для базового класса Base создаётся vtable с адресами виртуальных методов. В производных классах (Derived, Derived1) компилятор заменяет в таблице адреса переопределённых методов, сохраняя фиксированные индексы для каждого из них. Это первый этап.

Теперь создадим экземпляр класса через вызов:

Base* bs = new Derived();

У нас запускается процесс послойной инициализации, который превращает память в полиморфный объект. Сначала выделяется блок памяти, где хранятся данные объекта и сам vptr. Первым сработает конструктор родительского класса, который записывает указатель в адрес своей таблицы.

Затем управление переходит в конструктор Derived. Он выполняет ключевую операцию — перезаписывает значение указателя, подставляя туда адрес своей таблицы:

vtable for Derived:
        .quad   0
        .quad   typeinfo for Derived
        .quad   Derived::~Derived() [base object destructor]
        .quad   Derived::~Derived() [deleting destructor]
        .quad   Derived::func1()
        .quad   Base::func2()

К моменту завершения работы всех конструкторов объект стабилизируется. Его внутренний указатель теперь прочно связан с таблицей самого младшего класса в иерархии. Это даёт гарантию, что при любом полиморфном вызове будет использоваться именно та версия метода, которая соответствует реальному типу объекта, а не типу указателя. Этот процесс можно назвать динамической инициализацией при конструировании объекта.

При выполнении bs->func1() запускается механизм динамической диспетчеризации вызовов, основанный на принципе позднего связывания. Компилятор генерирует код, который игнорирует статический тип указателя Base* и вместо этого извлекает текущее значение указателя из памяти объекта. Он содержит адрес актуальной виртуальной таблицы, соответствующей реальному динамическому типу объекта. Далее происходит косвенное обращение к vtable через vptr. По фиксированному смещению в этой таблице извлекается адрес нужной реализации метода. Процессор выполняет переход по этому адресу, обеспечивая вызов именно переопределённой версии метода из класса Derived, а не базовой реализации.

Compiler Explorer

#include <iostream>

class Base
{
public:
  virtual ~Base() {};
  virtual void func1() {}
  virtual void func2() {}
};

class Derived: public Base
{
public:
  void func1() override {}
};

class Derived1: public Base
{
public:
  void func2() override {}
};

int main()
{
  Base* bs = new Derived();
  bs-> func1();
}

Разобравшись с тем, как vptr обеспечивает полиморфизм, необходимо рассмотреть его физическое влияние на объект. Поскольку указатель является полноценным полем в структуре, его наличие неизбежно корректирует топологию памяти. Давайте разберём, как внедрение этого указателя инициирует механизмы выравнивания и как это сказывается на итоговом размере структуры в байтах.

Выравнивание и vptr

В современных 64-битных системах виртуальный указатель имеет размер 8 байт. Согласно спецификациям большинства ABI данные должны быть выровнены по адресу, кратному их собственному размеру. Значит, виртуальный указатель требует выравнивания 8 байт.

Как это влияет на смещение? Поскольку поле указателя занимает первые 8 байт, любое последующее поле произвольно размещать мы не можем, так как компилятор обязан соблюдать правила выравнивания для каждого типа данных внутри структуры.

Вспомним, как будет происходить расположение полей. Если после vptr идёт тип с меньшим требованием выравнивания, например char, то он займёт следующий байт. Однако если за ним следует тип, требующий 4 или 8 байт, компилятор вставит паддинги, чтобы выровнять адрес следующего поля. Давайте посмотрим на это:

class Example
{
public:  
  virtual void func() {}
  char c;
};

Какое выравнивание? Чисто математически размер 9 байт, но мы-то уже знаем, что не всё так просто. Ответ: 16 байт.

*** Dumping AST Record Layout
         0 | class Example
         0 | (Example vtable pointer)
         8 |  char c
           | [sizeof=16, dsize=9, align=8,
           |  nvsize=9, nvalign=8]

С арифметикой итогового размера всё достаточно просто: 8 байт на указатель, 1 байт данных и 7 байт для финального выравнивания. Но чтобы понимать, как этот объект будет вести себя в иерархии наследования, нам нужно расшифровать спецификацию, которую выдал компилятор. Что это за обозначения dsize, nvsize, nvalign? Пойдёмте разбираться:

  • sizeof — это итоговый размер объекта в байтах;
  • dsize — фактический объём памяти, занятый полезными данными. Мы его сложили из 8 байт vptr и 1 байта char. По сути, это "чистый" размер состояния объекта до применения правил финального выравнивания;
  • align — требование кратности адреса объекта в памяти. Поскольку наиболее строгим типом в классе является 8-байтовый указатель, весь объект получает атрибут выравнивания 8;
  • nvsize — размер "невиртуальной" части класса. В контексте одиночного наследования это объём памяти, который занимает класс, когда становится базовым для другого. В нашем примере он совпадает с dsize, так как иерархия простая;
  • nvalign — выравнивание, которое должен соблюдать наследник при размещении своих полей.

Мы вооружились приличным багажом знаний, и пора окунуться в ту самую, тёмную сторону — посмотреть наследование.

Наследование и виртуальность

Проблема ромба

Давайте посмотрим на такой пример:

class Entity
{
public:
  int id;
  virtual void update();
};

class Movable: public Entity 
{
public:
  float velocity;
  void move() {}
};

class Renderable: public Entity
{
public:
  int textureId;
  void draw();
};

class Player: public Movable, public Renderable
{
public:
   char name[32];
};

На первый взгляд перед нами логичная и стройная композиция. Давайте попробуем поработать с этим классом в коде и посмотрим на результат.

Создав объект Player и заглянув в его раскладку памяти, мы обнаружим нечто странное:

Player hero;
*** Dumping AST Record Layout
         0 | class Player
         0 |   class Movable (primary base)
         0 |     class Entity (primary base)
         0 |       (Entity vtable pointer)
         8 |       int id
        12 |     float velocity
        16 |   class Renderable (base)
        16 |     class Entity (primary base)
        16 |       (Entity vtable pointer)
        24 |       int id
        28 |     int textureId
        32 |   char[32] name
           | [sizeof=64, dsize=64, align=8,
           |  nvsize=64, nvalign=8]

Вместо того, чтобы аккуратно собрать свойства от всех предков, компилятор буквально "склеивает" две полноценные структуры: Renderable и Movable. Отношения между классами можно представить схемой:

Проблема в том, что оба родительских класса уже несут в себе полноценную копию базового класса Entity. В итоге внутри одного объекта Player оказываются два независимых экземпляра Entity. У каждого из них свой собственный vptr и своё поле id. С точки зрения бинарной структуры объект оказывается избыточным: он не просто наследует функционал, а физически дублирует состояние базового класса в разных сегментах своей памяти.

Эта структурная особенность проявляется в самый неподходящий момент — при попытке простого обращения к идентификатору игрока:

hero.id = 1;

Компилятор оказывается в тупике. Он видит, что у объекта hero есть два пути к полю id:

  • через ветку движения (Movable);
  • через ветку отрисовки (Renderable).

В памяти эти поля существуют физически отдельно друг от друга: программа не может угадать, какой именно идентификатор вы хотите изменить. В результате мы получаем объект с рассогласованным внутренним состоянием: одна область памяти, выделенная под Entity (через Movable), может хранить значение id = 1, в то время как вторая область (через Renderable) останется неинициализированной или будет содержать другое значение. Поскольку это два физически разных участка адресного пространства, запись в один из них никак не влияет на другой. Такое состояние называется проблема ромбовидного наследования (Diamond Problem).

Compiler Explorer

#include <iostream>

class Entity
{
public:
  int id;
  virtual void update();
};

class Movable : public Entity 
{
public:
  float velocity;
  void move() {}
};

class Renderable : public Entity
{
public:
  int textureId;
  void draw();
};

class Player : public Movable, public Renderable
{
public:
  char name[32];
};

int main()
{
  Player hero;
  hero.id = 1;
}

Для устранения этой избыточности и восстановления когерентности применяется виртуальное наследование:

class Entity
{
public:
  int id;
  virtual void update()
  {
    std::cout << "Entity update, ID " << id << std::endl;
  }
};

class Movable : virtual public Entity 
{
public:
  float velocity;
  void move()
  {
    std::cout << "Moving with velocity " << velocity << std::endl;
  }
};

class Renderable : virtual public Entity
{
public:
  int textureId;
  void draw()
  {
    std::cout << "Drawing texture " << textureId << std::endl;
  }
};

class Player : public Movable, public Renderable
{
public:
  char name[32];
  void update() override 
  {
    std::cout << "Player " << name << " updating ... " << std::endl;
  }
};

В таком случае компилятор меняет алгоритм построения объекта. Вместо статического включения данных Entity в каждую ветку он выделяет для базового класса единую разделяемую область. Теперь, независимо от количества промежуточных классов, в итоговом объекте Player будет существовать только один экземпляр id и один набор виртуальных методов. Доступ к этой части происходит через дополнительные указатели смещения, что гарантирует логическое и физическое единство объекта.

Compiler Explorer

#include <iostream>
#include <cstring>

class Entity
{
public:
  int id;
  virtual void update()
  {
    std::cout << "Entity update, ID " << id << std::endl;
  }
};

class Movable: virtual public Entity 
{
public:
  float velocity;
  void move()
  {
    std::cout << "Moving with velocity " << velocity << std::endl;
  }
};

class Renderable: virtual public Entity
{
public:
  int textureId;
  void draw()
  {
    std::cout << "Drawing texture " << textureId << std::endl;
  }
};

class Player: public Movable, public Renderable
{
public:
  char name[32];
  void update() override 
  {
    std::cout << "Player " << name << " updating ... " << std::endl;
  }
};

int main()
{
  Player hero;

  hero.id = 1;
  hero.velocity = 6.5f;
  hero.textureId = 1;
  std::strcpy(hero.name, "Tom");

  hero.update();
  hero.move();
  hero.draw();
}

Player Tom updating ... 
Moving with velocity 6.5
Drawing texture 1

Механизмы виртуального наследования: vbptr, vbtable, VTT

При виртуальном наследовании нарушается привычная линейная раскладка памяти. Если раньше компилятор точно знал, что поле id находится по конкретному адресу от начала объекта, то теперь эта определённость исчезает. Виртуальная база становится динамическим компонентом: сегодня она часть Player, завтра — Movable. Её точное положение в памяти зависит от того, в какой именно иерархии собрался итоговый объект.

Чтобы не вычислять адрес на ощупь, компилятор внедряет механизм косвенной адресации через vbptr (virtual base pointer) и vbtable (virtual base table). В каждый объект промежуточного класса, наследующего базу виртуально, добавляется скрытый указатель – vbptr. Он служит точкой входа в статическую таблицу смещений (vbtable), генерируемую компилятором для конкретного типа, объект которого содержит этот vbptr.

В ней хранятся целочисленные значения, которые определяют точное расстояние от текущего положения vbptr до начала виртуальной базы родителя. Поэтому любое обращение к какому-то полю превращается в следующий алгоритм:

  • считываем адрес из vbptr;
  • извлекаем из vbtable соответствующее смещение;
  • суммируем текущий адрес объекта и полученное смещение для вычисления итогового физического адреса данных.

Теперь, понимая механику процесса, мы можем дать точные определения.

vbptr (virtual base pointer) — скрытый системный указатель, внедряемый компилятором в макет производного класса. Он служит динамической ссылкой на таблицу смещений, позволяя объекту в runtime определить, где именно в текущей конфигурации памяти находится его виртуальный предок.

vbtable (virtual base table) — статическая структура данных, генерируемая компилятором для каждого типа, использующего виртуальное наследование. По сути, эта структура является массивом целочисленных значений, которые определяют точное расстояние в байтах от vbptr до начала каждого виртуального базового подобъекта.

Часто возникает путаница между обычным полиморфизмом и виртуальным наследованием, поэтому мы сделали таблицу для наглядности:

После изучения сравнительной таблицы и понимания того, как работают отдельные указатели, возникает вопрос: как вся эта сложная машина приводится в движение? Если vtable и vbtable — это статические справочники, то в процессе создания объекта нам нужен механизм, который правильно расставит все указатели по своим местам. В иерархиях со сложным виртуальным наследованием, в рамках Itanium ABI, эту роль берёт на себя VTT или Virtual Method Table Hierarchy Table.

VTT — это вспомогательная структура данных, которую в простонародье можно назвать "таблицей таблиц". В то время как обычная виртуальная таблица хранит адреса функций, VTT хранит адреса самих виртуальных таблиц, необходимых для конкретной ветки наследования. Главная техническая задача VTT — обеспечить корректную работу объекта в тот пограничный момент, когда конструктор базового класса уже запущен, а конструктор наследника ещё нет. Без этого механизма вызов виртуальной функции или доступ к данным виртуальной базы из конструктора может привести к краху, так как объект ещё не достроен и его указатели могут ссылаться на неверные или пустые области.

Работу VTT можно описать следующим алгоритмом:

  • При вызове конструктора самого дальнего наследника компилятор скрыто передаёт ему в качестве аргумента адрес соответствующего VTT.
  • Конструктор считывает из VTT адреса первичных и вторичных виртуальных таблиц и записывает их в vptr и vbptr текущего объекта.
  • При вызове конструкторов базовых классов им передаётся не вся VTT, а лишь указатель на её определённый фрагмент для нужной ветки.
  • Базовый класс использует полученный фрагмент для временной настройки указателей объекта под себя, гарантируя, что, на время создания его конструктора, объект будет вести как экземпляр именно этого базового класса.

Разобравшись с тем, кто за что отвечает, можем перейти к выравниванию в виртуальности.

Сложности виртуального наследования

Приведение указателей

Давайте посмотрим, какие сложности нас могут поджидать при виртуальном наследовании. С первой проблемой мы можем столкнуться, когда будем приводить указатель на производный класс к его базовым типам. Мы привыкли к тому, что указатель на объект — это статическая метка, указывающая на начало блока памяти. Однако множественное и виртуальное наследование вносят свои коррективы.

Давайте взглянем на этот код:

struct Parent1
{
  int num;
  virtual ~Parent1() {}
};

struct Parent2
{
  int num2;
  virtual ~Parent2() {} 
};

struct Derived : Parent1, Parent2
{
  int res;
};

Перед нами классический сценарий множественного наследования: два родителя и наследник. Выведем их адреса.

Compiler Explorer

#include <iostream>
#include  <cstring>

struct Parent1
{
  int num;
  virtual ~Parent1() {}
};

struct Parent2
{
  int num2;
  virtual ~Parent2() {} 
};

struct Derived : Parent1, Parent2
{
  int res;
};

int main()
{
  Derived* d = new Derived();
  Parent1* p1 = d;
  Parent2* p2 = d;

  std::cout << "Derived address: " << d << std::endl;
  std::cout << "Parent1 address: " << p1 << std::endl;
  std::cout << "Parent2 address: " << p2 << std::endl;
}

Derived address: 0x55b3bf4b92b0
Parent1 address: 0x55b3bf4b92b0
Parent2 address: 0x55b3bf4b92c0

Если вы запустите этот код, то увидите нечто странное: адреса Derived и Parent2 не совпадут. Вот тут-то мы и видим фундаментальную особенность: один и тот же объект может иметь разные адреса в зависимости от типа указателя, через который к нему обращаются.

Мы привыкли к тому, что указатель на объект должен всегда указывать на начало соответствующего подобъекта в памяти. Так как Derived содержит в себе Parent1 и Parent2, они не могут занимать одно и то же место. Компилятор выстраивает их друг за другом.

Поэтому, когда мы написали Parent2* p2 = d;, произошло не просто копирование битов — компилятор выполнил корректировку указателя (pointer adjustment). Он взял адрес начала Derived и прибавил к нему смещение, чтобы p2 смотрел только на начало данных, относящихся к Parent2. При обычном множественном наследовании это смещение статично, но при добавлении виртуальности ситуация становится динамической:

  • Порядок расположения баз в памяти может меняться в зависимости от иерархии.
  • Компилятор больше не может зашить в код + 16 байт.
  • Компилятор генерирует код, который лезет в vbtable, достаёт оттуда актуальное смещение для текущего типа объекта и прибавляет его к адресу.

А что происходит тут с выравниванием? Оно тоже вносит свои коррективы:

*** Dumping AST Record Layout
         0 | struct Derived
         0 |   struct Parent1 (primary base)
         0 |     (Parent1 vtable pointer)
         8 |     int num
        16 |   struct Parent2 (base)
        16 |     (Parent2 vtable pointer)
        24 |     int num2
        28 |   int res
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=32, nvalign=8]

В самом начале — на нулевом смещении — располагается Parent1, но вместо ожидаемых 4 байт под int num он занимает целых 16. Это происходит потому, что наличие виртуального деструктора заставляет компилятор внедрить 8-байтовый vptr, а следом за полем num добавить 4 байта отступа, чтобы следующий объект начался с корректного адреса.

Вторая база, Parent2, начинается строго с 16-го байта — это и есть то самое смещение, которое мы видели в коде (p2 != d). Ей также выделяется 8 байт под собственный виртуальный указатель и 4 байта под данные. Завершает структуру поле res самого класса Derived. Итоговый размер объекта составляет 32 байта, хотя сумма полезных данных и указателей даёт лишь 28. Лишние 4 байта добавлены в самом конце для соблюдения правила align=8. Весь объект обязан быть кратным самому тяжёлому своему элементу — 8-байтовому указателю.

Давайте смотреть, какие ещё сюрпризы нам может преподнести выравнивание.

Каст к void*

Рассмотрим ситуацию, которая встречается при работе с низкоуровневым кодом. Нам нужно передать сложный объект Derived в callback-функцию через "сырой" указатель void*. Соответственно, мы его упаковываем в безадресный контейнер, а после в обработчике пытаемся извлечь обратно в виде базового класса Parent2. На первый взгляд всё просто, но не с виртуальным наследованием. Дополним наш предыдущий код:


void process_callback(void* raw_data)
{
  Parent2* broken_p2 = (Parent2*)raw_data;
  std::cout << "--- The result of a void cast ---" << std::endl;
  std::cout << "Original void* address: " << raw_data << std::endl;
  std::cout << "Broken Parent2 address: " << broken_p2 << " (Not offset)" <<
std::endl;
}

Посмотрим на вывод программы.

--- The result of a void cast ---
Original void* address: 0x5baf237792b0
Broken Parent2 address: 0x5baf237792b0 (Not offset)

Запустив этот код, мы увидим, что broken_p2 указывает на тот же адрес, что и начало всего объекта, хотя данные Parent2 смещены в памяти. Проблема в том, что void* полностью "ослепляет" компилятор, стирая информацию о расположении подобъектов. При попытке восстановить Parent2* сразу из необработанного указателя void*, компилятор ошибочно полагает, что данные этого родителя начинаются прямо по адресу void*. На деле же Parent2 отделён от начала объекта сервисными указателями и байтами выравнивания.

В итоге broken_p2 становится битым: он игнорирует необходимый pointer adjustment, и любая попытка прочитать поле вернёт мусор. Весь механизм держится на прецизионном расчёте байтов и отступов. Приведение через void* игнорирует эти пустоты, заставляя программу читать данные со сдвигом.

Как это исправить? Нам нужно, чтобы компилятор снова увидел правильные смещения. Для этого указатель нужно восстанавливать ступенчато:

void process_callback(void* raw_data)
{
  Parent2* broken_p2 = (Parent2*)raw_data; 

  Derived* restored_d = (Derived*)raw_data;
  Parent2* safe_p2 = restored_d; 
    
  std::cout << "--- The result of a void cast ---" << std::endl;
  std::cout << "Original void* address: " << raw_data << std::endl;
  std::cout << "Broken Parent2 address: " << broken_p2 << " (Not offset)" <<
  std::endl;
  std::cout << "Safe Parent2 address: " << safe_p2 << " (Offset)" << std::endl;

}

Вместо того чтобы пытаться совершить прямой прыжок из нетипизированной памяти сразу к базовому классу, мы должны сначала вернуть указатель к его исходному полному типу — в нашем случае к Derived*. На этом этапе компилятор восстанавливает контекст memory layout всего объекта. Он снова видит границы всех подобъектов, наличие служебных указателей и актуальные таблицы смещений. Только после этого, при приведении к Parent2*, активируется механизм pointer adjustment. Компилятор обращается к метаданным типа (включая vbtable при виртуальном наследовании), учитывает требования alignment и вычисляет итоговый эффективный адрес нужного сегмента данных.

Compiler Explorer

#include <iostream>
#include  <cstring>

struct Parent1
{
  int num;
  virtual ~Parent1() {}
};

struct Parent2
{
  int num2;
  virtual ~Parent2() {} 
};

struct Derived : Parent1, Parent2
{
  int res;
};

void process_callback(void* raw_data)
{
  Parent2* broken_p2 = (Parent2*)raw_data; 

  Derived* restored_d = (Derived*)raw_data;
  Parent2* safe_p2 = restored_d; 
  std::cout << "--- The result of a void cast ---" << std::endl;
  std::cout << "Original void* address: " << raw_data << std::endl;
  std::cout << "Broken Parent2 address: " << broken_p2 << " (Not offset)"
<<std::endl;
  std::cout << "Safe Parent2 address: " << safe_p2 << " (Offset)" << std::endl;

}

int main()
{
  Derived* d = new Derived();
  process_callback((void*)d);
  return 0;
}

Empty Base Optimization с virtual

Если помните, в первой части мы говорили про Empty Base Optimization (EBO). По стандарту размер любого объекта не может быть нулевым, поэтому даже абсолютно пустой класс занимает в памяти 1 байт. При обычном наследовании компилятор умеет схлопывать этот байт, чтобы базовый пустой класс не раздувал размер производного. Это мы помним. Но, к сожалению, как только в иерархию врывается виртуальность, эта оптимизация терпит крах.

Сразу спойлер: Clang показал чудеса оптимизации и смог всё оптимизировать. Он помещает все пустые классы по нулевому смещению, фактически спрятав их внутри указателя vtable. Поэтому на этот пример мы оставим Clang, а обратимся к MSVC и взглянем на вот этот код:

class Empty1 {};
class Empty2 {};
class Empty3 {};

class Root : virtual public Empty1
{
  int r;
};

class Root1 : virtual public Empty2
{
  int r1;
};

class Root2 : virtual public Empty3
{
  int r2;
};
class Base : virtual public Root, virtual public Root1, virtual public Root2
{
public:
  double X;
  char symbol;
  virtual void service() {}
};

Посмотрим на размер класса Base.

sizeof(Empty): 1 byte
sizeof(Base): 96 byte

Представить это можно таким образом:

class Base  size(96):
  +---
 0  | {vfptr}
 8  | {vbptr}
16  | X
24  | symbol
    | <alignment member> (size=7)
    | <alignment member> (size=4)
    | <alignment member> (size=4)
  +---
  +--- (virtual base Empty1)
  +---
  +--- (virtual base Root)
32  | {vbptr}
40  | r
    | <alignment member> (size=4)
  +---
  +--- (virtual base Empty2)
  +---
  +--- (virtual base Root1)
56  | {vbptr}
64  | r1
    | <alignment member> (size=4)
  +---
  +--- (virtual base Empty3)
  +---
  +--- (virtual base Root2)
80  | {vbptr}
88  | r2
    | <alignment member> (size=4)
  +---

Перед нами объект раздулся до 96 байт, хотя полезной нагрузки в нём едва наберётся на треть этого объёма. В начале объекта мы видим стандартную "голову": 8 байт на vfptr (указатель на таблицу функций) и ещё 8 байт на vbptr (указатель на таблицу баз). Затем идут данные double X, которые из-за своего размера навязывают всей структуре жёсткое выравнивание по 8 байтам. Но самое интересное начинается после поля char symbol. Вместо того чтобы упаковать данные плотнее, компилятор внедряет массивные блоки alignment member (технические пустоты по 7 и 4 байта), чтобы гарантировать, что каждая следующая виртуальная база начнётся строго с чистой восьмибайтовой границы.

Далее объект превращается в конвейер из виртуальных баз (Root, Root1, Root2), каждая из которых дорого нам обходится. Вместо того чтобы схлопнуть пустые классы Empty, как это делал Clang, MSVC выделяет под каждую ветку навигации собственный vbptr. В итоге каждая такая секция съедает от 16 до 24 байт: 8 байт на служебный указатель, 4-8 байт на данные и обязательный padding для соблюдения симметрии.

В сухом остатке мы получаем структуру, где реальные переменные буквально тонут в служебных указателях и интервалах.

Compiler Explorer

#include <iostream>
#include  <cstring>

class Empty1 {};
class Empty2 {};
class Empty3 {};

class Root : virtual public Empty1
{
  int r;
};

class Root1 : virtual public Empty2
{
  int r1;
};

class Root2 : virtual public Empty3
{
  int r2;
};
class Base : virtual public Root, virtual public Root1, virtual public Root2
{
public:
  double X;
  char symbol;
  virtual void service() {}
};

int main()
{
  std::cout << "sizeof(Empty): " << sizeof(Empty1) << " byte" << std::endl;
  std::cout << "sizeof(Base): " << sizeof(Base) << " byte" << std::endl;
  return 0;
}

Влияние на производительность

Разобрав теорию и примеры, стоит поговорить о цене виртуального наследования. А именно о том, как оно влияет на производительность процессора через проблему промахов кэша (cache misses).

Мы знаем, что в системном программировании очень важна локальность данных. Чем плотнее упакованы поля в памяти, тем выше вероятность того, что процессор загрузит их в кэш одной порцией. Однако виртуальное наследование целенаправленно разрушает эту плотность. За счёт того, что виртуальный базовый подобъект принудительно выносится в самый конец макета, а управляющий им vbptr располагается в начале, данные одного логического объекта оказываются разорваны по разным кэш-линиям. Процессору приходится несколько раз обращаться к оперативной памяти, что приводит к задержкам при выполнении кода.

Ко всей этой картине ещё добавляется выравнивание. Чтобы обеспечить единый доступ к указателям, компилятор вставляет дополнительные пустые байты. По итогу получаем "дырявый" объект. При прохождении таких объектов процессор вынужден тратить пропускную способность шины памяти на прокачку мусорных байтов выравнивания вместе с полезными. В итоге кэш не может уместить всю полезную информацию.

Если мы работаем с большими массивами данных, то всё становится ещё хуже. В обычном классе все поля лежат плотным боком, и процессор считывает их одним линейным проходом. При виртуальном наследовании эта целостность разрушается: данные базового класса переносятся в самый конец структуры, а в начале остаётся лишь указатель на таблицу смещений (vbptr).

По итогу для доступа к любому полю базы процессору приходится сначала обращаться к vbptr, вычислять адрес через таблицу и только потом совершать прыжок к самим данным в хвосте объекта. Такие постоянные вычисления и метания по памяти между служебными указателями и разбросанными полями лишают программу преимуществ кэширования и в разы замедляют обработку массива.

Стоит ли того виртуальность?

Мы видим, что, используя слово virtual, можно легко загрузить наш объект по памяти, запутаться в коде и допустить разные ошибки. Возникает вопрос: зачем это вообще нужно?

С одной стороны, виртуальное наследование и виртуальные функции — это фундаментальные инструменты проектирования. Они позволяют строить по-настоящему гибкие, расширяемые системы, решать проблему ромбовидного наследования. В сложных архитектурах удобство полиморфизма и чистой иерархии часто перевешивают потерю нескольких десятков байтов. Это помогает мыслить категориями абстракций, а не адресов памяти.

С другой стороны, за архитектурную гибкость приходиться платить железом. Как мы уже увидели, виртуальность превращает компактные структуры в громоздкие объекты с дырами в памяти, а двойная косвенная адресация через vbtable может стать "бутылочным горлышком" в критических узлах игровых движков. Однако совсем не использовать этот механизм не стоит. На практике всё зависит от контекста.

Если логика внутри виртуальной функции сложная и длительная, то микроскопическая задержка на поиск адреса в таблице становится пренебрежимо малой и просто растворяется на общем фоне. Более того, при работе с огромным количеством производных классов попытка заменить полиморфизм ручной проверкой типов через switch или if-else часто оказывается медленнее, чем прямой прыжок по адресу из таблицы. А такие альтернативы, как CRTP, которые обещают бесплатную статическую типизацию, в сценариях со множественным наследованием быстро превращаются в неподдерживаемых монстров.

В таких иерархиях стандартная виртуальность остаётся меньшим из зол, обеспечивая чистоту кода при вполне приемлемой цене. В конечном счёте выбор между статической и динамической структурой — это всегда поиск баланса.

Заключение

Итак, мы прошли достаточно долгий путь и теперь видим, что виртуальность и выравнивание — неразрывный тандем, диктующий физическую структуру объекта. Понимание этих механизмов превращает абстрактные классы в чёткую архитектурную схему, где каждый байт и каждое смещение находятся под инженерным контролем. Это позволяет находить баланс между гибкостью динамического полиморфизма и эффективностью исполнения кода.

Чтобы на практике убедится, что код находится под вашим полным контролем, и сложные иерархии не создают скрытых проблем с памятью, вы можете воспользоваться нашим статическим анализатором.

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

Комментарии (0)

Следующие комментарии next comments
close comment form