C++ vtables. Часть 2 (Virtual Inheritance + Compiler-Generated Code)

Автор оригинала: Shahar Mike
  • Перевод

Перевод статьи подготовлен специально для студентов курса «Разработчик С++». Интересно развиваться в данном направлении? Смотрите запись мастер-класса «Практика использования Google Test Framework»!



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


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


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


class ios ...
class istream : virtual public ios ...
class ostream : virtual public ios ...
class iostream : public istream, public ostream

Если бы не ключевое слово virtual, указанное выше, iostream фактически имел бы два экземпляра ios, которые могли бы вызывать головные боли при синхронизации и просто были бы неэффективными.


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


#include <iostream>
using namespace std;

class Grandparent {
 public:
  virtual void grandparent_foo() {}
  int grandparent_data;
};

class Parent1 : virtual public Grandparent {
 public:
  virtual void parent1_foo() {}
  int parent1_data;
};

class Parent2 : virtual public Grandparent {
 public:
  virtual void parent2_foo() {}
  int parent2_data;
};

class Child : public Parent1, public Parent2 {
 public:
  virtual void child_foo() {}
  int child_data;
};

int main() {
  Child child;
}

Давайте исследуем child. Я начну с дампинга большого количества памяти именно там, где начинается vtable Child, как мы это делали в предыдущих частях, а затем проанализирую результаты. Я предлагаю быстро взглянуть на результат здесь и вернуться к нему, когда я раскрою детали ниже.


