базовые концепции ООП — наследованиеbaseline OOP concepts — inheritance

НаследованиеInheritance

В C++ наследование — это не просто «переиспользование кода»: модификатор доступа при наследовании меняет видимость всех унаследованных членов, а множественное наследование от общего предка по разным путям порождает дублирование подобъектов — ромбовидную проблему, которую решает virtual inheritance ценой дополнительной косвенности.In C++, inheritance isn't just "code reuse": the access specifier used at inheritance time changes the visibility of every inherited member, and multiple inheritance from a shared ancestor via different paths creates duplicated subobjects — the diamond problem, solved by virtual inheritance at the cost of an extra layer of indirection.

частый вопрос на собеседованииcommon interview question ромбовидное наследованиеthe diamond problem

ОпределениеDefinition

Наследование — механизм, позволяющий одному классу (производному, derived) получить поля и методы другого класса (базового, base), не переписывая их заново, и при этом переопределить часть поведения. Отношение между классами обычно описывают как «is-a»: Circle — это разновидность Shape, поэтому везде, где ожидается Shape, можно подставить Circle (принцип подстановки Лисков, см. SOLID).Inheritance is the mechanism that lets one class (the derived class) acquire another class's (the base class's) fields and methods without rewriting them, while overriding part of the behavior. The relationship between the two is usually described as "is-a": a Circle is a kind of Shape, so anywhere a Shape is expected, a Circle can be substituted (the Liskov Substitution Principle, see SOLID).

struct Shape {                       // базовый класс// the base class
    double x = 0, y = 0;         // унаследуют все потомки// every descendant inherits these
    void moveTo(double nx, double ny) { x = nx; y = ny; }
};

struct Circle : Shape {          // производный класс: Circle "is-a" Shape// the derived class: Circle "is-a" Shape
    double r = 1;                  // добавляет собственное поле// adds its own field
};

Circle c;
c.moveTo(3, 4);   // метод базы вызывается на объекте потомка без переписывания// the base's method is called on the descendant's object with no rewriting

Здесь Circle получает поля x/y и метод moveTo «бесплатно», просто указав Shape в списке наследования, и добавляет к ним собственное поле r. Это самый базовый смысл наследования — переиспользование интерфейса и реализации; ромбовидная проблема и virtual ниже возникают только тогда, когда в игру входит наследование от нескольких классов сразу.Here Circle gets the x/y fields and the moveTo method "for free" just by naming Shape in its inheritance list, and adds its own r field on top. That's the most basic sense of inheritance — reusing an interface and its implementation; the diamond problem and virtual below only come into play once inheriting from multiple classes at once enters the picture.

public / protected / private наследованиеpublic / protected / private inheritance

Модификатор доступа при наследовании определяет, насколько «открытым» останется интерфейс базового класса в производном. Это отдельная ось от модификаторов доступа самих членов — они комбинируются, беря более строгий из двух.The access specifier used at inheritance time controls how "open" the base class's interface stays in the derived class. It's a separate axis from the access modifiers on the members themselves — the two combine by taking the stricter of the pair.

наследованиеinheritancepublic член базы →base member →protected член базы →base member →типичное применениеtypical use
publicpublicprotected«is-a» — производный действительно является разновидностью базового"is-a" — the derived type really is a kind of the base
protectedprotectedprotectedредко; «is-a» только для наследников дальше по иерархииrare; "is-a" only for further descendants down the hierarchy
privateprivateprivate«implemented-in-terms-of» — реализовано через, но не является"implemented-in-terms-of" — built using, but not actually a

private-наследование на практике почти всегда лучше заменить композицией (хранить объект базового класса как поле, а не наследоваться от него) — оно даёт тот же эффект «реализовано через» без участия в иерархии типов и без открытия protected-доступа к внутренностям базы.Private inheritance is almost always better replaced with composition (holding a base-class object as a field instead of inheriting from it) — it gives the same "implemented-in-terms-of" effect without joining the type hierarchy and without exposing protected access to the base's internals.

Ромбовидная проблемаThe diamond problem

Когда класс D наследует от B1 и B2, а оба они наследуют от общего A, при обычном (невиртуальном) наследовании в D оказываются две независимые копии подобъекта A — по одной на каждый путь наследования.When class D inherits from both B1 and B2, and both of those inherit from a common A, ordinary (non-virtual) inheritance leaves D with two independent copies of the A subobject — one per inheritance path.

A B1 B2 D D содержит A::B1 и A::B2 — два разных A

без virtual: два независимых подобъекта A внутри Dwithout virtual: two independent A subobjects inside D

struct A { int x = 1; };
struct B1 : A {};
struct B2 : A {};
struct D : B1, B2 {};

D d;
// d.x; — ошибка компиляции: неоднозначно, какой из двух A::x имеется в виду// d.x; — compile error: ambiguous which of the two A::x is meant
d.B1::x = 1;  // нужно явно указать путь// the path must be spelled out explicitly
d.B2::x = 2;  // это другой, независимый x// this is a different, independent x

