Embercode
✕
Object-Oriented ProgrammingLesson 7 of 17

40 min read

Composition over inheritance

Why this matters

When you start with object-oriented programming, inheritance looks like the main tool. You write a base class, then derived classes that reuse it. The code feels tidy at first. After a few months, the tree of classes often becomes hard to change. A small change to the base class breaks something three levels down.

Composition is the other tool. Composition means building a bigger object out of smaller objects that it holds inside. A car holds an engine. A car holds four wheels. The car does not need to be a kind of engine to use one. This lesson shows how to build a car from parts, how to tell when a design should use composition, and when inheritance is still the better choice. Earlier lessons in this module covered classes and inheritance itself. Here the question is which of the two to reach for.

Two words: has-a and is-a

Programmers use two short phrases to decide between the two tools.

An is-a relationship means one thing is a kind of another. A dog is an animal. A square is a shape. Inheritance models is-a. A derived class inherits from its base class, and it can be used anywhere the base class is expected.

A has-a relationship means one thing contains another. A car has an engine. A computer has a keyboard. Composition models has-a. The outer object stores the inner object as a member, and it uses the inner object to do its work.

Here is a quick check. Read the sentence out loud. "A car is an engine" sounds wrong. "A car has an engine" sounds right. A car is not a kind of engine, so inheriting from Engine would be a mistake, even though a car does need one.

The rest of this lesson turns that check into code.

Idea: build the car from parts

Start with the simplest version of the problem. A car needs an engine and four wheels. Each part should be its own class, with its own data and its own behaviour. The car is then a class that holds one engine and four wheels.

Here is the full program. It is the reference for the rest of the lesson.

C++ · editable

Expected output:

Hatch drives
engine (110 hp) starts
wheel 1 turns
wheel 2 turns
wheel 3 turns
wheel 4 turns

Read the code from the bottom up, because that is how it runs. main creates one Car. It calls drive. The car prints its own name, asks its engine to start, and asks each wheel to turn.

Three things in the Car class are worth a close look.

The first is the member list. Car has three data members: name_, engine_ and wheels_. Each one is a whole object stored inside the car. The Engine is stored by value, which means the car holds the engine itself, not a pointer to an engine somewhere else. When a Car is destroyed, its engine and wheels are destroyed with it. You do not need to free them by hand.

The second is the constructor's member initializer list, the part between the colon and the opening brace. Each member is built there, before the body of the constructor runs. engine_{horsepower} builds the engine from the number the caller passed in. wheels_{Wheel{1}, Wheel{2}, Wheel{3}, Wheel{4}} builds the four wheels, with positions 1 to 4. The body of the constructor is empty, because nothing else needs to happen.

The third is that the car's public interface is drive. A user of Car never needs to know that an engine exists. The car decides how to use its parts. If the engine later becomes electric, the Car code that calls engine_.start() may need a small change, but the people who call car.drive() do not change at all.

Why this is better than a subclass

Imagine the same design with inheritance. You would write class Car : public Engine. Then a car would have the engine's methods, such as start, as part of its own public interface. Anyone holding a Car could call engine methods on it directly, which is not what a car should expose. The car would also be locked to that one engine type, because the engine is its base class.

Composition avoids those problems. The car holds the engine privately, so only the car's own methods can reach it. The car chooses what to show. The car could also hold a different engine class in a later version, as long as the code that uses it still works.

Example two: a part that may be missing

Not every car has a radio. Some do, some do not. A part that may or may not be present is a common case in composition. The standard tool for this in C++ is std::optional, a type from the standard library that holds either a value or nothing.

A std::optional<Radio> either holds a Radio or is empty. You ask whether it holds a value with if (radio_), and you read the value with radio_->play(). The arrow -> works like a dot, but it reaches inside the optional to the object it holds. optional is a class template, which means it can wrap any type. The angle brackets name the type it wraps.

C++ · editable

Expected output:

Hatch has no radio
playing FM 101.4

The first car never gets a radio, so radio_ stays empty and the else branch runs. The second car gets one through install_radio. The std::move in that function moves the radio into the member instead of copying it. You will see std::move in more detail later in the course. For now, read it as "hand this object over to the member".