(gdb) p child
$1 = {<Parent1> = {<Grandparent> = {_vptr$Grandparent = 0x400998 <vtable for Child+96>, grandparent_data = 0}, _vptr$Parent1 = 0x400950 <vtable for Child+24>, parent1_data = 0}, <Parent2> = {_vptr$Parent2 = 0x400978 <vtable for Child+64>, parent2_data = 4195888}, child_data = 0}
(gdb) x/600xb 0x400938
0x400938 <vtable for Child>:    0x20    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400940 <vtable for Child+8>:  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400948 <vtable for Child+16>: 0x00    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400950 <vtable for Child+24>: 0x70    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400958 <vtable for Child+32>: 0xa0    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400960 <vtable for Child+40>: 0x10    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400968 <vtable for Child+48>: 0xf0    0xff    0xff    0xff    0xff    0xff    0xff    0xff
0x400970 <vtable for Child+56>: 0x00    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400978 <vtable for Child+64>: 0x90    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400980 <vtable for Child+72>: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400988 <vtable for Child+80>: 0xe0    0xff    0xff    0xff    0xff    0xff    0xff    0xff
0x400990 <vtable for Child+88>: 0x00    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400998 <vtable for Child+96>: 0x80    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x4009a0 <VTT for Child>:   0x50    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x4009a8 <VTT for Child+8>: 0xf8    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x4009b0 <VTT for Child+16>:    0x18    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x4009b8 <VTT for Child+24>:    0x98    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x4009c0 <VTT for Child+32>:    0xb8    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x4009c8 <VTT for Child+40>:    0x98    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x4009d0 <VTT for Child+48>:    0x78    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x4009d8:   0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x4009e0 <construction vtable for Parent1-in-Child>:    0x20    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x4009e8 <construction vtable for Parent1-in-Child+8>:  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x4009f0 <construction vtable for Parent1-in-Child+16>: 0x50    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x4009f8 <construction vtable for Parent1-in-Child+24>: 0x70    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400a00 <construction vtable for Parent1-in-Child+32>: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400a08 <construction vtable for Parent1-in-Child+40>: 0xe0    0xff    0xff    0xff    0xff    0xff    0xff    0xff
0x400a10 <construction vtable for Parent1-in-Child+48>: 0x50    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400a18 <construction vtable for Parent1-in-Child+56>: 0x80    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400a20 <typeinfo name for Parent1>:   0x37    0x50    0x61    0x72    0x65    0x6e    0x74    0x31
0x400a28 <typeinfo name for Parent1+8>: 0x00    0x31    0x31    0x47    0x72    0x61    0x6e    0x64
0x400a30 <typeinfo name for Grandparent+7>: 0x70    0x61    0x72    0x65    0x6e    0x74    0x00    0x00
0x400a38 <typeinfo for Grandparent>:    0x50    0x10    0x60    0x00    0x00    0x00    0x00    0x00
0x400a40 <typeinfo for Grandparent+8>:  0x29    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400a48:   0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400a50 <typeinfo for Parent1>:    0xa0    0x10    0x60    0x00    0x00    0x00    0x00    0x00
0x400a58 <typeinfo for Parent1+8>:  0x20    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400a60 <typeinfo for Parent1+16>: 0x00    0x00    0x00    0x00    0x01    0x00    0x00    0x00
0x400a68 <typeinfo for Parent1+24>: 0x38    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400a70 <typeinfo for Parent1+32>: 0x03    0xe8    0xff    0xff    0xff    0xff    0xff    0xff
0x400a78:   0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400a80 <construction vtable for Parent2-in-Child>:    0x10    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400a88 <construction vtable for Parent2-in-Child+8>:  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400a90 <construction vtable for Parent2-in-Child+16>: 0xd0    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400a98 <construction vtable for Parent2-in-Child+24>: 0x90    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400aa0 <construction vtable for Parent2-in-Child+32>: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400aa8 <construction vtable for Parent2-in-Child+40>: 0xf0    0xff    0xff    0xff    0xff    0xff    0xff    0xff
0x400ab0 <construction vtable for Parent2-in-Child+48>: 0xd0    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400ab8 <construction vtable for Parent2-in-Child+56>: 0x80    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400ac0 <typeinfo name for Parent2>:   0x37    0x50    0x61    0x72    0x65    0x6e    0x74    0x32
0x400ac8 <typeinfo name for Parent2+8>: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400ad0 <typeinfo for Parent2>:    0xa0    0x10    0x60    0x00    0x00    0x00    0x00    0x00
0x400ad8 <typeinfo for Parent2+8>:  0xc0    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400ae0 <typeinfo for Parent2+16>: 0x00    0x00    0x00    0x00    0x01    0x00    0x00    0x00
0x400ae8 <typeinfo for Parent2+24>: 0x38    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400af0 <typeinfo for Parent2+32>: 0x03    0xe8    0xff    0xff    0xff    0xff    0xff    0xff
0x400af8 <typeinfo name for Child>: 0x35    0x43    0x68    0x69    0x6c    0x64    0x00    0x00
0x400b00 <typeinfo for Child>:  0xa0    0x10    0x60    0x00    0x00    0x00    0x00    0x00
0x400b08 <typeinfo for Child+8>:    0xf8    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b10 <typeinfo for Child+16>:   0x02    0x00    0x00    0x00    0x02    0x00    0x00    0x00
0x400b18 <typeinfo for Child+24>:   0x50    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b20 <typeinfo for Child+32>:   0x02    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400b28 <typeinfo for Child+40>:   0xd0    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b30 <typeinfo for Child+48>:   0x02    0x10    0x00    0x00    0x00    0x00    0x00    0x00
0x400b38 <vtable for Grandparent>:  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400b40 <vtable for Grandparent+8>:    0x38    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b48 <vtable for Grandparent+16>:   0x80    0x08    0x40    0x00    0x00    0x00    0x00    0x00

Вау, здесь много информации. Сразу же всплывают два новых вопроса: что такое VTT и что такое construction vtable for X-in-Child? Мы ответим на них достаточно скоро.
Давайте начнем со структуры памяти Child:


Размер Значение
8 байт _vptr$Parent1
4 байта parent1_data (+ 4 байта заполнения)
8 байт _vptr$Parent2
4 байта parent2_data
4 байта child_data
8 байт _vptr$Grandparent
4 байта grandparent_data (+ 4 байта заполнения)

Действительно, у Child есть только 1 экземпляр Grandparent. Нетривиальная вещь заключается в том, что он последний в памяти, хотя он и является высшим в иерархии.
Вот структура vtable:


Адрес Значение Содержание
0x400938 0x20 (32) virtual-base offset (мы скоро это обсудим)
0x400940 0 top_offset
0x400948 0x400b00 typeinfo for Child
0x400950 0x400870 Parent1::parent1_foo(). Указатель vtable Parent1 указывает сюда.
0x400958 0x4008a0 Child::child_foo()
0x400960 0x10 (16) virtual-base offset
0x400968 -16 top_offset
0x4009 0x400b typeinfo for Child
70 00
0x400978 0x400890 Parent2::parent2_foo(). Указатель vtable Parent2 указывает сюда.
0x400980 0 virtual-base offset
0x400988 -32 top_offset
0x400990 0x400b00 typeinfo for Child
0x400998 0x400880 Grandparent::grandparent_foo(). Указатель vtable Grandparent указывает сюда.

