сведения компилятора об этом указателе, виртуальной функции и множественном наследовании

Я читаю статью Бьерна: Множественное наследование для C ++.

В разделе 3 на странице 370 Бьярне сказал, что «компилятор превращает вызов функции-члена в «обычный» вызов функции с «дополнительным» аргументом; этот «дополнительный» аргумент является указателем на объект, для которого функция-член называется."

Меня смущает лишний этот аргумент. См. следующие два примера:

Пример 1: (стр. 372)

class A {
    int a;
    virtual void f(int);
    virtual void g(int);
    virtual void h(int);
};
class B : A {int b; void g(int); };
class C : B {int c; void h(int); };

Объект C класса c выглядит так:

C:

-----------                vtbl:
+0:  vptr -------------->  -----------
+4:  a                     +0: A::f
+8:  b                     +4: B::g
+12: c                     +8: C::h
-----------                -----------  

Вызов виртуальной функции преобразуется компилятором в косвенный вызов. Например,

C* pc;
pc->g(2)

становится что-то вроде:

(*(pc->vptr[1]))(pc, 2)

В статье Бьярна я сделал такой вывод. Проходная this точка - C*.

В следующем примере Бьярне рассказал другую историю, которая меня совершенно сбила с толку!


Пример 2: (стр. 373)

Учитывая два класса

class A {...};
class B {...};
class C: A, B {...};

Объект класса C может быть расположен как непрерывный объект следующим образом:

pc-->          ----------- 
                  A part
B:bf's this--> -----------  
                  B part
               ----------- 
                  C part
               -----------

Вызов функции-члена B с учетом C*:

C* pc;
pc->bf(2); //assume that bf is a member of B and that C has no member named bf.

Бьерн писал: «Естественно, B::bf() ожидает B* (чтобы стать его указателем this)». Компилятор преобразует вызов в:

bf__F1B((B*)((char*)pc+delta(B)), 2);

Почему здесь нам нужен указатель B*, чтобы быть this? Если мы просто передаем указатель *C как this, я думаю, мы все еще можем правильно обращаться к членам B. Например, чтобы получить член класса B внутри B::bf(), нам просто нужно сделать что-то вроде: *(this+offset). это смещение может быть известно компилятору. Это правильно?


Дополнительные вопросы, например, 1 и 2:

(1) Когда речь идет о выводе по линейной цепочке (пример 1), почему можно ожидать, что объект C будет находиться по тому же адресу, что и подобъекты B и, в свою очередь, A? Нет проблем с использованием указателя C* для доступа к членам класса B внутри функции B::g в примере 1? Например, мы хотим получить доступ к элементу b, что произойдет во время выполнения? *(пк+8)?

(2) Почему мы можем использовать одну и ту же структуру памяти (вывод линейной цепочки) для множественного наследования? Предположим, что в примере 2 классы A, B, C имеют точно такие же члены, что и в примере 1. A: int a и f; B: int b и bf (или назовите это g); C: int c и h. Почему бы просто не использовать макет памяти, например:

 -----------               
+0:  a                     
+4:  b                    
+8: c                     
-----------   

(3) Я написал простой код для проверки различий между выводом по линейной цепочке и множественным наследованием.

class A {...};
class B : A {...};
class C: B {...};
C* pc = new C();
B* pb = NULL;
pb = (B*)pc;
A* pa = NULL;
pa = (A*)pc;
cout << pc << pb << pa

Это показывает, что pa, pb и pc имеют один и тот же адрес.

class A {...};
class B {...};
class C: A, B {...};
C* pc = new C();
B* pb = NULL;
pb = (B*)pc;
A* pa = NULL;
pa = (A*)pc;

Теперь pc и pa имеют один и тот же адрес, а pb является некоторым смещением к pa и pc.

Почему компиляция делает эти различия?


Пример 3: (стр. 377)

class A {virtual void f();};
class B {virtual void f(); virtual void g();};
class C: A, B {void f();};
A* pa = new C;
B* pb = new C;
C* pc = new C;
pa->f();
pb->f();
pc->f();
pc->g()

(1) Первый вопрос касается pc->g(), который относится к обсуждению в примере 2. Выполняет ли компиляция следующее преобразование:

pc->g() ==> g__F1B((*B)((char*)pc+delta(B)))

Или нам нужно дождаться времени выполнения, чтобы сделать это?

(2) Bjarne писал: При входе в C::f указатель this должен указывать на начало объекта C (а не на часть B). Однако во время компиляции обычно не известно, что B, на которое указывает pb, является частью C, поэтому компилятор не может вычесть константу delta(B).

Почему мы не можем знать, что объект B, на который указывает pb, является частью C во время компиляции? Насколько я понимаю, B* pb = new C, pb указывают на созданный объект C, а C наследуется от B, поэтому указатель B pb указывает на часть C.

(3) Предположим, что мы не знаем, что указатель B на pb является частью C во время компиляции. Таким образом, мы должны сохранить дельту (B) для среды выполнения, которая фактически хранится в файле vtbl. Итак, запись vtbl теперь выглядит так:

struct vtbl_entry {
    void (*fct)();
    int  delta;
}

Бьерн писал:

pb->f() // call of C::f:
register vtbl_entry* vt = &pb->vtbl[index(f)];
(*vt->fct)((B*)((char*)pb+vt->delta)) //vt->delta is a negative number I guess

Я тут совсем запутался. Почему (B*) не (C*) в (*vt->fct)((B*)((char*)pb+vt->delta))???? Исходя из моего понимания и введения Бьерна в первом предложении в разделе 5.1 на странице 377, мы должны передать C* как this здесь!!!!!!

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

О чувак!!! Я совершенно не понимаю, что Бьерн пытался сказать? Можете ли вы помочь мне объяснить это?


person Fihop    schedule 10.06.2015    source источник


Ответы (4)


Бьерн писал: «Естественно, B::bf() ожидает B* (чтобы стать его указателем this)». Компилятор преобразует вызов в:

bf__F1B((B*)((char*)pc+delta(B)), 2);

Почему здесь нам нужен указатель B*, чтобы быть this?

Рассмотрим B изолированно: компилятор должен иметь возможность компилировать код аля B::bf(B* this). Он не знает, какие классы могут быть производными от B (и введение производного кода может произойти только после компиляции B::bf). Код для B::bf не будет волшебным образом знать, как преобразовать указатель из какого-либо другого типа (например, C*) в B*, который он может использовать для доступа к членам данных и информации о типах времени выполнения (RTTI / таблица виртуальной диспетчеризации, typeinfo).

Вместо этого вызывающий объект несет ответственность за извлечение действительного B* в подобъект B в любом используемом фактическом типе среды выполнения (например, C). В этом случае C* содержит адрес начала всего объекта C, который, вероятно, совпадает с адресом подобъекта A, а подобъект B является некоторым фиксированным, но не равным 0 смещению дальше в память: это то смещение (в байтах), которые должны быть добавлены к C*, чтобы получить действительный B*, с которым можно вызвать B::bf - эта корректировка выполняется, когда указатель преобразуется из типа C* в тип B*.

(1) Когда речь идет о выводе по линейной цепочке (пример 1), почему можно ожидать, что объект C будет находиться по тому же адресу, что и подобъекты B и, в свою очередь, A? Нет проблем с использованием указателя C* для доступа к членам класса B внутри функции B::g в примере 1? Например, мы хотим получить доступ к элементу b, что произойдет во время выполнения? *(pc+8)?

Линейный вывод B : A и C : B можно рассматривать как последовательное прикрепление полей, специфичных для B, к концу поля A, затем полей, специфичных для C, к концу поля B (которое по-прежнему является полем, специфичным для B, прикрепляемым к концу поля A). ). Итак, все это выглядит так:

[[[A fields...]B-specific-fields....]C-specific-fields...]
 ^
 |--- A, B & C all start at the same address

Затем, когда мы говорим о «B», мы говорим обо всех встроенных полях A, а также о дополнениях, а для «C» по-прежнему есть все поля A и B: все они начинаются с одного и того же адреса. .

Что касается *(pc+8) - правильно (с учетом того, что мы добавляем 8 байтов к адресу, а не обычное поведение C++ при добавлении кратных размеру указателя).

