Преобразование типов при множественном наследовании в верхнем и нижнем направлениях. Коррекция указателя this

 

Структура размещения данных класса-примеси ImageButton в памяти предполагает, что в начале будут размещаться данные первого базового класса Button, затем второго ImageControl. Если бы ImageButton содержал собственные дополнительные поля, они бы размещались в объекте после полей обоих базовых классов:

 

Указатель или ссылка на объект класса ImageButton может быть преобразован к указателю/ссылке на объект любого из двух базовых классов:

 

ImageButton ib( “OK”, “ok.png” );

Button * pButton = & ib;

ImageControl * pControl = & ib;

 

 

Несмотря на манипулирование одним и тем же объектом ImageButton, абсолютные значения адресов в преобразованных указателях pButton и pControl совпадать не будут. Это вытекает из расположения полей базовых классов в памяти объекта. Поля первого базового класса находятся в начале объекта, и адрес pButton будет совпадать с адресом начала объекта. Но поля второго базового класса смещены от начала объекта на размер первого базового класса. Соответственно, этот адрес не совпадает с адресом начала объекта.

 

Учитывая факт возможного смещения адресов в иерархии множественного наследования, следует всячески избегать попыток преобразования вниз по такой иерархии. В иерархии одиночного наследования адреса начала базового и производного объектов всегда совпадают. Но в такой более сложной иерархии с несколькими базовыми классами, даже при правильном предположении о точном производном классе, преобразование может быть некорректным по ветке второго базового класса:

 

 

ImageButton * pIB1 = ( ImageButton * ) pButton;

ImageButton * pIB2 = ( ImageButton * ) pControl;

Результат преобразования здесь зависит от наличия полного определения всей иерархии в контексте, в котором осуществляется данное преобразование.

 

 

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

 

 

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

 

“истинное” поведение можно наблюдать только лишь при дизассемблировании

 

Интересно, что квалифицированные вызовы, в отличие от виртуальных вызовов, при компиляции в конфигурации Release будут “встраиваться” (они являются inline, так как реализованы непосредственно в определении класса):

 

Ассемблерный код квалифицированных вызовов заметно короче по сравнению с виртуальными вызовами, т.к. не требуется извлекать из объекта указатель vptr и адрес фактического метода.

 

 

Почему же на консольном выводе не видно данного смещения при вызове функции Derived::f2? Компилятор всячески скрывает такие особенности реализации, и в теле Derived::f2 фактическое обращение к ключевому слову this на низком уровне происходит с отрицательным смещением.

 

Возникает естественный вопрос - а зачем вообще компилятору нужно генерировать это фактическое смещение this при вызове метода, а затем имитировать его отсутствие? Очевидно, это необходимо для обеспечения корректности работы виртуальных функций из второго базового класса. Когда происходит преобразование типа производного объекта к указателю на второй базовый класс, адрес смещается. Далее, при вызове виртуальной функции через этот указатель управление должно попадать в производный класс, т.к. он переопределяет f2.

 

Base2 * pB = pD;

pB->f2();

 

 

Разумеется, код, который манипулирует базовым классом, не может знать ничего о наличии производного класса, тем более о необходимом обратном смещении. Соответственно, после извлечения адреса функции из таблицы VTABLE, будет осуществлен вызов с передачей в качестве this адреса pB. Однако, этот адрес больше адреса производного объекта на 8 байт!

 

 

Как же быть? Ведь имеется только одна версия машинного кода для каждого метода. Этот единственный вариант кода Derived::f2 должен корректно работать независимо от способа, которым он вызван - непосредственно на объекте Derived, либо через виртуальную функцию в базовом классе Base2. В связи с этим, компилятор и применяет описанный прием с неявным смещением this внутри методов, унаследованных/переопределенных по второй и последующим веткам иерархии. Код, знающий конкретный тип объекта, всегда самостоятельно генерирует корректное смещение. В то же время обеспечивается правильное функционирование кода, работающего через базовый класс, который может не заботиться о каком-либо смещении this.

 

Еще один технический момент, требующий пояснений, состоит в возможном конфликте имен между элементами базового класса. При обращении к повторяющемуся имени через объект производного класса возникнет неоднозначность.

Разрешить конфликт имен можно квалифицированными вызовами:

 

Int main ()

{

Derived d;

d.Base1::f();

d.Base2::f();

}

 

либо при помощи using-объявления:

 

Class Derived

: public Base1, public Base2

{

public:

using Base2::f;

};

 

 

Int main ()

{

Derived d;

d.f(); // используем Base2::f

}

 

Еще более сложный случай представляет собой ситуация, при которой оба базовых класса имеют виртуальные функции с одинаковыми названиями, а производный класс желает их переопределить. происходят регулярные смещения указателя this на 4 байта в положительную и отрицательную стороны при переходах между производным и вторым базовым классом.

 

Существует одно существенное отличие между этим примером и рассмотренным выше случаем, когда компилятор незаметно подставлял соответствующее смещение при передаче this в методы, переопределяющие содержимое по второй ветке иерархии. Эта разница состоит в том, что в метод Derived::f теперь можно прийти как из первой ветки иерархии, так и из второй. Соответственно, предыдущее решение с неявно подставляемыми смещениями в этой ситуации совершенно не подходит. Разумеется, должна существовать только одна версия машинного кода переопределенного метода Derived::f. Тем не менее, при вызове f через указатель на Base1, смещение this не требуется, а при вызове f через указатель на Base2, ожидается смещение на 4 байта в отрицательную сторону.

 

Для обеспечения незаметной фоновой коррекции указателя this при переходе к методу в производном классе через второй базовый класс, компилятор генерирует некоторый особый элемент, называемый thunk (какого-либо русскоязычного варианта этого термина не известно). Этот элемент представляет собой небольшую ассемблерную вставку, которая незаметно корректирует указатель this на нужное количество байт и переходит к целевому методу в производном классе. Помимо приведенного окна, thunk можно увидеть при пошаговой отладке в режиме дизассемблера при вызове pBase2->f(). Как при обычном вызове виртуальной функции, вместо реального метода из таблицы извлекается и используется адрес, содержащий такой несложный машинный код - команда sub корректирует указатель this, находящийся в регистре ecx на 4 байта в отрицательную сторону, а команда jmp осуществляет переход к целевому методу:

[thunk]:Derived::f`adjustor{4}':

D1CB0 sub ecx,4

013D1CB3 jmp Derived::f (13B38D4h)

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