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

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

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

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

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

B и D могут поделиться vptr

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

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

5 ответов

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

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

  1. Помните, что размер класса должен быть постоянным
  2. Помните, что при разработке ни 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

Следовательно, vptr здесь указал на Vtable D, и, следовательно, foo применяется был D's. За B это было полностью прозрачно.

Здесь B и D делят один и тот же вптр!

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

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

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

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

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

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

Если у класса есть виртуальные члены, нужно найти способ найти их адрес. Они собраны в постоянной таблице (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 сможет построить свою версию?),

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

Все это связано с тем, как компилятор вычисляет фактические адреса функций метода. Компилятор предполагает, что указатель виртуальной таблицы расположен с известным смещением от основания объекта (обычно смещение 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, соответствующего этому конкретному базовому классу.

Я не мог видеть причину, почему в 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 объекта позволяет процессору найти адрес реализации some_method, который действует для этого объекта. Каждый класс определяет свой собственный vtbl (содержащий адреса всех виртуальных методов), и каждый объект класса начинается с vptr, который указывает на этот vtbl.

Я думаю, что D нуждается в 2 или 3 Vptrs.

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

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

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

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

Другие вопросы по тегам