Преобразование типов при множественном наследовании в верхнем и нижнем направлениях. Коррекция указателя 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, несмотря на сложность восприятия, является блестящим техническим приемом, решающим проблему коррекции адреса при минимальных накладных расходах по сравнению с возможными альтернативными вариантами.