(2) Почему мы можем использовать одну и ту же структуру памяти (вывод линейной цепочки) для множественного наследования? Предположим, что в примере 2 классы A, B, C имеют точно такие же члены, что и в примере 1. A: int a и f; B: int b и bf (или назовите это g); C: int c и h. Почему бы просто не использовать макет памяти, например:

-----------               
+0:  a                     
+4:  b                    
+8: c                     
-----------   

Нет причин - именно так и происходит... такая же схема памяти. Разница в том, что подобъект B не считает A частью самого себя. Теперь вот так:

[[A fields...][B fields....]C-specific-fields...]
 ^             ^
 \ A&C start   \ B starts

Поэтому, когда вы вызываете B::bf, он хочет знать, где начинается объект B - указатель this, который вы предоставляете, должен быть на "+4" в приведенном выше списке; если вы вызываете B::bf с помощью C*, тогда сгенерированный компилятором вызывающий код должен будет добавить это 4, чтобы сформировать неявный параметр this для B::bf(). B::bf() нельзя просто сказать, где A или C начинаются с +0: B::bf() ничего не знает ни об одном из этих классов, ни о том, как достичь b или его RTTI, если вы дадите ему указатель на что-либо, кроме его собственного +4. адрес.

person Tony Delroy    schedule 10.06.2015
comment
Тони, еще два вопроса. Пожалуйста! Спасибо. - person Fihop; 10.06.2015
comment
@FihopZz: добавлено дополнительное объяснение. - person Tony Delroy; 10.06.2015
comment
Тони, большое спасибо. Теперь намного яснее. Что касается второго вопроса, скажем, два указателя C pc и *B pb указывают на начало A и начало B отдельно, чтобы получить доступ к элементу b в классе B, нам нужны *(pc+4) и *(pb)? Как компилятор или среда выполнения узнают, что указатель C должен добавить смещение 4, а указатель B* просто добавляет ноль? - person Fihop; 10.06.2015
comment
@FihopZz: пожалуйста. Я предполагаю, что ваш вопрос выше относится к сценарию множественного наследования: нужны *(pc+4) и *(pb)? - правильно. Откуда компилятор знает... - это его работа - знать - он должен создавать таблицы смещений элементов данных в разных классах программы и использовать эти таблицы при генерации фактического машинного кода. . - person Tony Delroy; 11.06.2015

Функция bf() в вашем примере является членом класса B. Внутри B::bf() вы сможете получить доступ ко всем членам B. Этот доступ осуществляется через указатель this. Поэтому, чтобы этот доступ работал правильно, вам нужно, чтобы this внутри B::bf() точно указывало на B. Вот почему.

Реализация B::bf() не знает, является ли этот объект B автономным объектом B, или объектом B, встроенным в объект C, или каким-то другим объектом B, встроенным во что-то еще. По этой причине B::bf() не может корректировать указатель для this. B::bf() ожидает, что все корректировки указателя будут сделаны заранее, так что, когда B::bf() начнет выполнение, this указывает именно на B и больше нигде.

Это означает, что когда вы вызываете pc->bf(), вы должны настроить значение pc на некоторое фиксированное смещение (смещение B в C) и использовать полученное значение в качестве указателя this для bf().

person AnT    schedule 10.06.2015
comment
У меня есть еще два вопроса. Можешь мне снова помочь? Спасибо - person Fihop; 10.06.2015
comment
Если мы просто передаем указатель *C как this, я думаю, мы все еще можем правильно обращаться к членам B. Например, чтобы получить член класса B внутри B::bf(), нам просто нужно сделать что-то вроде: *(this+offset). это смещение может быть известно компилятору. Это нормально? Обновите исходный вопрос. - person Fihop; 10.06.2015
comment
@FihopZz: Как я уже говорил в своем ответе (и других ответах), когда мы уже внутри B::bf(), мы понятия не имеем, встроен ли наш B в C (как в ваш код) или наш B вообще не имеет ничего общего с C. Помните, что B может быть отдельным объектом, например B b;. Поскольку мы этого не знаем, мы не знаем, должны ли мы добавить немного offset к this или нет. У нас нет возможности узнать, что внутри B::bf(). Чтобы решить эту проблему, задача предоставления уже настроенного значения this помещается в вызывающий код. - person AnT; 10.06.2015
comment
К тому времени, как мы попадем в B::bf(), указатель this уже должен быть правильно настроен вызывающим кодом и указывать точно на B. Таким образом, нам не нужно беспокоиться о добавлении offset к this. - person AnT; 10.06.2015
comment
Большое спасибо, АнТ. Теперь совершенно ясно. Я немного обновил вопрос. Не могли бы вы помочь мне взглянуть на то, когда вы свободны. - person Fihop; 10.06.2015
comment
АнТ, еще один вопрос. Я предполагаю, что компиляция или некоторые исполнители времени выполнения используют реализацию смещения для получения членов класса? Должна существовать таблица смещений, созданная компилятором или что-то еще? Еще этот пример, A имеет int a, B имеет int b, C имеет int c. так что у нас есть 0: a, 4: b, 8: c такой стол? Затем указатель C, такой как pc, может получить доступ к элементу b, проверив таблицу, а затем получить *(pc+4). Но что, если указатель B pb? pb хочет получить доступ к своему члену b. А потом глядя на таблицу, ох, смещение 4??? - person Fihop; 10.06.2015

Возможно, это имеет больше смысла, если вы пока проигнорируете вызов функции и вместо этого рассмотрите преобразование C* в B*, которое требуется перед вызовом bf(). Поскольку подобъект B не начинается с того же адреса, что и объект C, адрес необходимо скорректировать. В случаях, когда у вас есть только один базовый класс, делается то же самое, но смещение (delta(B)) равно нулю, поэтому оно оптимизируется. Затем изменяется только тип, прикрепленный к адресу.

Кстати: ваш цитируемый код (*((*pc)[1]))(pc, 2) не выполняет это преобразование, что формально неверно. Поскольку в любом случае это не настоящий код, вы должны сделать вывод, читая между строк. Возможно, Бьерн просто намеревался использовать здесь неявное преобразование в базовый класс.

Кстати 2: я думаю, вы неправильно понимаете расположение классов с виртуальными функциями. Кроме того, как и в случае отказа от ответственности, фактические макеты зависят от системы, то есть от компилятора и процессора. Во всяком случае, рассмотрим два класса A и B с одной виртуальной функцией:

class A {
    virtual void fa();
    int a;
};
class B {
    virtual void fb();
    int b;
};

Тогда макет будет таким:

-----------                ---vtbl---
+0:  vptr -------------->  +0: A::fa
+4:  a                     ----------  
-----------                

и

-----------                ---vtbl---
+0:  vptr -------------->  +0: B::fb
+4:  b                     ----------  
-----------                

Другими словами, для класса A есть три гарантии (для B эквивалентны):

  • Учитывая указатель A*, по нулевому смещению к этому указателю я нахожу адрес виртуальной таблицы. В нулевой позиции этой таблицы я нахожу адрес функции fa() для этого объекта. Хотя фактическая функция может измениться в производных классах (из-за переопределений), смещение в таблице является фиксированным.
  • Тип функции в vtable также фиксирован. В нулевой позиции vtable находится функция, которая принимает скрытый A* this в качестве параметра. Фактическая функция может быть переопределена в производном классе, но здесь тип функции должен быть сохранен.
  • Учитывая указатель A*, по смещению четыре к этому указателю я нахожу значение переменной-члена a.

Теперь рассмотрим третий класс C:

class C: A, B {
    int c;
    virtual void fa();
};

Его макет будет похож на

-----------                ---vtbl---
+0:  vptr1 ------------->  +0: A::fa
+4:  a                     
+8:  vptr2 ------------->  +4: B::fb
+12: b                     +8: C::fc
+16: c                     ----------  
-----------

Да, этот класс содержит два указателя vtable! Причина проста: расположение классов A и B фиксируется при их компиляции, см. гарантии выше. Чтобы разрешить замену C на A или B (принцип подстановки Лисков), эти гарантии макета должны быть сохранены, поскольку код, обрабатывающий объект, знает только, например. A, но не C.

