Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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 id ambiguous.
  • Derived* to Base* conversion also fails — 'Device' is an ambiguous base.
  • virtual inheritance 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::iostream is the standard library's virtual diamond, and one of very few good examples.

Questions about this lesson

Log in to ask a question.