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:
- Consider using type-erasure instead of Object-Oriented Programming (OOP). The diamond exists only because the design is expressed as a class hierarchy. Remove the hierarchy and the diamond disappears.
- Use type erasure over virtual functions for polymorphism. A shared interface does not require a shared base subobject; it requires a shared call signature.
- Prefer a C-style pseudo v-table singleton over the C++ v-table. Virtual inheritance adds virtual-base offsets and RTTI to the dispatch machinery. Both are ABI-specific, and neither is needed when the table is yours.
-
If
you have to use virtual methods, remember to always mark either
virtual,overrideorfinal. Virtual inheritance is not an exemption from the marker rule (Ch18.3). -
Prefer free functions over
methods.
print,scanandtransmittake the device as an argument; they do not need a class hierarchy to be polymorphic. - Avoid private fields and methods. A shared base subobject hides state behind an inheritance path. An explicit member can be inspected and tested.
-
Avoid
new/unique_ptr. The iostream-style hierarchy is the historical path that made both look necessary. The diamond is the same mistake expressed in the type system.