Lesson 7 of 9
C++ lets a class inherit from several bases. Most languages do not, and the reason is this lesson.
Multiple inheritance itself is unremarkable. The trouble starts when two of the bases share a base of their own — the diamond.
The diamond
Device VDevice
/ \ / \
Reader Writer vs VReader VWriter (virtual)
\ / \ /
ReadWriter VReadWriter
#include <iostream>
#include <string>
// ---- Non-virtual inheritance: the diamond duplicates the base ----
struct Device {
int id;
explicit Device(int i) : id(i) { std::cout << " Device(" << i << ")\n"; }
void report() const { std::cout << " device " << id << "\n"; }
};
struct Reader : Device {
explicit Reader(int i) : Device(i) {}
};
struct Writer : Device {
explicit Writer(int i) : Device(i) {}
};
struct ReadWriter : Reader, Writer {
ReadWriter(int a, int b) : Reader(a), Writer(b) {}
};
// ---- Virtual inheritance: one shared base ----
struct VDevice {
int id;
explicit VDevice(int i) : id(i) { std::cout << " VDevice(" << i << ")\n"; }
void report() const { std::cout << " vdevice " << id << "\n"; }
};
struct VReader : virtual VDevice {
explicit VReader(int i) : VDevice(i) {}
};
struct VWriter : virtual VDevice {
explicit VWriter(int i) : VDevice(i) {}
};
// The MOST DERIVED class initialises a virtual base, not the intermediates.
struct VReadWriter : VReader, VWriter {
VReadWriter(int i) : VDevice(i), VReader(i), VWriter(i) {}
};
int main() {
std::cout << "non-virtual diamond:\n";
ReadWriter rw(1, 2);
std::cout << " sizeof(ReadWriter): " << sizeof(ReadWriter) << "\n";
// rw.id; // error: ambiguous — there are two
std::cout << " Reader::id = " << rw.Reader::id
<< ", Writer::id = " << rw.Writer::id << " <-- two copies\n";
std::cout << "\nvirtual diamond:\n";
VReadWriter vrw(7);
std::cout << " sizeof(VReadWriter): " << sizeof(VReadWriter) << "\n";
std::cout << " id = " << vrw.id << " <-- one copy, unambiguous\n";
vrw.report();
return 0;
}
non-virtual diamond:
Device(1)
Device(2)
sizeof(ReadWriter): 8
Reader::id = 1, Writer::id = 2 <-- two copies
virtual diamond:
VDevice(7)
sizeof(VReadWriter): 24
id = 7 <-- one copy, unambiguous
vdevice 7
Device was constructed twice. ReadWriter contains two complete, independent Device
subobjects — one reached through Reader, one through Writer. They have different ids and
neither is "the" device.
That makes the obvious code ambiguous:
error: request for member 'id' is ambiguous
error: 'Device' is an ambiguous base of 'ReadWriter'
You cannot write rw.id, and you cannot convert a ReadWriter* to a Device* — the compiler does
not know which of the two you mean. Qualifying with rw.Reader::id works but is an admission that
the design has two devices where it meant one.
Virtual inheritance
virtual on the inheritance makes the shared base one subobject:
// struct VReader : virtual VDevice { ... };
// struct VWriter : virtual VDevice { ... };
VDevice was constructed once, vrw.id is unambiguous, and the conversion to VDevice* works.
But look at the sizes. ReadWriter is 8 bytes and VReadWriter is 24 — the "fixed" version is
three times larger, for a class holding a single int.
Virtual inheritance is not free. Each subobject with a virtual base needs a pointer (or offset) to find the shared base at run time, because its position depends on the most derived type, which the intermediate classes cannot know. That indirection costs memory and a load on every access.
The rule that surprises people
The most-derived class initialises a virtual base, not the intermediate classes.
// struct VReadWriter : VReader, VWriter {
// VReadWriter(int i) : VDevice(i), VReader(i), VWriter(i) {}
// // ^^^^^^^^^^ VReadWriter initialises VDevice directly
// };
VReader(i) and VWriter(i) each contain : VDevice(i) in their own constructors, and those
calls are ignored when the object is a VReadWriter. Only the most-derived class's call runs.
This has to be true — there is one VDevice, so only one initialisation can happen — but it means
an intermediate class cannot guarantee how its own virtual base is initialised. If someone derives
from VReader without initialising VDevice, and VDevice has no default constructor, the build
fails in the derived class for a reason that lives two levels up.
When multiple inheritance is fine
The non-controversial use is inheriting from several pure interfaces:
// class Serializable { public: virtual ~Serializable() = default;
// virtual std::string to_json() const = 0; };
// class Drawable { public: virtual ~Drawable() = default;
// virtual void draw(Canvas&) const = 0; };
//
// class Widget : public Serializable, public Drawable { ... };
No data, no shared base, no diamond. Each interface contributes only a vtable, and Widget promises
to implement both. This is how Java and C# do it, and it is the pattern that multiple inheritance
genuinely serves.
The trouble comes from inheriting state from more than one place. A rule that works:
Inherit from at most one class with data. Any number of pure interfaces is fine.
What to do instead
Before reaching for virtual inheritance, check whether the diamond is real:
Is one relationship actually composition? ReadWriter probably does not need to be two
devices. It needs to have a device, and to offer reading and writing over it:
// class ReadWriter {
// Device device; // one device, no ambiguity, 4 bytes
// public:
// explicit ReadWriter(int id) : device(id) {}
// int read() { ... }
// void write(int) { ... }
// };
That removes the diamond, the ambiguity, the 24-byte overhead, and the virtual-base initialisation rule in one step. Lesson 16.8 makes this argument properly.
Can the shared base be an interface? A base with no data causes no duplication worth worrying about — there is nothing to duplicate. The diamond is only a problem for state.
Where you have already met it
std::iostream is the standard library's famous diamond, and it uses virtual inheritance:
std::ios_base
|
std::ios (virtual base)
/ \
std::istream std::ostream
\ /
std::iostream
A std::stringstream has one stream state — one error flag, one locale — reachable through either
the input or the output side. That is exactly the case virtual inheritance is for: a genuinely
shared piece of state in a hierarchy that must present two faces.
It is also one of very few examples, which is itself informative.
Try it
Build a diamond three ways: with plain multiple inheritance, with virtual inheritance, and with
composition. Compare sizeof, count base-class constructions, and say which ambiguities each
version has.
Checkpoint
- A class may inherit from several bases; the trouble is when two bases share a base.
- Measured: a plain diamond constructed the shared base twice and made
idambiguous. Derived*toBase*conversion also fails —'Device' is an ambiguous base.virtualinheritance makes the shared base a single subobject.- Measured cost: 8 bytes became 24 for a class holding one
int. - The most-derived class initialises a virtual base; intermediate classes' calls are ignored.
- That means an intermediate class cannot control how its own virtual base is initialised.
- Multiple inheritance from pure interfaces is fine — no data, no diamond.
- Inherit from at most one class with data.
- Before reaching for virtual inheritance, check whether the relationship is composition.
- Composition solved the same problem in 4 bytes against 24, with a clearer interface.
std::iostreamis the standard library's virtual diamond, and one of very few good examples.
Questions about this lesson
Log in to ask a question.