Однажды в Slack я наткнулся на новый акроним для моего глоссария акронимов C++: “VTT.” Godbolt:

test.o: In function `MyClass':
test.cc:3: undefined reference to `VTT for MyClass'

“VTT” в данном контексте означает «таблица виртуальных таблиц» (virtual table table). Это вспомогательная структура данных, используемая (в Itanium C++ ABI) при создании некоторых базовых классов, которые сами унаследованы от виртуальных базовых классов. VTT следуют тем же правилам размещения, что и виртуальные таблицы (vtable) и информация о типе (typeinfo), так что если вы получили ошибку, приведённую выше, вы можете просто мысленно подставить «vtable» вместо «VTT», и начать отладку. (Скорее всего, вы оставили неопределённой ключевую функцию класса). Для того, чтобы увидеть, почему VTT, или аналогичная структура, необходима, начнём с основ.

Порядок конструирования для невиртуального наследования


Когда у нас есть иерархия наследования, базовые классы конструируются, начиная с наиболее базового. Чтобы сконструировать Charlie, мы должны сначала сконструировать его родительские классы MrsBucket и MrBucket, рекурсивно, чтобы сконструировать MrBucket, мы должны сначала сконструировать его родительские классы GrandmaJosephine и GrandpaJoe.

Вот так:

struct A {};
struct B : A {};
struct C {};
struct D : C {};
struct E : B, D {};

// Конструкторы вызываются в порядке
// A B C D E

Порядок конструирования для виртуальных базовых классов


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

struct G {};
struct M : virtual G {};
struct F : virtual G {};
struct E : M, F {};

// Конструкторы вызываются в порядке
// G M F E

В прошлом разделе, каждый конструктор отвечал за вызов конструктора своего базового класса. Но сейчас у нас виртуальное наследование, и конструкторы M и F должны как-то знать, что не нужно конструировать G, потому что он общий. Если бы M и F были ответственные за конструирование базовых объектов в этом случае, общий базовый объект был бы сконструирован дважды, а это не очень хорошо.

Чтобы работать с подобъектами виртуального наследования, Itanium C++ ABI разделяет каждый конструктор на две части: конструктор базового объекта и и конструктор полного объекта. Конструктор базового объекта отвечает за конструирование всех подобъектов невиртуального наследования (и их подобъектов, и установку их vptr на их vtable, и запуск кода в фигурных скобках в коде C++). Конструктор полного объекта, который вызывается каждый раз, когда вы создаёте полный объект C++, отвечает за конструирование всех подобъектов виртуального наследования производного объекта и затем делает всё остальное.

Рассмотрим разницу между нашим примером A B C D E из предыдущего раздела и следующим примером:

struct A {};
struct B : virtual A {};
struct C {};
struct D : virtual C {};
struct E : B, D {};

// Конструкторы вызываются в порядке
// A C B D E

Конструктор полного объекта Е сначала вызывает конструкторы базового объекта виртуальных подобъектов A и C; затем вызываются конструкторы базового объекта невиртуального наследования B и D. B и D не несут больше ответственность за конструирование A и C соответственно.

Конструирование таблиц vtable


Пусть у нас есть класс с какими-нибудь виртуальными методами, например, такой (Godbolt):

struct Cat {
    Cat() { poke(); }
    virtual void poke() { puts("meow"); }
};

struct Lion : Cat {
    std::string roar = "roar";
    Lion() { poke(); }
    void poke() override {
        roar += '!';
        puts(roar.c_str());
    }
};

Когда мы конструируем Lion, мы начинаем с конструирования базового подобъекта Cat. Конструктор Cat вызывает poke(). В этой точке мы имеем только один объект Cat — мы пока не инициализировали данные-члены, которые необходимы, чтобы сделать объект Lion. Если конструктор Cat вызовет Lion::poke(), он может попытаться изменить неинициализированный член std::string roar и мы получим UB. Итак, стандарт С++ обязывает нас сделать это в конструкторе Cat, вызов виртуального метода poke() должен вызвать Cat::poke(), не Lion::poke()!

