Память виртуально наследованных классов



Как было сказано в предыдущей статье, «просто так эти области памяти не могут быть объединены...». Возможность совместного использования памяти для общего родительского класса есть!



С помощью ключевого слова virtual, объявляется виртуально наследованный класс:

struct Base                 {..};

struct P1 : virtual Base {..};

struct P2 : virtual Base {..};

struct Child : P2, P1 {..};



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



Стандарт языка не регламентирует реализацию виртуального наследования и виртуальных методов. Большинство компиляторов придерживаются спецификации Itanium C++ ABI (в частности, GCC и LLVM Clang). Однако, различия всё равно могут быть. Нам важно получить именно понимание, какие могут быть нюансы и как они могут быть решены.



Давайте сразу посмотрим, как это будет представлено в памяти. Рассмотрим живой пример 5:

struct Base                {..};

struct P1 : virtual Base {..};

struct P2 : virtual Base {..};

struct Child: P2, P1 {..};



Layout of Child:

0: [ Memory of struct P2 ] <- P2*, Child*

16: [ Memory of struct P1 ] <- P1*

32: [ Memory of struct Child ]

40: [ Memory of struct Base ] <- Child::Base*, P2::Base*, P1::Base*



Да, как и раньше при множественном наследовании, адресы P1 и P2 будут отличны друг от друга, но вот их родительский класс Base теперь вынесен отдельно и существует в единственном исполнении.



Мы тут же сталкиваемся с одной интересной проблемой. В рамках класса Child мы можем вычислить смещение до полей класса Base. Но сам по себе класс Child может быть далеко не единственным классом, который наследует P1 или P2. Я хочу сказать, что мы не можем просто "запомнить" это смещение и использовать его для других классов. Нам так же непонятно, как вычислить это смещение, если мы работаем абстрагировано с классом P1 или P2. Вдруг это самостоятельный объект, а может быть это родительский класс Child или Child2? Более того, сам Child может быть унаследован другими классами, что добавит новые поля и изменит итоговое смещение. Вообще говоря, эта информация может даже меняться во время выполнения программы. Вот тут и начинается веселье!



Как может решаться эта проблема? В большинстве случаев, во все классы, которые используют виртуальное наследование, неявно добавляется виртуальный указатель на виртуальную таблицу смещений. Она генерируется компилятором и хранится в read-only памяти приложения. Размер класса, естественным образом, увеличивается сразу же на размер указателя для выбранной архитектуры:

struct Base                {..};

struct P1 : virtual Base {..};



Layout of P1:

0 | (P1 vtable pointer) // + 8 байт

8 | uint64_t data_of_P1

16 | struct Base (virtual base)

16 | uint64_t data_of_Base



При обращении к полям виртуально наследованного класса, будет выполняться дополнительная операция чтения виртуального указателя vtable pointer для доступа к виртуальной таблице смещений. И вуаля, теперь мы уже знаем, где у нас лежит наша Base.



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



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



#cppcore #compiler