Ch16.8: Inheritance or Composition?
1. The Two Ways to Reuse
A car has an engine. Writing that as inheritance produces the sentence “a car is
an engine”, which is false, and the falseness has consequences: every
engine& parameter accepts a car, a car cannot have two engines, and
changing engine silently changes car's public interface.
struct engine
{
::std::int_least32_t power{};
};
struct car_inherits : engine // "a car is an engine"
{
::std::string_view model{};
};
struct car_has // "a car has an engine"
{
engine eng{};
::std::string_view model{};
};
Both are the same size on this machine (24 bytes: a
::std::int_least32_t plus a ::std::string_view, rounded to
8), and both hold the same data. What differs is the type relationship,
and that is where all the value is.
2. What Composition Buys You
| Inheritance | Composition | |
|---|---|---|
| Correctness of the is-a claim | must be literally true | not claimed at all |
| Can hold two engines | no | yes, add another member |
| Changing the part's interface | changes the whole's interface, maybe its ABI | contained; only the wrapper's own members matter |
| Access control | inherited members are reachable by name | only through the member name, so the wrapper can guard them |
| Swap the implementation at run time | no (a base subobject cannot be replaced) | yes, if the member is a variant or an erased type |
| sizeof | same as the part plus new members | same, unless the part is empty (§4) |
The last-but-one row is the one to remember: a member is a boundary. Every derived class is inside the base class's invariant forever; every holder of a member can decide what to publish.
3. When Inheritance Is the Right Answer
Inheritance earns its place when all four of these hold:
- the is-a sentence is true for every caller,
- the base class is designed to be inherited from (documented, with the right
destructor,
protectedmembers where needed), - the derived class adds behaviour, not just data,
- the set of derived types is either closed and known, or the base is genuinely an interface.
The chapter's own examples pass: a labeled_point is a point, a
machine is a machine_part. A car fails the first test, and no
amount of implementation sharing makes it pass.
4. The Empty Base: Where Inheritance Actually Wins on Size
There is one case where inheritance beats a member on sizeof. An empty
base class contributes no bytes, while an empty member still has to be addressable
— it needs at least one byte, plus padding.
struct empty
{
};
struct with_member
{
empty e{};
::std::int_least32_t x{};
};
struct with_member_no_unique
{
[[no_unique_address]] empty e{};
::std::int_least32_t x{};
};
struct derived : empty
{
::std::int_least32_t x{};
};
| Type | sizeof |
Why |
|---|---|---|
empty |
1 |
every object needs a unique address |
derived |
4 |
empty base optimisation: the base takes no space |
with_member |
8 |
a member must have its own address, so 1 byte + 3 padding |
with_member_no_unique |
4 |
[[no_unique_address]] (C++20) gives the member the same
permission |
Since C++20 the size argument is gone: [[no_unique_address]] makes an
empty member free, so composition no longer costs bytes. Note what it does not fix: the
type relationship, the two-engines problem, or the interface containment. Size was
never the interesting part.
5. The Third Option: No Relationship at All
When the reason for the hierarchy is “these things must all be handled uniformly”, neither inheritance nor a member is the modern answer:
- A closed set of types:
::std::variantplus a visitor. No base class, no pointer, no allocation, and the compiler checks every case. - An open set: the type erasure of Ch15.10 — one non-template class, a table of function pointers, value semantics, and no inheritance at all.
- A compile-time requirement: a concept and a template (Ch15.3), which costs nothing at run time but commits the caller to one type.
Inheritance is the right tool for the fourth case in that list and the wrong tool for the first three. fast_io's own containers follow this: they share no base class, they are templates, and where a common type is genuinely needed the library erases it rather than hoisting a base class.
6. The Whole Program
#include <fast_io.h>
#include <cstdint>
#include <cstddef>
#include <string_view>
struct engine
{
::std::int_least32_t power{};
};
struct car_inherits : engine
{
::std::string_view model{};
};
struct car_has
{
engine eng{};
::std::string_view model{};
};
struct empty
{
};
struct with_member
{
empty e{};
::std::int_least32_t x{};
};
struct with_member_no_unique
{
[[no_unique_address]] empty e{};
::std::int_least32_t x{};
};
struct derived : empty
{
::std::int_least32_t x{};
};
int main()
{
try
{
car_inherits a{};
car_has b{};
a.power = 120;
b.eng.power = 120;
::fast_io::io::println("inherits: ", a.power, " sizeof=", sizeof(car_inherits));
::fast_io::io::println("has: ", b.eng.power, " sizeof=", sizeof(car_has));
::fast_io::io::println("sizeof(empty)=", sizeof(empty), " sizeof(derived)=", sizeof(derived));
::fast_io::io::println("sizeof(with_member)=", sizeof(with_member),
" sizeof(with_member_no_unique)=", sizeof(with_member_no_unique));
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
Output:
inherits: 120 sizeof=24
has: 120 sizeof=24
sizeof(empty)=1 sizeof(derived)=4
sizeof(with_member)=8 sizeof(with_member_no_unique)=4
7. Rules of Thumb
- Default to a member. Inheritance has to be argued for, and the argument is the is-a sentence.
- “Implemented in terms of” is composition, not private inheritance.
- Do not inherit to add a couple of helpers; write free functions or a template.
- Do not inherit from types you do not own, unless they document themselves as base classes.
- Before adding a base class, check whether a concept
(Ch15.3), a
::std::variantor the erased Ch15.10 shape says the same thing without a type relationship. - Use
[[no_unique_address]]rather than inheritance when the only reason for the base class was saving a byte.