наследование — детальный byte-level разборinheritance — a detailed byte-level breakdown

Множественное и виртуальное наследование: layout объекта в памятиMultiple & virtual inheritance: object layout in memory

Ромбовидная проблема решается не «магией» компилятора, а конкретной, предсказуемой перестройкой физической раскладки объекта в памяти: смещения полей меняются, появляются дополнительные указатели, а обращение к общему подобъекту требует лишнего чтения из памяти. Здесь — та же тема, что и на страницах про наследование и виртуальные таблицы, но с конкретными байтами и смещениями по Itanium C++ ABI (GCC/Clang).The diamond problem isn't solved by compiler "magic" — it's a concrete, predictable restructuring of the object's physical memory layout: field offsets change, extra pointers appear, and accessing the shared subobject costs one more memory read. This covers the same ground as the inheritance and virtual tables pages, but with concrete bytes and offsets per the Itanium C++ ABI (GCC/Clang).

частый вопрос на собеседованииcommon interview question Itanium C++ ABI, x86-64

Отправная точка: одиночное наследованиеStarting point: single inheritance

Прежде чем усложнять, зафиксируем базовый случай. При одиночном наследовании подобъект базового класса просто кладётся в начало объекта производного класса — их адреса совпадают.Before adding complexity, let's fix the baseline case. Under single inheritance, the base class's subobject is simply placed at the start of the derived class's object — their addresses coincide.

struct A { int a; };            // sizeof(A) == 4// sizeof(A) == 4
struct B : A { int b; };       // sizeof(B) == 8// sizeof(B) == 8
A::a B::b offset 0 offset 4

B* и вложенный в него A* указывают на один и тот же адресa B* and the A* nested inside it point at the exact same address

Именно поэтому static_cast<A*>(bPtr) при одиночном наследовании — это буквально no-op на уровне машинного кода: значение указателя не меняется, меняется только его тип для компилятора.This is exactly why static_cast<A*>(bPtr) under single inheritance is literally a no-op at the machine-code level: the pointer's value doesn't change, only its type does, for the compiler's bookkeeping.

Множественное наследование без ромба: конкатенация подобъектовMultiple inheritance without a diamond: subobject concatenation

struct Base1 { int x; };
struct Base2 { int y; };
struct Derived : Base1, Base2 { int z; };   // sizeof(Derived) == 12// sizeof(Derived) == 12
Base1::x Base2::y Derived::z offset 0 offset 4 offset 8

подобъекты баз идут подряд в порядке списка наследованияbase subobjects are laid out back to back, in inheritance-list order

Здесь уже видна первая практическая деталь: static_cast<Base2*>(derivedPtr) — это НЕ no-op. Компилятор обязан прибавить к адресу смещение Base2 (здесь — 4 байта), иначе указатель будет указывать на Base1::x, а не на Base2::y. Это смещение известно на этапе компиляции (нет виртуального наследования — нет неопределённости), поэтому подставляется прямо в код как константа.Here the first practical detail already shows up: static_cast<Base2*>(derivedPtr) is NOT a no-op. The compiler must add Base2's offset (4 bytes here) to the address, otherwise the pointer would point at Base1::x instead of Base2::y. That offset is known at compile time (no virtual inheritance means no uncertainty), so it's baked into the code as a constant.

Ромб без virtual: два независимых A рядом друг с другомThe diamond without virtual: two independent A's side by side