В этом нет проблемы. Компилятор просто заставляет Cat::Cat() (и версию для базового объекта, и версию для полного объекта) начинаться с установки vptr объекта на таблицу vtable объекта Cat. Lion::Lion() вызовет Cat::Cat(), и затем сбросит vptr в значение указателя на таблицу vtable для объекта Cat внутри Lion, перед тем, как запустить код в круглых скобках. Без проблем!

Смещения при виртуальном наследовании


Пусть Cat виртуально наследуется от Animal. Тогда таблица vtable для Cat хранит не только указатели на функции для виртуальных функций-членов Cat, но также смещение виртуального подобъекта Animal внутри Cat. (Godbolt.)

struct Animal {
    const char *data = "hi";
};

struct Cat : virtual Animal {
    Cat() { puts(data); }
};

struct Nermal : Cat {};

struct Garfield : Cat {
    int padding;
};

Конструктор Cat запрашивает член Animal::data. Если этот объект Cat является базовым подобъектом объекта Nermal, то его данные-члены находятся по смещению 8, прямо за vptr. Но если объект Cat является базовым подобъектом объекта Garfield, то данные-члены находятся по смещению 16, за vptr и Garfield::padding. Чтобы справиться с ситуацией, Itanium ABI сохраняет смещения виртуальных базовые объектов в таблице vtable объекта Cat. Таблица vtable для Cat-в-Nermal сохраняет тот факт, что Animal, базовый подобъект Cat, сохранён по смещению 8; таблица vtable для Cat-в-Garfield сохраняет тот факт, что Animal, базовый подобъект Cat, сохранён по смещению 16.

Сейчас объединим это с предыдущим разделом. Компилятор должен убедиться, что Cat::Cat() (как версия базового объекта, так и версия полного объекта) начинается с установки vptr на таблицу vtable для Cat-в-Nermal или на vtable для Cat-в-Garfield, в зависимости от типа наиболее производного объекта! Но как это работает?

Конструктор полного объекта для наиболее производного объекта должен заранее вычислить, на какую таблицу vtable он хочет, чтобы ссылался vptr базового подобъекта в течение времени конструирования объекта, и затем конструктор полного объекта для наиболее производного объекта должен передать эту информацию вниз, в конструктор базового объекта базового подобъекта как скрытый параметр! Посмотрим на сгенерированный код для Cat::Cat() (Godbolt):

_ZN3CatC1Ev: #конструктор полного объекта для Cat
  movq $_ZTV3Cat+24, (%rdi)  # this->vptr = &vtable-for-Cat;
  retq

_ZN3CatC2Ev: # конструктор базового объекта для Cat
  movq (%rsi), %rax  # fetch a value from rsi
  movq %rax, (%rdi)  # this->vptr = *rsi;
  retq

Конструктор базового объекта принимает не только этот скрытый параметр в %rdi, но также скрытый параметр VTT в %rsi! Конструктор базового объекта загружает адрес из (%rsi) и сохраняет адрес в таблице vtable объекта Cat.

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

Зачем нужен ещё один уровень индиректности?


Рассмотрим конструктор полного объекта Nermal.

_ZN3CatC2Ev: # конструктор базового объекта Cat
  movq (%rsi), %rax  # загружаем значение из rsi
  movq %rax, (%rdi)  # this->vptr = *rsi;
  retq
_ZN6NermalC1Ev: # конструктор полного объекта Nermal
  pushq %rbx
  movq %rdi, %rbx
  movl $_ZTT6Nermal+8, %esi    # %rsi = &VTT-for-Nermal
  callq _ZN3CatC2Ev            # вызываем конструктор базового объекта Cat
  movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal
  popq %rbx
  retq
_ZTT6Nermal:
  .quad _ZTV6Nermal+24         # vtable-for-Nermal
  .quad _ZTC6Nermal0_3Cat+24   # construction-vtable-for-Cat-in-Nermal

Почему _ZTC6Nermal0_3Cat+24 размещено в секции data и его адрес передаётся в %rsi, вместо того, чтобы просто передать _ZTC6Nermal0_3Cat+24 напрямую?

# Почему не так?

_ZN3CatC2Ev: # конструктор базового объекта для Cat
  movq %rsi, (%rdi)  # this->vptr = rsi;
  retq
