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:

  1. the is-a sentence is true for every caller,
  2. the base class is designed to be inherited from (documented, with the right destructor, protected members where needed),
  3. the derived class adds behaviour, not just data,
  4. 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:

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