Понимание vptr в множественном наследовании?

Я пытаюсь понять утверждение в книге с эффективным С++. Ниже приведена диаграмма наследования для множественного наследования.

enter image description here

enter image description here

Теперь в книге говорится, что для vptr требуется отдельная память в каждом классе. Также он делает следующее утверждение

Нечетность на приведенной выше диаграмме состоит в том, что есть только три vptrs, хотя задействованы четыре класса. Реализации могут генерировать четыре vptrs, если они нравятся, но три достаточно (оказывается, что B и D может совместно использовать vptr), и большинство реализаций используют эту возможность, чтобы уменьшить накладные расходы, генерируемые компилятором.

Я не видел никакой причины, по которой в каждом классе есть потребность в отдельной памяти для vptr. У меня было понимание, что vptr унаследовано от базового класса, каков бы ни был тип наследования. Если предположить, что он показал результирующую структуру памяти с унаследованным vptr, как они могут сделать утверждение, что

B и D могут совместно использовать vptr

Может кто-нибудь прояснить немного о vptr в множественном наследовании?

  • Нужен ли нам отдельный vptr в каждом классе?
  • Также, если выше верно, почему B и D могут совместно использовать vptr?

Ответы

Ответ 1

Твой вопрос интересен, однако я боюсь, что ты слишком большой, как первый вопрос, поэтому я отвечу в несколько шагов, если ты не против:)

Отказ от ответственности: я не автор-компилятор, и хотя я, конечно же, изучил этот вопрос, мое слово следует принимать с осторожностью. Там будут неточности. И я не так хорошо разбираюсь в 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

И idem для 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 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

Следовательно, vptr здесь указывает на D vtable, и, таким образом, применяемый foo был D. Для B он был полностью прозрачным.

Здесь B и D используют один и тот же vptr!

4. Виртуальные таблицы в многоуровневом

К сожалению, этот обмен не всегда возможен.

Во-первых, как мы видели, в случае виртуального наследования "общий" элемент задан странно в конечном завершенном объекте. Поэтому у него есть собственный vptr. Это 1.

Во-вторых, в случае многонаследования первая база выравнивается с полным объектом, но вторая база не может быть (им обоим нужно пространство для своих данных), поэтому она не может делиться своим vptr. Это 2.

В-третьих, первая база выровнена с полным объектом, тем самым предлагая нам тот же макет, что и в случае простого наследования (такая же возможность оптимизации). Это 3.

Довольно просто, нет?

Ответ 2

Если класс имеет виртуальных членов, нужно найти их адрес. Они собираются в постоянной таблице (vtbl), адрес которой хранится в скрытом поле для каждого объекта (vptr). Вызов виртуального элемента по существу:

obj->_vptr[member_idx](obj, params...);

Производный класс, добавляющий виртуальных членов в его базовый класс, также нуждается в их месте. Таким образом, новый vtbl и новый vptr для них. Вызов унаследованного виртуального участника по-прежнему

obj->_vptr[member_idx](obj, params...);

и вызов нового виртуального члена:

obj->_vptr2[member_idx](obj, params...);

Если база не является виртуальной, можно установить вторую vtbl, которая будет помещена сразу после первой, эффективно увеличивая размер vtbl. И _vptr2 больше не нужен. Таким образом, вызов нового виртуального элемента:

obj->_vptr[member_idx+num_inherited_members](obj, params...);

В случае (не виртуального) множественного наследования один наследует два vtbl и два vptr. Они не могут быть объединены, и вызовы должны обратить внимание на добавление смещения к объекту (для того, чтобы наследованные члены данных были найдены в нужном месте). Вызовы для первых членов базового класса будут

obj->_vptr_base1[member_idx](obj, params...);

и для второго

obj->_vptr_base2[member_idx](obj+offset, params...);

Новые виртуальные члены снова могут быть добавлены в новый vtbl или добавлены к vtbl первой базы (чтобы в будущих вызовах не добавлялись смещения).

Если база является виртуальной, нельзя добавить новый vtbl в унаследованный, поскольку это может привести к конфликтам (в примере, который вы указали, если оба B и C добавляют свои виртуальные функции, как D может построить его версия?).

Таким образом, A нуждается в vtbl. B и C нужны vtbl, и он не может быть добавлен к A, потому что A является виртуальной базой обоих. D требует vtbl, но он может быть добавлен к B, поскольку B не является виртуальным базовым классом D.

Ответ 3

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

Класс B и класс C будут иметь совершенно разные структуры виртуальных таблиц, поскольку они имеют разные методы. Виртуальная таблица для класса D может выглядеть как виртуальная таблица для класса B, за которой следуют дополнительные данные для методов класса C.

Когда вы создаете объект класса D, вы можете использовать его как указатель на B или как указатель на C или даже как указатель на класс A. Вы можете передать эти указатели в модули, которые даже не знают о существовании класса D, но может вызывать методы класса B или C или A. Эти модули должны знать, как найти указатель на виртуальную таблицу класса, и им нужно знать, как найти указатели на методы класса B/C/A в виртуальной таблице. Для этого вам нужно иметь отдельные VPTR для каждого класса.

Класс D хорошо осведомлен о существовании класса B и структуре его виртуальной таблицы и поэтому может расширить свою структуру и повторно использовать VPTR из объекта B.

Когда вы накладываете указатель на объект D на указатель на объект B или C или A, он фактически обновляет указатель некоторым смещением, так что он начинается с vptr, соответствующего этому конкретному базовому классу.

Ответ 4

Я не мог понять, почему является требованием отдельной памяти в каждый класс для vptr

Во время выполнения, когда вы вызываете (виртуальный) метод с помощью указателя, ЦП не знает о фактическом объекте, по которому отправляется этот метод. Если у вас есть B* b = ...; b->some_method();, то переменная b может потенциально указывать на объект, созданный с помощью new B() или через new D() или даже new E(), где E - это другой класс, который наследует (либо) B или D. Каждый из этих классов может предоставить свою собственную реализацию (переопределить) для some_method(). Таким образом, вызов b->some_method() должен отправлять реализацию из B, D или E в зависимости от объекта, на который указывает b.

vptr объекта позволяет CPU найти адрес реализации some_method, который действует для этого объекта. Каждый класс определяет его собственный vtbl (содержащий адреса всех виртуальных методов), и каждый объект класса начинается с vptr, который указывает на этот vtbl.

Ответ 5

Думаю, D нуждается в 2 или 3 vptrs.

Здесь A может потребоваться или не потребоваться vptr. B нужен тот, который не должен использоваться совместно с A (поскольку A фактически унаследован). C требуется тот, который не должен использоваться совместно с A (то же самое). D может использовать B или C vftable для своих новых виртуальных функций (если они есть), поэтому он может делиться B или C.

Моя старая статья "С++: Under the Hood" объясняет реализацию виртуальных базовых классов Microsoft С++. http://www.openrce.org/articles/files/jangrayhood.pdf

И (MS С++) вы можете скомпилировать с cl/d1reportAllClassLayout, чтобы получить текстовый отчет о макетах памяти классов.

Счастливый взлом!