Проблема не только в неудобстве обращения через B1::x/B2::x — это ещё и лишняя память (подобъект A хранится дважды) и логическая ошибка: концептуально должен быть один общий A, а не два независимых.The problem isn't just the awkwardness of qualifying access with B1::x/B2::x — it also wastes memory (the A subobject is stored twice) and is a logical bug: conceptually there should be one shared A, not two independent ones.

Решение: virtual inheritanceThe fix: virtual inheritance

Если B1 и B2 наследуют от A виртуально, компилятор гарантирует единственный общий подобъект A внутри D, независимо от того, сколькими путями к нему можно добраться.If B1 and B2 inherit from A virtually, the compiler guarantees a single shared A subobject inside D, no matter how many paths lead to it.

struct A { int x = 1; };
struct B1 : virtual A {};
struct B2 : virtual A {};
struct D : B1, B2 {};

D d;
d.x = 5;  // теперь однозначно — один общий A на весь D// unambiguous now — one shared A for all of D

Цена — дополнительная косвенность. Смещение виртуального базового подобъекта относительно начала D не может быть известно на этапе компиляции B1/B2 по отдельности (оно зависит от того, кто именно наследуется от них дальше), поэтому оно хранится в специальной таблице (vbase offset table / vtable-подобной структуре) и подставляется в рантайме. Каждый объект с виртуальным базовым классом несёт дополнительный указатель на эту таблицу, а доступ к полям виртуальной базы требует одного дополнительного разыменования по сравнению с обычным наследованием.The cost is an extra layer of indirection. The offset of the virtual base subobject relative to the start of D can't be known when compiling B1/B2 in isolation (it depends on whoever eventually inherits from them further down), so it's stored in a dedicated table (a vbase offset table / vtable-like structure) and resolved at runtime. Every object with a virtual base carries an extra pointer to that table, and accessing a virtual base's fields costs one more dereference than ordinary inheritance.

Конструктор виртуальной базы вызывается один раз — но не тем классом, который «ближе» к ней в списке наследования, а самым производным классом (D), даже если B1/B2 тоже пытаются его инициализировать в своих списках инициализации. Это ещё один источник путаницы: инициализатор A в списке B1 игнорируется, если D не является самым производным классом в конкретной иерархии.The virtual base's constructor runs exactly once — not by whichever class is "closer" to it in the inheritance list, but by the most-derived class (D), even if B1/B2 also try to initialize it in their own init lists. That's another common source of confusion: B1's initializer for A is silently ignored whenever D isn't the most-derived class in a given hierarchy.

Byte-level разбор того, как именно перестраивается объект в памяти — с конкретными смещениями до и после virtual — вынесен на отдельную страницу: множественное и виртуальное наследование: layout объекта в памяти.A byte-level breakdown of exactly how the object gets restructured in memory — with concrete offsets before and after virtual — lives on its own page: multiple & virtual inheritance: object layout in memory.

ГраблиGotchas

virtual inheritance не бесплатен даже без ромбаvirtual inheritance isn't free even without an actual diamond

Если пометить наследование виртуальным «на всякий случай», объект получает дополнительный указатель и косвенность доступа к полям базы, даже если в конкретной иерархии никакого ромба нет. Виртуальное наследование стоит объявлять только тогда, когда ромб реально существует или предвидится.Marking inheritance virtual "just in case" gives every object an extra pointer and an extra indirection for accessing the base's fields, even when no actual diamond exists in that hierarchy. Virtual inheritance should only be declared when a diamond genuinely exists or is anticipated.

Порядок инициализации виртуальных баз — по порядку объявления в самом производном классеVirtual base initialization order follows declaration order in the most-derived class

Все виртуальные базы во всей иерархии инициализируются первыми, до невиртуальных, и в порядке, определённом объявлениями в самом производном классе — а не в порядке, в котором они перечислены в списках инициализации промежуточных классов.Every virtual base across the whole hierarchy is initialized first, before any non-virtual base, in an order determined by the declarations in the most-derived class — not the order they're listed in intermediate classes' init lists.

static_cast между несвязанными ветвями иерархии с виртуальным наследованием запрещёнstatic_cast across unrelated branches of a virtually-inherited hierarchy is forbidden

Поскольку смещение виртуальной базы известно только в рантайме, привести указатель на виртуальную базу к производному типу через static_cast нельзя (компилятор не может вычислить смещение статически) — нужен dynamic_cast, что требует RTTI и полиморфного (с vtable) типа.Since the virtual base's offset is only known at runtime, casting a pointer to a virtual base down to a derived type via static_cast is illegal (the compiler can't compute the offset statically) — a dynamic_cast is required instead, which needs RTTI and a polymorphic (vtable-bearing) type.