Выше появилась новая концепция — virtual-base offset. Скоро мы поймем, что он там делает.
Далее давайте исследуем эти странно выглядящие construction vtables. Вот construction vtable for Parent1-in-Child:


Значение Содержание
0x20 (32) virtual-base offset
0 top-offset
0x400a50 typeinfo for Parent1
0x400870 Parent1::parent1_foo()
0 virtual-base offset
-32 top-offset
0x400a50 typeinfo for Parent1
0x400880 Grandparent::grandparent_foo()

На данный момент я думаю, что было бы более понятным описать процесс, нежели наваливать на вас больше таблиц со случайными числами. Итак:


Представьте, что вы Child. Вас просят сконструировать себя в новом участке памяти. Поскольку вы наследуете Grandparent напрямую (именно это означает виртуальное наследование), сначала вы будете напрямую вызывать его конструктор (если бы это не было виртуальное наследование, вы вызывали бы конструктор Parent1, который, в свою очередь, вызвал бы конструктор Grandparent). Вы устанавливаете this += 32 байта, так как именно здесь находятся данные Grandparent, и вызываете конструктор. Очень просто.


Затем наступает время сконструировать Parent1. Parent1 может с уверенностью предполагать, что к тому времени, когда он конструирует себя, Grandparent уже был создан, поэтому он может, например, получить доступ к данным и методам Grandparent. Но подождите, как он может знать, где найти эти данные? Они ведь не в одном месте с переменными Parent1!


На сцену выходит construction table for Parent1-in-Child. Эта таблица предназначена для указания Parent1, где найти фрагменты данных, к которым он может получить доступ. this указывает на данные Parent1. virtual-base offset указывает, где можно найти данные Grandparent: Перешагните на 32 байта вперед от this, и вы найдете память Grandparent. Поняли? virtual-base offset аналогичен top_offset, но для виртуальных классов.


Теперь, когда мы это понимаем, конструирование Parent2 в основном такое же, только с использованием construction table for Parent2-in-Child. И действительно, Parent2-in-Child имеет virtual-base offset в 16 байтов.


Дайте информации немного впитаться. Вы готовы продолжить? Хорошо.
Теперь давайте вернемся к VTT. Вот структура VTT:


