Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 8 of 9

Modules 16.1 to 16.7 taught you inheritance properly: virtual dispatch, virtual destructors, override, slicing, abstract bases, the diamond. This lesson tells you when not to use any of it.

That is not a contradiction. You learn the tool completely so that you can recognise the cases it fits — and those cases are narrower than most C++ teaching suggests.

What a base class promises

Deriving from Bird is a claim: anywhere code accepts a Bird, a Penguin will work. Not "a penguin is sort of a bird" in the biological sense. The claim is about substitutability in code, and the compiler will not check it for you.

Here is the claim failing.

#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

// ---- The inheritance design ----
struct Bird {
    virtual ~Bird() = default;
    virtual std::string name() const = 0;
    virtual void fly() const { std::cout << "  " << name() << " takes off\n"; }
};

struct Sparrow : Bird {
    std::string name() const override { return "sparrow"; }
};

struct Penguin : Bird {
    std::string name() const override { return "penguin"; }
    void fly() const override {
        throw std::logic_error("penguins cannot fly");
    }
};

// A function written against the BASE. It has read Bird's interface and
// nothing else. Bird::fly() is not declared noexcept and documents no
// failure mode, so this code is correct against the base's promise.
void migrate(const std::vector<const Bird*>& flock) {
    for (const Bird* b : flock) b->fly();
}

int main() {
    Sparrow s;
    Penguin p;

    std::cout << "flock of sparrows:\n";
    migrate({&s, &s});

    std::cout << "flock with a penguin:\n";
    try {
        migrate({&s, &p, &s});
    } catch (const std::logic_error& e) {
        std::cout << "  migrate() threw: " << e.what() << "\n";
    }
    std::cout << "  the third sparrow never flew\n";
    return 0;
}
flock of sparrows:
  sparrow takes off
  sparrow takes off
flock with a penguin:
  sparrow takes off
  migrate() threw: penguins cannot fly
  the third sparrow never flew

Read migrate() again. It was written against Bird, it uses only what Bird declares, and it is correct. Bird::fly() is not noexcept and documents no failure mode, so migrate() has no reason to wrap the call. Then a Penguin arrives and the third sparrow never flies.

Nothing here is a bug in Penguin or a bug in migrate(). The bug is in the line struct Penguin : Bird, which made a promise the class could not keep. This is the Liskov substitution principle, and it is the only real test for whether inheritance is appropriate:

If you cannot pass the derived object to every function that accepts the base and have it behave sensibly, it is not an is-a relationship, whatever the English words suggest.

Once you apply that test honestly, a lot of hierarchies fail it. Square : Rectangle fails it — a function that sets width and height independently breaks a square. Stack : Vector fails it — a function that inserts in the middle breaks the stack.

The second problem: inheritance couples you to an implementation

Substitutability is the famous objection. This one costs more in practice, and it shows up even when the is-a relationship is genuine.

#include <iostream>
#include <string>
#include <vector>

// ---- A base class written by someone else ----
class Bag {
    std::vector<std::string> items_;
public:
    virtual ~Bag() = default;
    virtual void add(const std::string& s) { items_.push_back(s); }
    virtual void add_all(const std::vector<std::string>& v) {
        // Implementation detail: add_all is written in terms of add().
        for (const std::string& s : v) add(s);
    }
    std::size_t size() const { return items_.size(); }
};

// ---- Inheriting to reuse: count everything that goes in ----
class CountingBagInherit : public Bag {
    int added_ = 0;
public:
    void add(const std::string& s) override { ++added_; Bag::add(s); }
    void add_all(const std::vector<std::string>& v) override {
        added_ += static_cast<int>(v.size());
        Bag::add_all(v);
    }
    int added() const { return added_; }
};

// ---- Composing instead: hold a Bag, forward what you choose ----
class CountingBagCompose {
    Bag bag_;
    int added_ = 0;
public:
    void add(const std::string& s) { ++added_; bag_.add(s); }
    void add_all(const std::vector<std::string>& v) {
        added_ += static_cast<int>(v.size());
        bag_.add_all(v);
    }
    std::size_t size() const { return bag_.size(); }
    int added() const { return added_; }
};

