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:
- No class per combination. Inheritance would need
FlyingWalkingBird,SwimmingWalkingBird,WalkingBird, and so on. Composition needs oneBirdand threeMovements. - 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.
- Nothing had to lie. There is no
fly()onBirdthat some birds must throw from, becauseBirdnever 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
Movementabove 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:
- Can a
Dbe passed to every function taking aB&and behave sensibly? If no, stop. - Does
Bhave data members or non-virtual implemented methods? If yes, prefer composition. - Do I want
B's interface, or justB's code? If just the code, compose — reuse is not inheritance's job. - 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
Penguinoverridingfly()to throw made a correctmigrate()abandon the flock. - The second cost is coupling: a derived class depends on its base's internal call graph.
- Measured:
add_allimplemented viaaddcounted 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
Stepclasses 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 andunique_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.