Размещение объектов. Часть 2: Виртуальность

В предыдущей статье мы рассмотрели то, как располагаются объекты при обычных условиях, под которыми я понимаю отсутствие виртуальности. Почему я отделяю случаи с виртуальностью от других случаев? На мой взгляд, всё что связано с виртуальностью стоит особняком, так как реализация виртуальности задевает размещении объектов не совсем очевидным образом. В настоящей статье мы рассмотрим как компилятор от Майкрософт реализует виртуальное наследование и виртуальные функции. Почему именно MSVC? Ответ прост – с помощью MSVC проще анализировать составляющие объекта, по крайней мере для меня. Также как и в прошлой статье, многое из того, что будет описано здесь, является зависимым от реализации и не является частью стандарта. При тестах использовались те же машины, что и в предыдущей статье. Так как прошлая статья закончилась рассмотрением наследование, то её продолжение начнётся тоже с него.

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

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

struct Gun
{
    float m_Calibre;
    unsigned short m_MagazineSize;
};

struct Handgun: public Gun
{
    enum Type{eAutomatic, eSemiAutomatic, eRevolver};
    Type m_HandgunType;
};

struct Machinegun: public Gun
{
    unsigned short m_BarrelsNumber;
};

struct SubmachineGun: Handgun, Machinegun
{
    float m_HandleSize;
};

Как и в примере из конца предыдущей статьи мы имеем повторное включение высшего класса в иерархии в низшем(т.е. 2 экземпляра Gun в SubmachineGun):

 MultIheritИз рисунка видно, что результирующий объект класса SubmachineGun занимает 28 байтов. В целом тут нет ничего нового, чего бы мы не видели в предыдущей статье, поэтому перейдём к наследованию виртуальному. Из учебников мы знаем, что виртуальное наследование призвано решить проблему так называемого “ромбовидного наследования” и его вариаций. Я не буду описывать виртуальное наследования с точки зрения стандарта языка – это вы можете найти в любом учебнике. Мы, же, рассмотрим одну из реализаций виртуального наследования.

Изменим нашу иерархию для SubmachineGun, вплетя в неё виртуальный базовый класс:

struct Gun
{
    float m_Calibre;
    unsigned short m_MagazineSize;
};

struct Handgun: virtual public Gun
{
    enum Type{eAutomatic, eSemiAutomatic, eRevolver};
    Type m_HandgunType;
};

struct Machinegun: virtual public Gun
{
    unsigned short m_BarrelsNumber;
};

struct SubmachineGun: Handgun, Machinegun
{
    float m_HandleSize;
};

Крохотные изменения в описании классов, дают весьма серьёзные в размещение объектов оных, поэтому, прежде чем рассмотреть, что стало с нашим SubmachineGun, давайте рассмотрим его предка, MachineGun. Вот как объект MachineGun выглядит после наследования Gun виртуально:MachineGunVirt

Как вы можете видеть из вышеприведенной картинки, базовый класс, Gun, переехал в конец объекта Machinegun, а на его месте поселился некий vbptr! Сначала давайте разберёмся с тем, что же такое vbptr и почему он “выгнал” базовый класс в конец: vbptr, это указатель, указывающий на таблицу, в которой содержится информация необходимая для адресования виртуальных базовых классов. Содержания таблицы в нашем примере вынесено в правую часть рисунка: 0 и 16. Как многие уже наверное догадались, 16 это смещение от vbptr до объекта Gun в объекте Machinegun. Ноль же, является смещением от vbptr, до начала объекта класса, к которому данный vbptr принадлежит(в данном случае Machinegun).

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

В общем виде таблица на которую указывает vbptr выглядит следующим образом:

vbptr_layout

Где каждое поле, в MSVC2013, занимает 4 байта. Таким образом, размер таблицы зависит от количества виртуальных базовых классов и не может быть меньше 8 байтов(2 поля)