int main() {
    const std::vector<std::string> three = {"a", "b", "c"};

    CountingBagInherit ci;
    ci.add_all(three);
    std::cout << "inheritance: size " << ci.size() << ", counted " << ci.added() << "\n";

    CountingBagCompose cc;
    cc.add_all(three);
    std::cout << "composition: size " << cc.size() << ", counted " << cc.added() << "\n";
    return 0;
}
inheritance: size 3, counted 6
composition: size 3, counted 3

Three strings went in. The inheriting version counted six.

CountingBagInherit overrode both add and add_all, each incrementing once. That looks complete. It is wrong because Bag::add_all happens to be implemented by calling add() — and since add is virtual, it dispatches back into the derived override. Every string is counted twice.

Now notice what would fix it: deleting the add_all override, or deleting the add override. Which one is correct depends on a private implementation detail of Bag that its interface never stated. If the author of Bag later rewrites add_all to push directly into items_, the working version silently becomes the broken one, and Bag's author has broken you without touching your file or your interface.

This is the fragile base class problem. A derived class does not depend on its base's interface; it depends on its base's internal call graph. That is not a dependency anyone can document or maintain.

CountingBagCompose has one dependency — the public interface of Bag — and counted 3. It cannot be re-entered, because nothing in Bag knows it exists.

Composition: hold it instead of being it

The alternative is plain. Where inheritance says is-a, composition says has-a, and you forward what you choose:

class CountingBagCompose {
    Bag bag_;            // has-a
    int added_ = 0;
public:
    void add(const std::string& s) { ++added_; bag_.add(s); }
    // ...
};

You pay for it in typing: every operation you want to expose, you write a one-line forwarder. Inheritance gives you all of them for free. That free-ness is the trap — you also inherit every operation you did not want, and each one is a promise you are now on the hook for.

Behaviour as an object

The stronger form of composition does not wrap one object, it holds a behaviour — an abstract base whose job is to be swapped. The hierarchy does not disappear; it moves to a place where substitutability actually holds.

#include <iostream>
#include <memory>
#include <string>
#include <vector>

// ---- The inheritance design, and where it breaks ----
//
// class Bird { virtual void fly(); };
// class Penguin : public Bird { void fly() override { throw ...; } };
//
// A Penguin IS-A Bird but cannot fly. Overriding to throw means every caller
// holding a Bird* must now handle an exception the base never promised.
// That is the Liskov substitution principle being violated: a Penguin is not
// usable everywhere a Bird is.

// ---- The composition design ----
class Movement {
public:
    virtual ~Movement() = default;
    virtual std::string move() const = 0;
};

class Flies : public Movement {
public:
    std::string move() const override { return "flies"; }
};

class Swims : public Movement {
public:
    std::string move() const override { return "swims"; }
};

class Walks : public Movement {
public:
    std::string move() const override { return "walks"; }
};

class Bird {
    std::string name_;
    std::vector<std::unique_ptr<Movement>> movements_;

public:
    explicit Bird(std::string n) : name_(std::move(n)) {}

    void add_movement(std::unique_ptr<Movement> m) { movements_.push_back(std::move(m)); }

    std::string describe() const {
        std::string out = name_ + " ";
        for (std::size_t i = 0; i < movements_.size(); ++i) {
            out += movements_[i]->move();
            if (i + 1 < movements_.size()) out += " and ";
        }
        return out;
    }
};

