C++ / INHERITANCE AND POLYMORPHISM
Virtual base classes and when they pay off
Recognize a diamond, make the shared base virtual, initialize it from the most-derived class, and judge when that trade pays off.
What you will learn
- Predict whether a diamond duplicates or shares its base subobject
- Put virtual on the intermediate classes' base-specifiers, not on the leaf class
- Initialize a virtual base from the most-derived class; intermediates are skipped
- Use dynamic_cast, not static_cast, to get from a virtual base down to derived
Understanding Virtual base classes and when they pay off
A diamond appears when two bases of a class each derive from the same grandparent. By default every path gets its own copy of that grandparent, so a ReadWriter that inherits Reader and Writer holds two Device subobjects: rw.id is ambiguous, converting ReadWriter* to Device* is ambiguous, and a value written through the Reader path is invisible through the Writer path. Writing struct Reader : virtual Device changes the object layout rather than the syntax: every class that derives virtually from Device shares one Device subobject inside the complete object. The price is that the distance from a Reader subobject to that shared Device is not a compile-time constant, because it depends on which complete object the Reader is embedded in, so the compiler reaches the base through a stored offset, typically found via the vtable.
Sharing one subobject means only one constructor call may initialize it, and the language gives that job to the most-derived class. In the code below ReadWriter names Device in its own mem-initializer list, which is legal even though Device is not a direct base, while the Device(0) initializers written inside Reader and Writer are ignored; they must still be written, because Reader has to compile as if it could itself be the most-derived class. Virtual bases are constructed before all non-virtual bases and destroyed last. Two practical taxes follow: every leaf class must know how to initialize the shared base, which leaks the base's constructor requirements upward, and static_cast from a virtual base down to a derived type is ill-formed because the offset is not known statically, so you need dynamic_cast.
Virtual inheritance pays off when the shared base carries state or identity that must exist exactly once per object and both branches are genuinely meant to be combined. The standard streams are the canonical case: basic_ios holds the stream state, the format flags and the streambuf pointer, and both basic_istream and basic_ostream derive from it virtually, so basic_iostream ends up with one buffer and one failbit instead of two of each. It also pays when a stateless abstract base is used as a single interface pointer, since duplicated empty bases make the conversion from Derived* to Interface* ambiguous and split overrides across two unrelated subobjects. Anywhere else, a base inherited along one path only, or state that could simply be a member, flattening the hierarchy or holding the shared object by reference is cheaper and far easier to reason about.
Marking a base virtual replaces the per-path copies with one shared subobject, which moves both that base's initialization and its address computation to the most-derived class.
<iostream>
struct Device {
int id;
explicit Device(int i) : id(i) { std::cout << "Device(" << i << ")\n"; }
};
struct Reader : virtual Device {
Reader() : Device(0) { std::cout << "Reader\n"; }
};
struct Writer : virtual Device {
Writer() : Device(0) { std::cout << "Writer\n"; }
};
struct ReadWriter : Reader, Writer {
explicit ReadWriter(int i) : Device(i) { std::cout << "ReadWriter\n"; }
};
int main() {
ReadWriter rw(7); // one Device, initialized by ReadWriter
std::cout << "rw.id = " << rw.id << "\n";
Reader r; // here Reader IS the most-derived class
std::cout << "r.id = " << r.id << "\n";
}
Marking a base virtual gives the whole object exactly one copy of that base, and in exchange its constructor is chosen by the most-derived class and its location is found at run time.
Worked examples
Shared state versus duplicated state
The same class shapes with and without virtual inheritance, showing when a write through one branch is visible from the other.
<iostream>
struct StreamState { bool bad = false; };
struct In : virtual StreamState { void fail() { bad = true; } };
struct Out : virtual StreamState { bool ok() const { return !bad; } };
struct InOut : In, Out {};
struct State2 { bool bad = false; };
struct In2 : State2 { void fail() { bad = true; } };
struct Out2 : State2 { bool ok() const { return !bad; } };
struct InOut2 : In2, Out2 {};
int main() {
InOut a;
a.fail();
std::cout << std::boolalpha << a.ok() << "\n"; // one shared flag
InOut2 b;
b.fail();
std::cout << b.ok() << "\n"; // Out2 has its own flag
}
Example explained
Line 1In::fail writes StreamState::bad, and because StreamState is a virtual base there is exactly one such bool inside a.
Line 2Out::ok reads that same bool, so a.ok() reports false after the failure.
Line 3In2 and Out2 each carry a private copy of State2, so b.fail() sets a flag that Out2::ok never looks at and b.ok() stays true.
Line 4a.bad compiles, while b.bad does not: it is ambiguous between the two State2 subobjects and would need b.In2::bad.
One subobject, and the casts that follow
Both branches see the same virtual base object, and getting from that base back down to the derived type requires dynamic_cast.
<iostream>
struct Base {
virtual ~Base() = default;
int tag = 1;
};
struct L : virtual Base {};
struct R : virtual Base {};
struct D : L, R {};
int main() {
D d;
L* pl = &d;
R* pr = &d;
Base* b1 = pl;
Base* b2 = pr;
std::cout << std::boolalpha << (b1 == b2) << "\n";
pl->tag = 42;
std::cout << pr->tag << "\n";
// D* bad = static_cast<D*>(b1); // ill-formed: cast from a virtual base
D* pd = dynamic_cast<D*>(b1);
std::cout << (pd == &d) << "\n";
}
Example explained
Line 1b1 == b2 is true because the L branch and the R branch converge on a single Base subobject; without virtual, Base* b1 = pl would still compile but Base* pb = &d would be ambiguous.
Line 2pl->tag = 42 followed by pr->tag reading 42 proves the two branches address the same int, not two copies.
Line 3The commented static_cast is rejected: the offset from Base back to D depends on the complete object, so it cannot be folded into a compile-time adjustment.
Line 4dynamic_cast succeeds because it consults the run-time type information of the complete object, which is why Base needs at least one virtual function here.
Important notes
virtual in a base-specifier and virtual on a member function are unrelated features: the first changes layout and name lookup, the second changes dispatch.
A base can be inherited virtually on one path and non-virtually on another; you then get the shared subobject plus an extra copy, which is nearly always an accident rather than a design.
Common mistakes
Putting virtual on the wrong edge, as in struct ReadWriter : virtual Reader, virtual Writer: it compiles, adds indirection, and leaves Device duplicated, so rw.id is still ambiguous.
Assuming Reader's Device(0) initializer runs when Reader is a base of a ReadWriter; it is skipped, so the shared base holds whatever the most-derived class chose and the invariants Reader thought it established are gone.
Reacting to the now-illegal static_cast<ReadWriter*>(devicePtr) with reinterpret_cast: the error disappears but the pointer is wrong, and using it is undefined behaviour.
Try it yourself
Change, predict, then run
Copy the Reader/Writer/ReadWriter program, delete virtual from both base-specifiers, and fix the compile errors that appear. Then report which values rw.Reader::id and rw.Writer::id hold and why the 7 no longer reaches either of them.
Open the C++ workspaceCheck your understanding
Reader and Writer both derive virtually from Device, whose only constructor takes an int. ReadWriter derives from Reader and Writer and lists Device(7); Reader lists Device(0). What happens when a ReadWriter is constructed?
- Device is constructed once with 7, before Reader and Writer run, and the Device(0) initializers are ignored
- Reader constructs Device with 0 and ReadWriter then re-initializes it with 7
- Device is constructed twice, once with 0 through Reader and once with 7 through ReadWriter
- The program is ill-formed, because ReadWriter may not name Device, which is not a direct base
Show answer
For a virtual base, only the most-derived class's mem-initializer takes effect, and virtual bases are constructed before non-virtual ones, so Device(7) runs exactly once and first. Option 2 is tempting because Reader is still required to write Device(0) when Device has no default constructor, but a required initializer is not an executed one, and nothing re-initializes or duplicates the shared subobject. Naming a virtual base in a mem-initializer list is explicitly permitted, so option 4 is wrong too.