Некоторые замечания по этому поводу:

  • Выше вы уже найдете оптимизацию, указатель vtable для класса C был объединен с указателем для класса A. Это упрощение возможно только для одного из базовых классов, отсюда и разница между одиночным и множественным наследованием.
  • При вызове fb() для объекта типа C компилятор должен вызвать B::fb с указателем, чтобы соблюдались приведенные выше гарантии. Для этого он должен настроить адрес объекта так, чтобы он указывал на B (смещение +8) перед вызовом функции.
  • Если C переопределяет fb(), компилятор сгенерирует две версии этой функции. Одна версия предназначена для виртуальной таблицы подобъекта B, которая затем принимает B* this в качестве скрытого параметра. Другой будет для отдельной записи в vtable класса C, и он принимает C*. Первый только скорректирует указатель с подобъекта B на объект C (смещение -8) и вызовет второй.
  • Вышеупомянутые три гарантии не являются необходимыми. Вы также можете сохранить смещение переменных-членов a и b внутри vtable. Точно так же корректировка адреса во время вызова функции может быть выполнена косвенно с помощью информации, встроенной в объект через его виртуальную таблицу. Хотя это будет гораздо менее эффективно.
person Ulrich Eckhardt    schedule 10.06.2015
comment
Бьерна становится чем-то вроде: оставляет место для псевдокода, и, поскольку существует линейная цепочка наследования, а не множественное наследование, можно ожидать, что объект C будет по тому же адресу, что и объект B, и, в свою очередь, Подобъекты, поэтому фактической корректировки указателя не ожидается. (Я ожидаю, что вы все это знаете, но для других читателей - это просто моя точка зрения на то, почему явное приведение типов мало что добавит к пониманию среднего читателя). - person Tony Delroy; 10.06.2015
comment
@ Тони Д., значит, линейная цепочка деривации имеет другую структуру памяти, чем множественное наследование? - person Fihop; 10.06.2015
comment
@FihopZz: да ... в линейном выводе подобъекты A, B и C обычно начинаются с одного и того же адреса памяти; при множественном наследовании A и C по-прежнему имеют один и тот же адрес, но B находится после A/с некоторым смещением, отличным от 0, внутри C. Я вижу из вашего обновленного вопроса, что вы наблюдали это при тестировании. - person Tony Delroy; 10.06.2015
comment
Я немного обновил вопрос. Ульрих, не могли бы вы помочь мне взглянуть на него, когда у вас будет время? - person Fihop; 10.06.2015
comment
Я не уверен, поможет ли это вам, но подумайте о том, чтобы взглянуть на Дизайн и эволюцию C++, я думаю, что это должно многое прояснить. Кроме того, подумайте о том, чтобы пройти курс по языку ассемблера. Многие возможности C и, следовательно, C++ являются простыми упрощениями языка ассемблера. Знание этих вещей (и мысленный перевод между ними) делает ответ на ваши вопросы почти очевидным. - person Ulrich Eckhardt; 10.06.2015
comment
@Ульрих Экхардт. Это очень помогает!!! Спасибо! Еще один вопрос: что, если в производном классе C есть две виртуальные переопределенные функции fa и fb, какова структура объекта C? - person Fihop; 11.06.2015
comment
@UlrichEckhardt, еще один вопрос, значит, для объекта C есть только одна виртуальная таблица. - person Fihop; 11.06.2015

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

person pbcub1    schedule 10.06.2015
comment
Извините я не понимаю. Можете ли вы объяснить немного подробнее? - person Fihop; 10.06.2015
comment
Что ж, я не читал его статью и не знаю наверняка, однако я предполагаю, что это будет так, как если бы кто-то спрашивал, что такое this? ты смотришь на него растерянно, что есть что? Если этот человек не хватает что-то или указывает на что-то, вы не знаете, что он имеет в виду под this. Поэтому я хочу сказать, что другим аргументом, о котором вы говорите, будет палец, указывающий на объект, или рука, схватившая объект. Я бы предположил, что это будет так, чтобы компилятор мог понять this. - person pbcub1; 10.06.2015
comment
Вопрос в том, что делает компилятор, а не в том, что он может понять. - person user207421; 10.06.2015
comment
@EJP, я немного обновил вопрос. Ульрих, не могли бы вы помочь мне взглянуть на него, когда у вас будет время. - person Fihop; 10.06.2015