int main() {
    Bird sparrow("sparrow");
    sparrow.add_movement(std::make_unique<Flies>());
    sparrow.add_movement(std::make_unique<Walks>());

    Bird penguin("penguin");
    penguin.add_movement(std::make_unique<Swims>());
    penguin.add_movement(std::make_unique<Walks>());

    Bird ostrich("ostrich");
    ostrich.add_movement(std::make_unique<Walks>());

    for (const Bird* b : {&sparrow, &penguin, &ostrich}) {
        std::cout << "  " << b->describe() << "\n";
    }

    // Capabilities compose at RUN TIME. An inheritance hierarchy would need a
    // class per combination: FlyingWalkingBird, SwimmingWalkingBird, ...
    std::cout << "\nadding a capability at run time:\n";
    ostrich.add_movement(std::make_unique<Flies>());
    std::cout << "  " << ostrich.describe() << " (if only)\n";
    return 0;
}
  sparrow flies and walks
  penguin swims and walks
  ostrich walks

adding a capability at run time:
  ostrich walks and flies (if only)

The inheritance that survived is Flies : Movement. That one passes the Liskov test easily — Flies really is usable anywhere a Movement is, because Movement promises exactly one thing and Flies delivers it. Small interfaces make honest base classes.

The three things to see:

  1. No class per combination. Inheritance would need FlyingWalkingBird, SwimmingWalkingBird, WalkingBird, and so on. Composition needs one Bird and three Movements.
  2. Capabilities are data. The ostrich gained flight at run time, with no cast, no new type, and no recompile. An inheritance design fixes capabilities at compile time.
  3. Nothing had to lie. There is no fly() on Bird that some birds must throw from, because Bird never claimed all birds fly.

This is the strategy pattern. You have already used it without the name: std::sort's comparator and std::unique_ptr's deleter are both strategy objects, passed in rather than inherited from.

When inheritance is still right

It is not never. Reach for it when:

  • You need run-time polymorphism through a pointer or reference to a shared interface. That is what virtual dispatch is for, and it is how Movement above works. Note that this is inheritance from an interface, not from an implementation.
  • The base is a pure interface — no data, no implemented behaviour, so there is no call graph to be fragile about and no diamond to fall into (lesson 16.7).
  • Substitutability genuinely holds, and you can name the functions that rely on it.

The reliable summary: inherit from interfaces, compose implementations. Nearly every problem in lessons 16.5 through 16.7 — slicing, the diamond, virtual base initialisation — is a problem you only get from inheriting data and behaviour. Inheriting a pure interface has none of them.

A checklist

Before writing class D : public B, ask in order:

  1. Can a D be passed to every function taking a B& and behave sensibly? If no, stop.
  2. Does B have data members or non-virtual implemented methods? If yes, prefer composition.
  3. Do I want B's interface, or just B's code? If just the code, compose — reuse is not inheritance's job.
  4. Would I need a class per combination of features? If yes, those features are objects.

A "no" anywhere is not a prohibition. It is a requirement to write down why you are doing it anyway.

Try it

Build a text-processing pipeline from small behaviour objects — uppercase, quote, truncate — that compose in any order. Count how many behaviour classes you had to write against how many combinations you can produce, and show that swapping two steps changes the result without adding a type.

Checkpoint

  • Inheritance is a promise of substitutability, not a statement about English words.
  • The Liskov test: can the derived object be passed to every function taking the base?
  • Measured: a Penguin overriding fly() to throw made a correct migrate() abandon the flock.
  • The second cost is coupling: a derived class depends on its base's internal call graph.
  • Measured: add_all implemented via add counted three strings as six.
  • Whether the fix is to delete one override or the other depends on a detail the base never stated.
  • That is the fragile base class problem; the composed version counted 3 and cannot re-enter.
  • Composition costs one forwarding line per operation — and exposes nothing you did not choose.
  • Strategy: hold an abstract behaviour object instead of inheriting the behaviour.
  • Measured: 3 Step classes produced 8 pipelines; type-per-combination doubles with each feature.
  • Capabilities held as data can change at run time; capabilities held as types cannot.
  • std::sort's comparator and unique_ptr's deleter are strategy objects you already use.
  • Inherit from interfaces, compose implementations.
  • Slicing, the diamond and virtual bases are all problems of inheriting data, not interfaces.

Questions about this lesson

Log in to ask a question.