Итак, что же такое vbptr нам теперь должно быть понятно, но почему vbptr изгнал базовый класс в конец объекта? Я не могу утверждать наверняка, но весьма вероятно это сделано для того, чтобы любой класс, в данном случае Machinegun, имел постоянную структуру(размещение), вне зависимости от того является ли он частью другого класса(благодаря наследования) или же является конечным звеном иерархии. Действительно, давайте представим, что ничего не меняется и мы также размещаем Gun в начале Machinegun. Но тогда мы должны сделать тоже самое и для Handgun, а это, в свою очередь приведёт нас к изначальном проблеме: два подобъекта типа Gun в одном объекте типа SubmachineGun! Вместо этого, объекты виртуальных базовых классов всегда размещаются в конце объекта и, таким образом, дают возможность сохранения структуры объектов вне зависимости от их позиции в иерархии.

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

VirtMultInherit

 

Очевидно, что наш объект вырос, и вырос он с 28 до 48 байтов! Что, на мой взгляд, весьма существенно. Как мы и говорили выше, подобъект Gun располагается в самом конце SubmachineGun, а подобъекты Handgun и Machinegun содержат vbptr, который помогает им добраться до Gun. Из вышеприведённой картинки становится очевидным, что SubmachineGun теперь содержит только один объект Gun и, следовательно, двусмысленность при обращением к объекту Gun более не является нашей проблемой. Теперь у нас есть другая проблема: наш объект увеличился более чем на 50%! И это не всё, что мы заплатим за виртуальное наследование.

В clang объект получился равным 40-а байтам, а всё за счёт более умной стратегии выравнивание – clang использовал меньше заполнителей.

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

SubmachineGun gun;
Handgun& handgun = static_cast<Handgun&>(gun);
handgun.m_Calibre = 9.0;

Для простого, не виртуального наследования, запись 9.0 в m_Calibre  это стандартный однострочный mov* ассемблера: у нас есть адрес операнда, куда поместить нашу константу 9.0, а значит это довольно быстрая операция, которую сделать быстрее уже не представляется возможным. Но что же будет, если мы перекомпилируем вышеприведённый пример, с использованием виртуального базового класса Gun? Здесь мы уже так просто не отделаемся, чтобы выполнить handgun.m_Calibre = 9.0; нам уже не достаточно одной инструкции, т.к. мы не можем обратиться к полю m_Calibre напрямую. Вместо этого нам надо выполнить следующие шаги:

  1. Загрузить vbptr
  2. Считать смещение из таблицы до объекта класса Gun
  3. Обратиться к m_Calibre с помощью ранее полученного смещения

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

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


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

class Gun
{
public:
    virtual void fire() = 0;
    virtual void reload()
    {}
    virtual void putOnSafe()
    {}
    virtual ~Gun()
    {}
private:
    float m_Calibre = 0.0f;
    unsigned short m_MagazineSize = 9;
};

class Machinegun : public Gun
{
public:
    void fire() override
    {}
    void putOnSafe() override
    {}
private:
    unsigned short m_BarrelsNumber = 5;
};

И вот какой у нас получился объект класса Machinegun:

vfptr_plain_layout

Можно заметить, что размещение с виртуальными функциями отличается только тем, что добавляется новый член – vfptr. В остальном, объект класса выглядит как и прежде. Что же представляет собой vfptr? По аналогии с vbptr это указатель на некую таблицу, но в отличии от таблицы на которую указывает vbptr, таблица на которую указывает vfptr содержит не смещения, а адреса функций. Так выглядит таблица виртуальных функций в общем виде:

vfptr_layout

Т.е. фактически она представляет собой массив адресов функций. Так, если мы посмотрим на содержимое таблицы для Machinegun мы увидим, что она содержит одну функцию из Gun, а всё остальное содержимое уже принадлежит Machinegun. Таким образом мы имеем корректную реализацию виртуальных функций: при использовании указателя типа Gun, который указывает на объект Machinegun будут вызваны корректные методы Machinegun, если они переопределены и методы Gun, если нет. Как это работает? Если класс объекта имеет виртуальные функции, то, как мы уже заметили в него добавляется указатель vfptr, который, в свою очередь инициализируется в конструкторе класса. Таким образом, если бы мы создали объект типа Gun(мы не можем этого сделать в силу того, что класс абстрактен), то vfptr этого объекта указывал бы на Gun::VfTable(здесь и далее я буду использовать такой синтаксис для определения принадлежности таблицы ВФ). Если бы мы создали объект Machinegun, то vfptr объекта указывал бы на Gun::Machinegun::VfTable.

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