Адрес Значение Символ Содержание
0x4009a0 0x400950 vtable for Child + 24 Записи Parent1 в vtable Child
0x4009a8 0x4009f8 construction vtable for Parent1-in-Child + 24 Методы Parent1 в Parent1-in-Child
0x4009b0 0x400a18 construction vtable for Parent1-in-Child + 56 Методы Grandparent для Parent1-in-Child
0x4009b8 0x400a98 construction vtable for Parent2-in-Child + 24 Методы Parent2 в Parent2-in-Child
0x4009c0 0x400ab8 construction vtable for Parent2-in-Child + 56 `Методы Grandparent для Parent2-in-Child
0x4009c8 0x400998 vtable for Child + 96 `Записи Grandparent в vtable Child
0x4009d0 0x400978 vtable for Child + 64 `Записи Parent2 в vtable Child

VTT расшифровывается как virtual-table table (таблицу виртуальных таблиц), что означает, что это таблица vtable-ов. Это таблица трансляции, которая знает, например, вызывается ли конструктор Parent1 для отдельного объекта, для объекта Parent1-in-Child или для Parent1-in-SomeOtherObject. Она всегда появляется сразу после vtable, чтобы компилятор знал, где ее найти. Поэтому нет необходимости хранить другой указатель в самих объектах.


Фух… много деталей, но я думаю, что мы охватили все, что я хотел охватить. В четвертой части мы поговорим о деталях vtables более высокого уровня. Не пропускайте, так как это, вероятно, самая важная часть в этой статье!


Часть 4 — Код, сгенерированный компилятором


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


Конструкторы


Для конструктора любого класса генерируется следующий код:


  • Вызов конструктов родителей, если они есть;
  • Установка указателей vtable, если они есть;
  • Инициализация членов в соответствии со списком инициализаторов;
  • Выполнение кода внутри скобок конструктора.

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


  • Родительские конструкторы по умолчанию запускаются автоматически, если не указано иное;
  • Члены инициализируются по умолчанию, если у них нет значения по умолчанию или записи в списке инициализатора;
  • Весь конструктор может быть помечен = default;
  • Только назначение vtable всегда скрыто.

Вот пример:


#include <iostream>
#include <string>
using namespace std;

class Parent {
public:
    Parent() { Foo(); }
    virtual ~Parent() = default;
    virtual void Foo() { cout << "Parent" << endl; }
    int i = 0;
};

class Child : public Parent {
public:
    Child() : j(1) { Foo(); }
    void Foo() override { cout << "Child" << endl; }
    int j;
};

class Grandchild : public Child {
public:
    Grandchild() { Foo(); s = "hello"; }
    void Foo() override { cout << "Grandchild" << endl; }
    string s;
};

int main() {
    Grandchild g;
}

Давайте напишем псевдокод для конструктора каждого класса:


Parent Child Grandchild
1. vtable = vtable Parent; 1. Вызывает конструктор по умолчанию Parent; 1. Вызывает конструктор по умолчанию Child;
2. i = 0; 2. vtable = vtable Child; 2. vtable = vtable Grandchild;
3. Вызывает Foo(); 3. j = 1; 3., Вызывает конструктор по умолчанию s;
4. Вызывает Foo(); 4. Вызывает Foo();
5. Вызывает оператор = для s;

Учитывая это, неудивительно, что в контексте конструктора класса vtable указывает на vtable этого самого класса, а не на его конкретный класс. Это означает, что виртуальные вызовы разрешаются так, как будто доступных наследников нет. Таким образом, вывод


Parent
Child
Grandchild

А как насчет чисто виртуальных функций? Если они не реализованы (да, вы можете реализовать чисто виртуальные функции, но зачем вам это надо?), Вы, вероятно, (и мы надеемся) проследуете прямиком к segfault. Некоторые компиляторы пренебрегают ошибку, что круто.


Деструкторы


Как вы можете себе представить, деструкторы ведут себя так же, как конструкторы, только в обратном порядке.


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


Неявное приведение


Как мы видели во второй и третей части, указатель на дочерний объект не обязательно равен родительскому указателю того же экземпляра (как в случае множественного наследования).


Тем не менее, для вас (разработчика) нет дополнительной работы по вызову функции, которая получает указатель родителя. Это потому, что компилятор неявно сдвигает this, когда вы апкастите указатели и ссылки на родительские классы.


Динамическое приведение (RTTI)


При динамическом приведении используются таблицы typeinfo, которые мы исследовали в первой части. Они делают это во время выполнения, просматривая запись typeinfo за один указатель до того, на что указывает указатель vtable, и используют класс оттуда, чтобы проверить, возможно ли приведение.


Это объясняет стоимость dynamic_cast при частом использовании.


Указатели на методы


Я планирую написать полный пост об указателях на методы в будущем. До этого я хотел бы подчеркнуть, что указатель на метод, указывающий на виртуальную функцию, будет фактически вызывать переопределенный метод (в отличие от указателей на функции, не являющиеся членами).


// TODO: добавить ссылку, когда пост будет готов

Проверьте себя!


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


#include <iostream>
using namespace std;

class FooInterface {
public:
    virtual ~FooInterface() = default;
    virtual void Foo() = 0;
};

class BarInterface {
public:
    virtual ~BarInterface() = default;

    virtual void Bar() = 0;
};

class Concrete : public FooInterface, public BarInterface {
public:
    void Foo() override { cout << "Foo()" << endl; }
    void Bar() override { cout << "Bar()" << endl; }
};

int main() {
    Concrete c;
    c.Foo();
    c.Bar();

    FooInterface* foo = &c;
    foo->Foo();

    BarInterface* bar = (BarInterface*)(foo);
    bar->Bar(); // Выводит "Foo()" - WTF?
}

На этом я заканчиваю свою статью из четырех частей. Я надеюсь, что вы узнали что-то новое, так же как и я.

OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 2

    0
    Отличные статьи, спасибо! Правда про VTT кажется получилось немного скомканно.
    А вообще, имеет ли виртуальное наследование отношение к виртуальным функциям (кроме того что используется общее ключевое слово и, судя по всему, схожие механизмы доступа)?
      0
      Теперь вы сможете объяснить себе, почему следующий фрагмент кода ведет себя так, как он себя ведет:

      C-style каст сделал reinterpret_cast к BarInterface с таким же представлением и вызов BarInterface::Bar попал на FooInterface::Foo?

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

      Самое читаемое