struct A  { int a; };            // sizeof(A)  == 4// sizeof(A)  == 4
struct B1 : A { int b1; };      // sizeof(B1) == 8// sizeof(B1) == 8
struct B2 : A { int b2; };      // sizeof(B2) == 8// sizeof(B2) == 8
struct D  : B1, B2 { int d; };   // sizeof(D)  == 20 — два A целиком!// sizeof(D)  == 20 — two whole A's!
A::a (#1) B1::b1 A::a (#2) B2::b2 D::d 0 8 16

две копии A по смещениям 0 и 8 — d.a неоднозначен, потому что физически существуют обаtwo copies of A at offsets 0 and 8 — d.a is ambiguous because both physically exist

Ошибка компиляции «d.a неоднозначен» — не искусственное ограничение языка, а прямое следствие layout: по смещению 0 лежит один int, по смещению 8 — другой, оба одинаково валидны, и компилятор физически не может «угадать», какой из двух байтовых участков памяти имелся в виду.The "d.a is ambiguous" compile error isn't an artificial language restriction — it's a direct consequence of the layout: one int sits at offset 0, another at offset 8, both equally valid, and the compiler physically cannot "guess" which of the two memory regions was meant.

Ромб с virtual: A выносится в конец, появляются vbase-указателиThe diamond with virtual: A moves to the end, vbase pointers appear

Когда B1 и B2 наследуют от A виртуально, компилятор физически перестраивает объект: единственная копия A перемещается в конец объекта D (после всех невиртуальных частей), а на её место, в начале подобъектов B1 и B2, встают скрытые указатели на таблицу смещений виртуальной базы (vbase offset, хранится в структуре, похожей на vtable, — см. страницу про виртуальные таблицы).When B1 and B2 inherit from A virtually, the compiler physically restructures the object: the single copy of A moves to the end of D's object (after every non-virtual part), and in its place, at the start of the B1 and B2 subobjects, hidden pointers to the virtual base offset table appear (stored in a vtable-like structure — see the virtual tables page).

struct A  { int a; };
struct B1 : virtual A { int b1; };
struct B2 : virtual A { int b2; };
struct D  : B1, B2 { int d; };
// на x86-64, GCC/Clang: sizeof(D) == 32 (не 20!) — но A теперь только одна копия// on x86-64, GCC/Clang: sizeof(D) == 32 (not 20!) — but there's only one A now
vbase ptr (B1) B1::b1 vbase ptr (B2) B2::b2 D::d A::a (×1!) 0 16 24

оба vbase-указателя (в B1 и в B2) в конечном счёте ведут к одному и тому же A на смещении 24both vbase pointers (in B1 and in B2) ultimately lead to the same A at offset 24

Обратите внимание, что размер объекта вырос (32 байта против 20 без virtual) — экономия на дублировании A (4 байта) съедена двумя новыми vbase-указателями (по 8 байт каждый на x86-64). Виртуальное наследование выгодно семантически (единый общий подобъект, никакой неоднозначности), но не по чистому размеру объекта — реальная выгода в том, что d.a теперь однозначен и логически корректен, а не в экономии памяти.Notice the object's size actually grew (32 bytes vs. 20 without virtual) — the savings from not duplicating A (4 bytes) are eaten up by two new vbase pointers (8 bytes each on x86-64). Virtual inheritance pays off semantically (one shared subobject, no ambiguity), not in raw object size — the real win is that d.a is now unambiguous and logically correct, not memory saved.

Доступ к d.a теперь компилируется примерно так: прочитать vbase-указатель из B1-части (или из vtable объекта — конкретный механизм зависит от компилятора), получить смещение виртуальной базы, прибавить его к адресу объекта — то самое «одно дополнительное разыменование», упомянутое на странице про наследование, здесь видно буквально как одна лишняя инструкция чтения из памяти на пути к полю a.Accessing d.a now compiles roughly to: read the vbase pointer from the B1 portion (or from the object's vtable — the exact mechanism is compiler-dependent), fetch the virtual base's offset, add it to the object's address — that "one extra dereference" mentioned on the inheritance page shows up here literally as one extra memory-read instruction on the way to field a.

Сравнение всех трёх вариантовComparing all three variants

одиночноеsingleромб без virtualdiamond, no virtualромб с virtualdiamond, virtual
копий Acopies of A121
доступ к aaccess to aпрямойdirectнеоднозначен (ошибка)ambiguous (error)косвенный, через vbase ptrindirect, via a vbase ptr
static_cast к базеto a baseno-opконстантное смещениеa constant offsetзапрещён (нужен dynamic_cast)forbidden (dynamic_cast required)
лишний указатель на объектextra pointer per objectнетnoneнетnoneпо одному на каждый виртуальный путь наследованияone per virtual inheritance path

ГраблиGotchas

Layout — деталь реализации ABI, не гарантия стандартаLayout is an ABI implementation detail, not a standard guarantee

Конкретные смещения и порядок полей в этой статье описаны для Itanium C++ ABI (GCC/Clang на большинстве платформ) — стандарт C++ не специфицирует layout объектов с виртуальным наследованием вообще, поэтому MSVC на Windows кладёт vbase-информацию иначе. Полагаться на конкретные смещения в переносимом коде нельзя — только sizeof, offsetof для standard-layout типов и явные API языка.The specific offsets and field order in this article describe the Itanium C++ ABI (GCC/Clang on most platforms) — the C++ standard doesn't specify object layout under virtual inheritance at all, so MSVC on Windows places vbase information differently. Portable code can't rely on specific offsets — only on sizeof, offsetof for standard-layout types, and the language's explicit APIs.

reinterpret_cast между связанными по иерархии типами — почти всегда ошибкаbetween hierarchy-related types is almost always a bug

Поскольку static_cast к базовому классу при множественном/виртуальном наследовании — это реальная арифметика указателя, а не смена интерпретации того же адреса, reinterpret_cast (который никогда не корректирует адрес) даст указатель на неправильное место в объекте — компилирующийся, но совершенно неверный код.Since static_cast to a base class under multiple/virtual inheritance is real pointer arithmetic, not a reinterpretation of the same address, reinterpret_cast (which never adjusts the address) yields a pointer to the wrong place inside the object — code that compiles but is completely wrong.

Размер объекта с виртуальным наследованием непредсказуем "на глаз"The size of a virtually-inherited object isn't predictable "at a glance"

Как показано выше, добавление virtual к наследованию может увеличить, а не уменьшить sizeof объекта — интуиция «virtual убирает дублирование, значит объект должен стать меньше» неверна, если новых vbase-указателей оказывается больше, чем сэкономленных байт от единственной копии базы.As shown above, adding virtual to inheritance can grow, not shrink, an object's sizeof — the intuition "virtual removes duplication, so the object must get smaller" is wrong whenever the new vbase pointers outweigh the bytes saved by having only one copy of the base.