This example shows a point that matters for design. The Car class did not need a second class for "car with a radio" and another for "car without a radio". One class holds the optional part, and each car decides whether it has the part. With inheritance, the same idea would need a family of classes, and the number of classes would grow with every combination of options.

Example three: a stack that should not be a vector

Composition is also the answer to a common beginner mistake. Suppose you want a stack, which is a collection where you add and remove items from one end only. A stack is not a vector. A vector lets you insert and remove items at any position. It is tempting to reuse the vector's code by inheriting from it.

Here is the tempting version.

C++ · editable

Expected output:

pop: 3
bottom: 99
size: 3

The program compiles and runs, but the result is wrong for a stack. The item 99 was placed at the bottom of the stack, in the middle of the order the caller built. A stack's rule is that items come off in the reverse order they went on, and the caller just broke that rule with an operation the stack class was supposed to hide. The Stack class has no protection against this, because it gave away everything std::vector can do.

The composed version fixes this. The stack holds a vector privately, and it offers only the operations a stack should have.

C++ · editable

Expected output:

pop: 3
size: 2
pop: 2
pop: 1

Now the code that tries the bad operation fails at compile time, which is the best time to find it. Add s.insert(s.begin(), 99); after the three push calls in the composed main. The compiler then reports:

ex3_insert.cpp: In function ‘int main()’:
ex3_insert.cpp:31:7: error: ‘class Stack’ has no member named ‘insert’
   31 |     s.insert(s.begin(), 99);
      |       ^~~~~~

The error comes from a file that declares a Stack with only push, pop, empty and size. Because the stack holds its vector privately, the caller has no route to the vector's insert.

The composed version is also easier to change. If you later want the stack to store a std::deque or a linked list, only the items_ member and its uses change. The callers keep calling push and pop.

When inheritance is still the right tool

Composition is the default, but it is not the only good tool. Inheritance is right when a derived class is truly a kind of the base class, and when code that works with the base class should work with the derived class without knowing which one it has.

The standard library has a clear example. The standard exception types, such as std::runtime_error and std::out_of_range, all derive from std::exception. A function can be written once against the base type.

C++ · editable

Expected output:

error: disk is full
error: basic_string::at: __n (which is 10) >= this->size() (which is 3)

The second message comes from the standard library in g++ 15.2.0. Other versions can print the text differently. The point is that report does not know or care which exception type it received. A new exception type added next year will work with report too, as long as it derives from std::exception.

That is the test for inheritance. Ask two questions.

The first question is whether the is-a sentence is true in every case. A std::out_of_range is an exception, every time. A Square is a shape, every time.

The second question is whether code written against the base class should work for every derived class without special cases. If report needed to check for each exception type, the design would be wrong. If a function needs to ask "is this a Circle or a Rectangle?", the inheritance tree is probably the wrong shape.

The lesson on polymorphism in this module covers how a base class calls derived behaviour through virtual functions. Use inheritance when that behaviour is part of the contract of the base class.

The square and the rectangle

A classic example shows where inheritance goes wrong even when the is-a sentence sounds true. In mathematics a square is a rectangle. It is tempting to write class Square : public Rectangle.

Suppose Rectangle has set_width and set_height, which change one side each. A caller who holds a Rectangle& expects that setting the width does not change the height. A Square must keep both sides equal, so a Square cannot honour that expectation. Code that works correctly with any rectangle breaks when it receives a square.

The safer design is to keep Square and Rectangle apart, or to make them share a base type that only offers what both can do, such as an area function. The rule to remember is that a derived class must keep every promise the base class makes. If it cannot, the inheritance is wrong, even when the words say "is a".

Choosing for a new class

When you design a new class, start with composition. Write down the parts the class needs to hold. Then write down the operations a user should be able to call. If the parts are things the class contains, the design is composition.

Inheritance needs a stronger reason. Write the sentence "X is a kind of Y" and check it against two cases that a real program would have. If both cases fit, and code written for Y must work for X without special cases, then inheritance is a candidate. If you find a case where the derived class would have to refuse a promise of the base class, go back to composition.

Many classes in a typical program are built from other classes. Inheritance appears mostly where a small set of types shares one contract, such as the exception types or the shape types in a drawing program.

Common mistakes

Inheriting to reuse code

