[Перевод] C++ vtables. Часть 1 (basics + multiple Inheritance)

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

ehlhoxzs1sz7v7xtni9lizjefxq.png

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

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


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

40podustjhxkcl7_keexd9uea2e.png

отлично, тогда давайте начнем.


Часть 1 — vtables — Основы

Давайте рассмотрим следующий код:

#include 
using namespace std;

class NonVirtualClass {
 public:
  void foo() {}
};

class VirtualClass {
 public:
  virtual void foo() {}
};

int main() {
  cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl;
  cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl;
}
$ # скомпилируйте и запустите main.cpp
$ clang++ main.cpp && ./a.out
Size of NonVirtualClass: 1
Size of VirtualClass: 8

NonVirtualClass имеет размер 1 байт, потому что в C++ классы не могут иметь нулевой размер. Однако сейчас это не важно.

Размер VirtualClass составляет 8 байт на 64-битной машине. Почему? Потому что внутри есть скрытый указатель, указывающий на vtable. vtables — это статические таблицы трансляции, создаваемые для каждого виртуального класса. Эта статья рассказывает об их содержании и о том, как они используются.

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

#include 

class Parent {
 public:
  virtual void Foo() {}
  virtual void FooNotOverridden() {}
};

class Derived : public Parent {
 public:
  void Foo() override {}
};

int main() {
  Parent p1, p2;
  Derived d1, d2;

  std::cout << "done" << std::endl;
}
$ # скомпилируем наш код с отладочными символами и начнем отладку, используя gdb
$ clang++ -std=c++14 -stdlib=libc++ -g main.cpp && gdb ./a.out
...
(gdb) # установим gdb автоматически де-декорировать символы C++
(gdb) set print asm-demangle on
(gdb) set print demangle on
(gdb) # установим точку останова на main
(gdb) b main
Breakpoint 1 at 0x4009ac: file main.cpp, line 15.
(gdb) run
Starting program: /home/shmike/cpp/a.out

Breakpoint 1, main () at main.cpp:15
15    Parent p1, p2;
(gdb) # перейдем к следующей строке
(gdb) n
16    Derived d1, d2;
(gdb) # перейдем к следующей строке
(gdb) n
18    std::cout << "done" << std::endl;
(gdb) # выведем p1, p2, d1, d2 - мы скоро поговорим о том, что означает вывод
(gdb) p p1
$1 = {_vptr$Parent = 0x400bb8 }
(gdb) p p2
$2 = {_vptr$Parent = 0x400bb8 }
(gdb) p d1
$3 = { = {_vptr$Parent = 0x400b50 }, }
(gdb) p d2
$4 = { = {_vptr$Parent = 0x400b50 }, }

Вот что мы узнали из вышеизложенного:
— Несмотря на то, что у классов нет членов данных, существует скрытый указатель на vtable;
— vtable для p1 и p2 одинаков. vtables — это статические данные для каждого типа;
— d1 и d2 наследуют vtable-указатель от Parent, который указывает на vtable Derived;
— Все vtables указывают на смещение 16 (0×10) байтов в vtable. Это мы также обсудим позже.

Давайте продолжим нашу gdb-сессию, чтобы увидеть содержимое vtables. Я буду использовать команду x, которая выводит память на экран. Мы собираемся вывести 300 байтов в шестнадцатеричном формате, начиная с 0×400b40. Почему именно этот адрес? Потому что выше мы видели, что указатель vtable указывает на 0×400b50, а символ для этого адреса vtable for Derived+16 (16 == 0x10).