_ZN6NermalC1Ev: # конструктор полного объекта для Nermal
  pushq %rbx
  movq %rdi, %rbx
  movl $_ZTC6Nermal0_3Cat+24, %esi # %rsi = &construction-vtable-for-Cat-in-Nermal
  callq _ZN3CatC2Ev                # вызов конструктора базового объекта Cat
  movq $_ZTV6Nermal+24, (%rbx)     # this->vptr = &vtable-for-Nermal
  popq %rbx
  retq

Так происходит потому, что у нас может быть несколько уровней наследования! На каждом уровне наследования конструктор базового объекта должен установить vptr и затем, возможно, передать управление дальше по цепочке, к следующему базовому конструктору, который может установить vptrs в какое-то другое значение. Это подразумевает список либо таблицу указателей на vtable.

Вот конкретный пример (Godbolt):

struct VB {
    int member_of_vb = 42;
};
struct Grandparent : virtual VB {
    Grandparent() {}
};
struct Parent : Grandparent {
    Parent() {}
};

struct Gretel : Parent {
    Gretel() : VB{1000} {}
};
struct Hansel : Parent {
    int padding;
    Hansel() : VB{2000} {}
};

Объект базового конструктора Grandparent должен установить свой vptr в значение Grandparent-в-чём-нибудь, что является наиболее производным классом. Конструктор базового объекта Parent должен сначала вызвать Grandparent::Grandparent() с подходящим %rsi, и затем установить vptr в значение Parent-в-чём-нибудь, что является наиболее производным классом. Способ реализации этого для Gretel:

Gretel::Gretel() [конструктор полного объекта]:
  pushq %rbx
  movq %rdi, %rbx
  movl $1000, 8(%rdi) # imm = 0x3E8
  movl $VTT for Gretel+8, %esi
  callq Parent::Parent() [конструктор базового объекта]
  movq $vtable for Gretel+24, (%rbx)
  popq %rbx
  retq
VTT for Gretel:
  .quad vtable for Gretel+24
  .quad construction vtable for Parent-in-Gretel+24
  .quad construction vtable for Grandparent-in-Gretel+24

Вы можете видеть в Godbolt, что конструктор базового объекта класса Parent сначала вызывает Grandparent::Grandparent() с %rsi+8, затем устанавливает собственный vptr в (%rsi). Итак, здесь используется факт того, что Гретель, так сказать, тщательно проложила тропу из хлебных крошек, по которой шли все её базовые классы при конструировании.

Та же VTT используется в деструкторе (Godbolt). Насколько я знаю, нулевая строка таблицы VTT никогда не используется. Конструктор Gretel загружает vtable для Gretel+24 в vptr, но он знает, что этот адрес статический, его никогда не нужно загружать из VTT. Я думаю, что нулевая строка таблицы сохранилась просто по историческим причинам. (И конечно, компилятор не может просто выбросить её, потому что это будет нарушением Itanium ABI и станет невозможной линковка со старым кодом, который придерживается Itanium-ABI).

Вот и всё, мы рассмотрели таблицу виртуальных таблиц, или VTT.

Дальнейшая информация


Вы можете найти информацию о VTT в этих местах:

StackOverflow: “What is the VTT for a class?
VTable Notes on Multiple Inheritance in GCC C++ Compiler v4.0.1” (Morgan Deters, 2005)
The Itanium C++ ABI, раздел “VTT Order”

Наконец, я должен повторить, что VTT, это особенность Itanium C++ ABI, и используется в Linux, OSX, и т.п. MSVC ABI, используемое в Windows, не имеет VTT, и использует совершенно иной механизм для виртуального наследования. Я (пока что) практически ничего не знаю о MSVC ABI, но может быть, однажды, я всё выясню и напишу об этом пост!

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


  1. COKPOWEHEU
    25.11.2019 13:13

    Совсем недавно писал демонстрационную программку как раз для виртуальной таблицы функций: www.cyberforum.ru/cpp/thread2530423-page3.html#post14017128
    На мой взгляд, без ассемблера оно нагляднее получилось.