Еще один интересный вывод можно сделать, если посмотреть на то, как расположены адреса методов в таблице ВФ: их порядок повторяет расположение методов в описании класса. А это значит, что если в одном и том же проекте использовать один и тот же класс, в котором просто изменён порядок виртуальных функций - вы получите крайне “интересный” результат. К счастью, стандарт требует, чтобы описание класса всегда оставалось одинаковым – в противном случае UB(корректное поведение не гарантируется). К несчастью, соблюдение этого правила лежит полностью на плечах программиста :)

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

Рассмотрев простейший случай, перейдём к чуть более усложнённому примеру. Введём новую сущность: Blade(POD-класс) и унаследуем Machinegun не только от Gun, но и от Blade; полученный класс назовём BladedMachinegun(этакий пулемёт со штыком Улыбка):

class Gun
{
public:
    virtual void fire() = 0;
    virtual void reload()
    {}
    virtual void putOnSafe()
    {}
    virtual ~Gun()
    {}
private:
    float m_Calibre = 0.0f;
    unsigned short m_MagazineSize = 9;
};

class Blade
{
private:
    int m_Length;
};

class BladedMachinegun: public Blade, public Gun
{
public:
    void fire() override
    {}
    void putOnSafe() override
    {}
private:
    unsigned short m_BarrelsNumber = 5;
};

Заметьте, что Blade идёт первым в списке родителей. Теперь пришло время взглянуть на то, как MSVC разместил части объекта в памяти:

vfptr_with_plain_parent_and_not

Вопреки тому, что Blade опережает Gun в списке родителей, он располагается после Gun в объекте! Это следствие того, что Gun имеет виртуальные функции и, следовательно, vfptr, который “тяготеет” к началу объекта. Теперь добавим в Blade виртуальные функций и посмотрим на изменения в размещении:

class Blade
{
public:
    virtual void putOnSafe()
    {}
    virtual ~Blade()
    {}
private:
    int m_Length;
};

vfptr_with_both_complex_parents

Весьма предсказуемо, что всё вернулось на круги своя и Blade занял положенное ему место. Из этого можно сделать вывод, что при участии классов с ВФ в наследовании приоритет при размещении имеют именно они. При этом, вызов конструктора остаётся прежним(т.к. этот порядок регламентирован стандартом) строго слева-направо по списку родителей. Кроме того, теперь мы имеем два указателя на таблицу ВФ – по одной на каждого родителя. Таким образом каждый родитель, содержащий хотя бы одну ВФ, добавляет как минимум 8 байт(для 64 бит) к размеру объекта класса-потомка.

В clang, размер объекта составляет 32 байта – опять же за счёт более разумного распределения заполнителей.

 

Функции-преобразователи

Кроме вышесказанного, как вы уже наверное успели заметить, наследование от двух классов с ВФ дало нам весьма необычную таблицу для Gun::vfptr. Появились некие “адреса ФП”. Функция-преобразователь(thunk) представляет собой  смещение + прыжок(jmp в терминах ассемблера). В случае ФП для виртуальных функций происходит смещение this на определённую величину, с последующим прыжком на заданный метод. В нашем случае Адрес ФП 1 находится на месте putOnSafe() метода и Адрес ФП 2, находится на месте деструктора. ФП 1 представляет собой следующее(псевдокод): this-=16; goto BladedMachinegun::putOnSafe, ФП 2: this-=16; goto BladedMachinegun::{dtor}. Как легко заметить, на вышеприведённой схеме размещения, объект Gun, когда является частью объекта BladedMachingun, расположен по смещению 16 относительно начала объекта BladedMachingun. Именно эта разница и нивелируется в наших ФП.