(gdb) x/300xb 0x400b40
0x400b40 :  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400b48 :    0x90    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400b50 :   0x80    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b58 :   0x90    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400b60 :   0x37    0x44    0x65    0x72    0x69    0x76    0x65    0x64
0x400b68 : 0x00    0x36    0x50    0x61    0x72    0x65    0x6e    0x74
0x400b70 :  0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400b78 : 0x90    0x20    0x60    0x00    0x00    0x00    0x00    0x00
0x400b80 :   0x69    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400b88:   0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400b90 :    0x10    0x22    0x60    0x00    0x00    0x00    0x00    0x00
0x400b98 :  0x60    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400ba0 : 0x78    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400ba8 :   0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x400bb0 : 0x78    0x0b    0x40    0x00    0x00    0x00    0x00    0x00
0x400bb8 :    0xa0    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
0x400bc0 :    0x90    0x0a    0x40    0x00    0x00    0x00    0x00    0x00
...


Примечание: мы смотрим на де-декорированные (demangled) символы. Если вам действительно интересно, _ZTV — это префикс для vtable, _ZTS — это префикс для строки типа (имени), а _ZTI для typeinfo.


Вот структура vtable Parent:

Вот структура vtable Derived:

1:

(gdb) # выясним, какой отладочный символ мы имеем для адреса
0x400aa0
(gdb) info symbol 0x400aa0
Parent::Foo() in section .text of a.out

2:

(gdb) info symbol 0x400a90
Parent::FooNotOverridden() in section .text of a.out

3:

(gdb) info symbol 0x400a80
Derived::Foo() in section .text of a.out

Помните, что указатель vtable в Derived указывал на смещение +16 байтов в vtable? Третий указатель является адресом указателя первого метода. Хотите третий метод? Нет проблем — добавьте 2 sizeof (void) к указателю vtable. Хотите запись typeinfo? перейдите к указателю перед ним.

Двигаемся дальше — как насчет структуры записей typeinfo?

Parent:

А вот запись typeinfo Derived:

1:

(gdb) info symbol 0x602090
vtable for __cxxabiv1::__class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out

2:

(gdb) x/s 0x400b69
0x400b69 :    "6Parent"

3:

(gdb) info symbol 0x602210
vtable for __cxxabiv1::__si_class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out

4:

(gdb) x/s 0x400b60
0x400b60 :   "7Derived"

Если вы хотите узнать больше о __si_class_type_info, вы можете найти некоторую информацию здесь, а также здесь.

Это исчерпывает мои навыки с gdb, а также завершает эту часть. Я предполагаю, что некоторые люди сочтут это слишком низким уровнем, или, возможно, просто не имеющим практической ценности. Если это так, я бы порекомендовал пропустить части 2 и 3, перейдя прямо к части 4.


Часть 2 — Множественное наследование

Мир иерархий одиночного наследования проще для компилятора. Как мы видели в первой части, каждый дочерний класс расширяет родительский vtable, добавляя записи для каждого нового виртуального метода.

Давайте рассмотрим множественное наследование, которое усложняет ситуацию, даже когда наследование реализуется только чисто от интерфейсов.

Посмотрим на следующий фрагмент кода:

class Mother {
 public:
  virtual void MotherMethod() {}
  int mother_data;
};

class Father {
 public:
  virtual void FatherMethod() {}
  int father_data;
};

class Child : public Mother, public Father {
 public:
  virtual void ChildMethod() {}
  int child_data;
};

Обратите внимание, что есть 2 указателя vtable. Интуитивно я бы ожидал 1 или 3 указателя (Mother, Father и Child). На самом деле невозможно иметь один указатель (подробнее об этом далее), и компилятор достаточно умен, чтобы объединять записи дочерней vtable Child как продолжение vtable Mother, сохраняя таким образом 1 указатель.

Почему у Child не может быть одного указателя vtable для всех трех типов? Помните, что указатель Child может быть передан в функцию, принимающую указатель Mother или Father, и оба будут ожидать, что указатель this будет содержать правильные данные в правильных смещениях. Эти функции не обязательно должны знать о Child, и определенно не следует предполагать, что Child — это действительно то, что находится под указателем Mother/Father, которым они оперируют.

(1) Не имеет отношения к этой теме, но, тем не менее, интересно, что child_data фактически помещается в заполнении Father. Это называется «tail padding» и может быть темой будущего поста.

Вот структура vtable:

