Ваш вопрос интересен, однако я боюсь, что вы ставите слишком большие цели в качестве первого вопроса, поэтому я отвечу в несколько шагов, если вы не возражаете:)
Отказ от ответственности: я не пишу компилятор, и хотя я, конечно, изучал этот вопрос, мое слово следует воспринимать с осторожностью. Будут мне неточности. И я не очень хорошо разбираюсь в RTTI. Кроме того, поскольку это не является стандартным, я описываю возможности.
1. Как реализовать наследование?
Примечание. Я не буду учитывать проблемы с выравниванием, они просто означают, что между блоками может быть добавлен некоторый отступ
Давайте пока не будем использовать виртуальные методы и сконцентрируемся на том, как реализовано наследование, ниже.
Правда состоит в том, что наследование и состав разделяют многое:
struct B { int t; int u; };
struct C { B b; int v; int w; };
struct D: B { int v; int w; };
Будем выглядеть так:
B:
+-----+-----+
| t | u |
+-----+-----+
C:
+-----+-----+-----+-----+
| B | v | w |
+-----+-----+-----+-----+
D:
+-----+-----+-----+-----+
| B | v | w |
+-----+-----+-----+-----+
Шокирует, не правда ли :)?
Это означает, однако, что множественное наследование довольно просто выяснить:
struct A { int r; int s; };
struct M: A, B { int v; int w; };
M:
+-----+-----+-----+-----+-----+-----+
| A | B | v | w |
+-----+-----+-----+-----+-----+-----+
Используя эти диаграммы, давайте посмотрим, что происходит при приведении производного указателя к базовому указателю:
M* pm = new M();
A* pa = pm; // points to the A subpart of M
B* pb = pm; // points to the B subpart of M
Используя нашу предыдущую диаграмму:
M:
+-----+-----+-----+-----+-----+-----+
| A | B | v | w |
+-----+-----+-----+-----+-----+-----+
^ ^
pm pb
pa
Тот факт, что адрес pb
немного отличается от адреса pm
, автоматически обрабатывается с помощью арифметики указателей компилятором.
2. Как реализовать виртуальное наследование?
Виртуальное наследование сложно: вам нужно убедиться, что один V
(для виртуального) объекта будет совместно использоваться всеми другими подобъектами. Давайте определим простое наследование алмазов.
struct V { int t; };
struct B: virtual V { int u; };
struct C: virtual V { int v; };
struct D: B, C { int w; };
Я опущу представление и сконцентрируюсь на том, чтобы в объекте D
оба элемента B
и C
имели один и тот же подобъект. Как это можно сделать?
- Помните, что размер класса должен быть постоянным
- Помните, что при проектировании ни B, ни C не могут предвидеть, будут ли они использоваться вместе или нет
Решение, которое было найдено, поэтому простое: B
и C
оставляют только место для указателя на V
и:
- если вы создадите автономный
B
, конструктор выделит V
в куче, которая будет обрабатываться автоматически
- если вы строите
B
как часть D
, то подпункт B
будет ожидать, что конструктор D
передаст указатель на местоположение V
И то же самое для C
, очевидно.
В D
оптимизация позволяет конструктору зарезервировать пространство для V
прямо в объекте, поскольку D
не наследуется практически ни от B
, ни от C
, давая диаграмму, которую вы показали ( хотя у нас пока нет виртуальных методов).
B: (and C is similar)
+-----+-----+
| V* | u |
+-----+-----+
D:
+-----+-----+-----+-----+-----+-----+
| B | C | w | A |
+-----+-----+-----+-----+-----+-----+
Теперь отметьте, что приведение от B
к A
немного сложнее, чем простая арифметика указателей: вам нужно следовать за указателем в B
, а не простой арифметике указателей.
Хотя есть и худший случай - повышение. Если я дам вам указатель на A
, как вы узнаете, как вернуться к B
?
В этом случае магию выполняет dynamic_cast
, но для этого требуется некоторая поддержка (т.е. информация), хранящаяся где-то. Это так называемая RTTI
(информация о типе времени выполнения). dynamic_cast
сначала определит, что A
является частью D
посредством некоторой магии, а затем запросит информацию времени выполнения D, чтобы узнать, где в D
хранится подобъект B
.
Если бы у нас не было подобъекта B
, он либо возвратил бы 0 (форма указателя), либо выдал исключение bad_cast
(форма ссылки).
3. Как реализовать виртуальные методы?
В общем, виртуальные методы реализуются через v-таблицу (то есть таблицу указателей на функции) для каждого класса и v-ptr для этой таблицы для каждого объекта. Это не единственно возможная реализация, и было продемонстрировано, что другие могут быть быстрее, однако это и просто, и с предсказуемыми издержками (как с точки зрения памяти, так и скорости отправки).
Если мы возьмем простой объект базового класса с виртуальным методом:
struct B { virtual foo(); };
Для компьютера нет таких вещей, как методы членов, поэтому на самом деле у вас есть:
struct B { VTable* vptr; };
void Bfoo(B* b);
struct BVTable { RTTI* rtti; void (*foo)(B*); };
Когда вы выводите из B
:
struct D: B { virtual foo(); virtual bar(); };
Теперь у вас есть два виртуальных метода, один переопределяет B::foo
, другой совершенно новый. Компьютерное представление сродни:
struct D { VTable* vptr; }; // single table, even for two methods
void Dfoo(D* d); void Dbar(D* d);
struct DVTable { RTTI* rtti; void (*foo)(D*); void (*foo)(B*); };
Заметьте, как BVTable
и DVTable
так похожи (поскольку мы ставим foo
перед bar
)? Это важно!
D* d = /**/;
B* b = d; // noop, no needfor arithmetic
b->foo();
Давайте переведем вызов на foo
на машинном языке (несколько):
// 1. get the vptr
void* vptr = b; // noop, it's stored at the first byte of B
// 2. get the pointer to foo function
void (*foo)(B*) = vptr[1]; // 0 is for RTTI
// 3. apply foo
(*foo)(b);
Эти vptrs инициализируются конструкторами объектов, при выполнении конструктора D
вот что произошло:
D::D()
призывает B::B()
в первую очередь инициализировать свои части
B::B()
инициализирует vptr
, чтобы указать на его vtable, затем возвращает
D::D()
инициализировать vptr
, чтобы указать на его vtable, переопределяя B's
Следовательно, vptr
здесь указывало на vtable таблицы D, и, таким образом, foo
применялся к D's. Для B
оно было полностью прозрачным.
Здесь B и D совместно используют один и тот же vptr!
4. Виртуальные таблицы в мульти-наследовании
К сожалению, это не всегда возможно.
Во-первых, как мы видели, в случае виртуального наследования «общий» элемент странным образом позиционируется в конечном законченном объекте. Поэтому у него есть свой vptr. Это 1 .
Во-вторых, в случае множественного наследования первая база выравнивается по всему объекту, но вторая база не может быть (им обоим нужно место для своих данных), поэтому она не может совместно использовать свой vptr. Это 2 .
В-третьих, первая база выровнена по всему объекту, что дает нам ту же компоновку, что и в случае простого наследования (та же возможность оптимизации). Это 3 .
Довольно просто, нет?