Почему так происходит? Всё достаточно просто, для начала давайте рассмотрим метод, который не имеет преобразователя - BladedMachingun::fire(). Во время компиляции компилятор знает, что эта функция может быть вызвана либо напрямую у объекта BladedMachingun, либо по указателю на родителя Gun(Gun*). В первом случае мы имеем статическое связывание и можем изменить this так, чтобы он указывал на под-объект Gun перед вызовом любой функции унаследованной от Gun, при компиляции. Во втором случае мы имеем динамическое связывание и не можем манипулировать с this, поэтому мы передаём его как есть. Важным моментом тут является то, что внутри метода могут использоваться различные члены класса, а значит будет идти обращение к this по соответствующим смещениям. Поэтому this в методе должен быть всегда одинаковым(указывать на одну и ту же часть объекта), безотносительно связывания, которое было использовано при его вызове. Если вернуться к нашему псевдокоду, то вызов BladedMachingun::fire() со статическим связыванием будет выглядеть так: this += 16; BladedMachingun::fire(this);, тогда как с динамическим связыванием вызов будет выглядеть так: BladedMachingun::fire(this);. Таким образом мы получаем this в BladedMachingun::fire, который всегда указывает на начало подобъекта Gun.

Теперь перейдём к другому методу: BladedMachingun::putOnSafe() – мы наследуем его от Gun и Blade одновременно, и, следовательно, помимо статического связывания мы имеем в наличии возможность динамического связывания через 2 указателя различных типов: Gun* и Blade*. Т.к. мы не можем знать на что в действительности указывает указатель родителя, то мы не можем производить никаких манипуляций с this в месте вызова метода, и, следовательно, должны передавать его как есть. Но тогда мы имеем ситуацию, при которой разные this будут переданы в один и тот же метод! Как тогда метод может быть скомпилирован с жёсткими смещениями? Никак. Именно поэтому, когда мы наследуем одну и ту же функцию от двух и более родителей, только тот родитель, который идёт в объекте первым имеет прямой адрес этой функции в таблице ВФ. Все остальные содержат ФП, которые сначала приводят this к общему знаменателю, а потом уже вызывают(прыгают на) метод. Таким образом в методе всегда будет одинаковый this, вне зависимости от того, как метод был вызван.

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

clang имеет схожую реализацию ФП

Что-то с размещением моим стало

Рассмотрев, что же происходит с размещением объектов в памяти с добавлением ВФ, предлагаю вернуться к нашей изначальной иерархии:

class Gun
{
public:
    virtual void fire() = 0;
    virtual void reload()
    {}
    virtual void putOnSafe()
    {}
    virtual ~Gun()
    {}
private:
    float m_Calibre = 0.0f;
    unsigned short m_MagazineSize = 9;
};

class Handgun: public Gun
{
public:
    void reload() override
    {}
private:
    enum Type{eAutomatic, eSemiAutomatic, eRevolver};
    Type m_HandgunType;
};

class Machinegun: public Gun
{
public:
    void fire() override
    {}
    void putOnSafe() override
    {}
private:
    unsigned short m_BarrelsNumber = 5;
};

class SubmachineGun: public Handgun, public Machinegun
{
public:
    void fire() override
    {}
private:
    float m_HandleSize;
};

Для начала без виртуальных базовых классов, размещение будет следующим:

submachine_with_vfptr

Как вы можете видеть наш объект заметно прибавил в весе и теперь он занимает целых 56 байтов! И это на 8 байтов больше чем, при наличии виртуального базового класса, но без ВФ. Правда нельзя считать это общим случаем, так как разница в 8 байтов продиктована размещением именно этого класса и эти 8 байтов появились за счёт заполнителя. Поэтому, в общем случае, можно считать, что наличие ВФ даёт такое же увеличение в размере объекта класса, как и наличие виртуального предка. Так как ничего необычного вышеприведённое размещение из себя не представляет, и, учитывая что, подобное(ромбовидное) наследование без виртуальных базовых классов, вряд ли где-то может встретиться, то и продолжать разговор о нём нет смысла.

Чуть более интересным является размещение, когда мы заменим “наивное” ромбовидное наследование, на его каноническую реализацию с виртуальным базовым классом:

class Handgun: public virtual Gun
//...
class Machinegun: public virtual Gun
//...

В результате получим следующее размещение:

submachine_with_vbptr_vfptr