The most common mistake is to inherit from a class only to use its methods. The stack example above is one case. A Logger class might inherit from std::vector<std::string> so it can call push_back directly. Then every user of the logger can also erase, sort and clear its contents. The fix is the same as before: hold the vector as a private member and write only the methods the logger needs.

Missing default constructor for a member array

When a class holds an array of objects, every object has to be built. In the car example, std::array<Wheel, 4> needs four Wheel values. If you list only three in the initializer list, the compiler has to build the fourth one with no value at all. Wheel has no default constructor, because it requires a position. The compiler stops with this error:

car.cpp: In constructor ‘Car::Car(std::string, int)’:
car.cpp:22:11: error: could not convert ‘<brace-enclosed initializer list>()’ from ‘<brace-enclosed initializer list>’ to ‘Wheel’
   22 |           wheels_{Wheel{1}, Wheel{2}, Wheel{3}} {}
      |           ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The message is confusing because it names the brace list and not the missing fourth wheel. When you see "could not convert" inside a constructor's initializer list, count the items you passed and compare them with the size of the array.

Reaching into another class's private data

Composition makes the parts private. Code outside the Engine class cannot read its horsepower directly. Trying to do so produces this error:

car.cpp: In function ‘int main()’:
car.cpp:17:26: error: ‘int Engine::horsepower_’ is private within this context
   17 |     int hp = Engine{110}.horsepower_;
      |                          ^~~~~~~~~~~

If you need the value, add a public method, such as int horsepower() const { return horsepower_; }, and call that. Do not make the member public to get around the error. The private rule is what lets the engine change its internal layout later without breaking callers.

Storing parts by value and forgetting copies

The car stores its engine by value. A copy of a car makes a copy of the engine, and the copy is a separate object. That is usually what you want for a part that belongs to one car. It can be surprising when the part is large or when it is meant to be shared. In that case, store a std::shared_ptr or a reference, and decide who owns the part. Ownership is covered later in the module on RAII.

Practice

Exercise 1. Explain why Car stores its Engine by value and not as Engine*.

Show the answer

A car owns its engine. The engine is built together with the car and destroyed with it. Storing the engine by value means the compiler handles its lifetime, so you do not need a new or a delete, and you cannot forget to free it. A pointer would need someone to decide who frees the engine, and a mistake there causes a memory leak or a crash.

Exercise 2. Predict the result of s.insert(s.begin(), 99); when s is the composed Stack from example three. Say what the compiler does.

Show the answer

The compiler reports an error and the program does not build. The composed Stack has no insert member. Its only public methods are push, pop, empty and size, so the call has nothing to match. The error text says has no member named 'insert'.

Exercise 3. Add a Trunk class that holds a capacity in litres, and make Car hold one. Write the new constructor signature, and say whether the trunk should be stored by value or by std::optional.

Show the answer

Store the trunk by value, because every car has a trunk. The constructor can take a trunk as a parameter, for example Car(std::string name, int horsepower, Trunk trunk), and store it with trunk_{std::move(trunk)} in the initializer list. Use std::optional only for a part that some cars do not have, like the radio in example two.

Exercise 4. Add a method void play_music() const to the Car class from example two that works like turn_on_music. Then write one sentence that explains why the method checks radio_ before it calls play.

Show the answer

The easiest version is void play_music() const { turn_on_music(); }, which reuses the existing check. The check inside turn_on_music is what keeps the program safe. Without it, radio_->play() on an empty optional is undefined behaviour, which means the program may crash or do anything at all.

Exercise 5. A programmer writes class Square : public Rectangle and gives Rectangle a method set_width(int). Explain what goes wrong for code that receives a Rectangle& and passes it a Square.

Show the answer

The caller expects set_width to change only the width. A Square must keep its two sides equal, so the square's version of set_width also changes the height. Code that calls set_width and then reads the height gets a value the caller did not expect. The derived class breaks a promise of the base class.

Summary

  • Use has-a for parts a class holds, such as a car that has an engine, and use composition for it.
  • Use is-a for real kinds of a base type, where code written for the base type should work for every derived type.
  • Store parts as private members, and expose only the methods that the outer class needs.
  • Use std::optional for a part that may be missing, and check it before use.
  • If a derived class cannot keep every promise of its base class, the inheritance is wrong, even when the sentence "is a" sounds true.
Check yourself: Composition (6 questions) →Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.