В этом примере экземпляр Child будет иметь тот же указатель при приведении к указателю Mother. Но при приведении к указателю Father компилятор вычисляет смещение указателя this, чтобы указать на _vptr$Father часть Child (3-е поле в структуре Child, см. таблицу выше).

Другими словами, для данного Child c;: (void)&c!= (void)static_cast(&c). Некоторые люди не ожидают этого, и, возможно, однажды эта информация сэкономит вам время на отладку.

Я находил это полезным не один раз. Но подождите, это еще не все.

Что, если Child решил переопределить один из методов Father? Рассмотрим этот код:

class Mother {
 public:
  virtual void MotherFoo() {}
};

class Father {
 public:
  virtual void FatherFoo() {}
};

class Child : public Mother, public Father {
 public:
  void FatherFoo() override {}
};

Ситуация становится сложнее. Функция может принимать аргумент Father* и вызывать FatherFoo () для него. Но если вы передадите экземпляр Child, ожидается, что он вызовет переопределенный метод Child с правильным указателем this. Тем не менее, вызывающий не знает, что он действительно содержит Child. Он имеет указатель на смещение Child, где находится расположение Father. Кто-то должен выполнить смещение указателя this, но как это сделать? Какую магию выполняет компилятор, чтобы заставить это работать?

Прежде чем мы ответим на это, обратите внимание, что переопределение одного из методов Mother не очень мудрено, так как указатель this одинаков. Child знает, что нужно читать после vtable Mother, и ожидает, что методы Child будут сразу после нее.

Вот решение: компилятор создает метод «переходник» (thunk), который исправляет указатель this, а затем вызывает «настоящий» метод. Адрес метода-переходника будет находиться под vtable Father, в то время как «настоящий» метод будет под vtable Child.

Вот vtable Child:

0x4008e8 :    0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x4008f0 :  0x60    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x4008f8 : 0x00    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400900 : 0x10    0x08    0x40    0x00    0x00    0x00    0x00    0x00
0x400908 : 0xf8    0xff    0xff    0xff    0xff    0xff    0xff    0xff
0x400910 : 0x60    0x09    0x40    0x00    0x00    0x00    0x00    0x00
0x400918 : 0x20    0x08    0x40    0x00    0x00    0x00    0x00    0x00

Что означает:

Объяснение: как мы видели ранее, у Child есть 2 vtables — одна используется для Mother и Child, а другая для Father. В vtable Father, FatherFoo () указывает на «переходник», а в vtable Child указывает непосредственно на Child: FatherFoo ().

А что в этом «переходнике», спросите вы?

(gdb) disas /m 0x400820, 0x400850
Dump of assembler code from 0x400820 to 0x400850:
15    void FatherFoo() override {}
   0x0000000000400820 :  push   %rbp
   0x0000000000400821 :  mov    %rsp,%rbp
   0x0000000000400824 :  sub    $0x10,%rsp
   0x0000000000400828 :  mov    %rdi,-0x8(%rbp)
   0x000000000040082c : mov    -0x8(%rbp),%rdi
   0x0000000000400830 : add    $0xfffffffffffffff8,%rdi
   0x0000000000400837 : callq  0x400810 
   0x000000000040083c : add    $0x10,%rsp
   0x0000000000400840 : pop    %rbp
   0x0000000000400841 : retq   
   0x0000000000400842:  nopw   %cs:0x0(%rax,%rax,1)
   0x000000000040084c:  nopl   0x0(%rax)

Как мы уже обсуждали — смещения this и вызов FatherFoo (). И на сколько мы должны сместить this, чтобы получить Child? top_offset!

Обратите внимание, что я лично считаю, что имя non-virtual thunk чрезвычайно запутанно, поскольку это запись в виртуальной таблице для виртуальной функции. Я не уверен, что тут не виртуального, но это только мое мнение.


Пока на этом всё, в ближайшем будущем переведем 3 и 4 части. Следите за новостями!

© Habrahabr.ru