Как вы можете видеть, указатели vbptr вытеснили указатели vfptr и, в результате, мы имеем только один vfptr, который указывает на единую таблицу для класса Gun::SubmachineGun. Хотя мы, так же, имеем размер в 56 байтов, дополнительная нагрузка, в лице указателей на таблицы, составляет 3 указателя, в отличии от случая без виртуального базового класса. Т.е. в общем случае добавление виртуального базового класса даст больший размер объекта. Но, учитывая, что ромбовидное наследование без виртуального базового класса это, в лучшем случае, неудобная для использования сущность, то и говорить о преимуществах оного, в каких бы то ни было моментах, не приходится.

Если посмотреть на таблицу ВФ, то можно увидеть два ФП в нем: ФП 1 выглядит так: this-=24; goto Handgun::reload, и ФП 2: this-=8; goto Machinegun::putOnSafe. Полагаю, что они в пояснении не нуждаются, т.к. ничем не отличаются от других ФП, которые мы рассмотрели ранее. Обратите внимание, что в отличии от предыдущего варианта, здесь нет ФП на месте деструктора. Всё это благодаря особенности виртуального наследования: если бы мы наследовали оба деструктора от предков, то было бы неясно как из них должен доминировать и, таким образом, мы не наследуем ни один.

Таким образом, если, к примеру мы имеем Machinegun* указывающий на подобъект SubmachineGun и хотим вызвать метод putOnSafe(), происходит следующая цепочка шагов:

  1. Загружается vbptr и извлекается смещение до Gun.
  2. Загружается Gun::Submachinegun::vftable и извлекается содержимое 3-й строки таблицы ВФ
  3. Исполняется ФП 2(смещается this и, наконец, исполняется метод putOnSafe())

Довольно длинный путь для вызова метода, не так ли?

Как вы думаете, можно ли еще увеличить размер объекта SubmachineGun не добавляя новых членов или родителей? С MSVC нет ничего невозможного: мы просто добавляем конструктор(или деструктор) в SubmachineGun и любуемся полученным размещением:

vtordisp

Заметьте, что после m_HandleSize идут два поля заполнения(это не ошибка в картинке, это отражение действительности) и новое поле vtordisp после них. vtordisp это 4-х байтовое поле, которое необходимо компилятору от MS, чтобы корректно вызывать виртуальные функции в конструкторе и деструкторе. Подробнее можно почитать в официальной документации. Я буду откровенен, я так и не понял как именно это поле используется и помогает в вызове виртуальных функций в конструкторе. В моём случае оно всегда содержало 0. Но, раз оно есть,- значит как-то используется. Как вы можете заметить, с добавлением конструктора мы получили размер объекта равный 64 байтам, что эквивалентно размеру одной кэш-линии в популярных реализациях процессоров. Не плохой размер, не так ли?

В clang не вводится никаких дополнительных сущностей, типа vtordisp, при появлении конструктора и, следовательно, объект остаётся относительно маленьким – “всего” 48 байтов. Размер приличный, но, всё же , на 16 байтов меньше чем в MSVC!

Подводя итоги

В статьях посвященных размещению мы рассмотрели то, как популярные компиляторы(с большим уклоном в сторону MSVC) реализуют те или иные правила языка. Конечно, мы рассмотрели лишь простейшие случаи и, если скомбинировать различные особенности языка, то мы можем увидеть еще более удивительные размещения. Но, как мне кажется, основы всего мы рассмотрели, а рассматривать кучу частных случаев нет необходимости. Главное, что нужно помнить, что за все удобства и абстракции нужно платить. Поэтому при написании, критичного к производительности и размеру занимаемой памяти, кода, нужно всегда держать в уме, что виртуальность может быть весьма дорогим удовольствием. Если нам удалось создать весьма лёгкий, с точки зрения количества членов, объект занимающий в памяти 64 байта, то что же творится в “боевом” коде, где членов бывает куда больше?

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

P.S. я пробовал использовать ключевое слово final, для проверки – сможет ли компилятор оптимизировать размещение класса, зная, что он не может иметь наследников? Ни один из испытанных мною компиляторов не изменил размещение – все они добавляли vfptr даже тогда, когда в нём просто не было смысла.