Ch18.9: Virtual Inheritance and the Diamond

中文(简体)

1. Virtual Inheritance: The Diamond

virtual in a base-class list is a different feature from virtual on a function. It solves a different problem: multiple inheritance can put more than one copy of a common base into one object.

struct base
{
    int id{};
    virtual ~base() = default;
};

struct left : base
{
    int l{};
};

struct right : base
{
    int r{};
};

struct bottom : left, right
{
    int b{};
};

A bottom object now contains two base subobjects: one through left, one through right. The name id is ambiguous, and so is the conversion to base&:

bottom x;
// x.id = 1;            // error: ambiguous, which base?
// base &br = x;        // error: ambiguous base

left &l = x;
right &r = x;
base &bl = l;           // the base inside left
base &br = r;           // the base inside right

The two bases also have separate state. If base holds an id, a change through bl is not visible through br. For a true "is-a" diamond — one shared base subobject — declare the inheritance from base as virtual:

struct left : virtual base
{
    int l{};
};

struct right : virtual base
{
    int r{};
};

struct bottom : left, right
{
    int b{};
};

Now bottom has exactly one base subobject, shared by the left and right branches. The name is no longer ambiguous:

bottom x;
x.id = 1;               // OK: one shared base
base &br = x;           // OK: one shared base
left &l = x;
base &bl = l;           // still refers to the same shared base

2. Layout and Construction

The cost is layout. A virtual base cannot be placed at a fixed offset in every complete object, because the path to it depends on the most-derived type. The implementation usually adds a pointer or an offset table (a virtual base pointer, or vbptr) to the class, and access to a virtual base member becomes an indirect access. The standard does not mandate the layout; sizeof, offsets and the number of hidden pointers are ABI details. Do not build a wire format on them.

Construction order is the other sharp edge. Virtual base classes are constructed before non-virtual base classes, and the most-derived class is responsible for initializing the virtual base. Initializers for that virtual base in intermediate classes are ignored when the intermediate class is not the most-derived object:

struct base
{
    base() { std::print("base\n"); }
};

struct left : virtual base
{
    left() : base{} { std::print("left\n"); }
};

struct right : virtual base
{
    right() : base{} { std::print("right\n"); }
};

struct bottom : left, right
{
    bottom() { std::print("bottom\n"); }
};

bottom x; // base, left, right, bottom

Destruction runs in the reverse order: bottom, right, left, base. If you write a constructor that assumes it controls the virtual base, test it both as a standalone object and as a base of a more-derived object. The same constructor can see different base initialization in the two cases.

3. Cross-Casts and the Alternative

Cross-casting between sibling branches needs RTTI. A static_cast from left* to right* is not allowed, because the compiler cannot know the offset without the complete object. dynamic_cast can find the shared base or the sibling subobject, but it requires a polymorphic type and a runtime search:

bottom x;
left *lp = &x;
base *bp = dynamic_cast<base *>(lp); // shared virtual base
right *rp = dynamic_cast<right *>(lp); // sibling subobject, or nullptr

This is exactly the kind of code the rest of the chapter argues against: a runtime cast to recover a relationship that the type system could have expressed with composition. If you need a shared interface, prefer a pure interface with no data, or composition plus a reference. If you need shared state, make the sharing explicit with a pointer or a reference member, not with a virtual base.

Virtual inheritance is not a general-purpose code-reuse tool. It is for the narrow case where a diamond is genuinely part of the domain model and one shared base subobject is the correct semantic. It is expensive in layout, construction order and casts, and it is easy to get wrong. In new code, composition is almost always clearer.

This is the guideline's Use type erasure over virtual functions for polymorphism taken to its conclusion: if the concrete type never escapes, there is nothing to cast, and the cost of RTTI disappears with the cast.

4. Why the Feature Exists

Virtual base classes were one of the key C++ 2.0 innovations, and the iostream redesign of that era was their first large-scale use. That is why the feature is usually associated with iostreams. When Jerry Schwarz redesigned the stream library around multiple inheritance, the goal was one shared basic_ios holding the state and the buffer, with basic_istream adding reads, basic_ostream adding writes, and basic_iostream offering both. Without virtual inheritance, basic_iostream would contain two basic_ios subobjects: two sets of flags, two buffers, two error states. Virtual inheritance collapses the diamond to one base.

That is a real modelling problem, but solving it inside one library added one of the most complicated features of the object model to the language. Every C++ program pays for it: virtual base offsets, most-derived construction, ignored initializers, cross-casts and RTTI. Most programs never need a diamond. iostreams needed one because a deep class hierarchy was the only tool the library had at the time.

fast_io takes the other road. The device is a template parameter, not a base class. print, scan and transmit are functions on a device reference, and a read-write type holds one reference to one device. There is no shared base to duplicate, so there is no diamond, no virtual base and no RTTI. Sharing is a member that is visible in the type, not a hidden adjustment in the object layout.

That is the guideline's Prefer free functions over methods and Consider using type-erasure instead of OOP applied to I/O: the operation is a function on a device, not a method on a hierarchy.

The security cost follows from the same complexity. Type confusion, corrupted vtable or virtual-base pointers, and dynamic_cast on damaged objects are standard exploitation techniques. A layout with hidden offsets and runtime pointer adjustment gives an attacker more state to corrupt and more places where a wrong offset turns into the wrong object. That is not the main reason to avoid virtual inheritance, but it is another reason the simple layout wins.

The lesson is not that the iostream designers were careless. They were solving a genuine duplication problem with the tools of the time, when runtime polymorphism and deep hierarchies were treated as the default shape of good design. The lesson is that a library-internal modelling problem should not force a language-wide mechanism, and that templates plus composition can express the same sharing with far less machinery.

5. Guideline References

The rules this page applies, from Portable-Cpp-Guideline: