Concepts / Object-oriented foundations / Inheritance versus composition

Inheritance versus composition

Being a kind of thing, or having a thing: when each is right.

01The intuition

The problem it solves

Suppose you model vehicles for a school transport office. A bus has an engine and wheels, so you write Bus as a subclass of Vehicle. Then an electric bus arrives, which has a battery and no fuel tank. Then a trailer, which has wheels and no engine. The obvious tool is inheritance: keep subclassing.

class Vehicle:
    def refuel(self, litres): self.fuel += litres
    def drive(self, km): self.fuel -= km / 10

class Bus(Vehicle): pass
class ElectricBus(Bus):
    def refuel(self, litres):
        raise NotImplementedError("no tank")
class Trailer(Vehicle):
    def drive(self, km):
        raise NotImplementedError("no engine")

Two classes now inherit methods they must refuse. Every caller that holds a Vehicle has to guess whether refuel will work. Add a hybrid bus and the tree splits again. The tree says "an electric bus is a bus", which is true in the car park, but the code cares about parts: has an engine, has a tank, has a battery. Inheritance answers "is a kind of"; composition answers "has a". Picking the wrong one is the source of most tangled class trees in interviews.

Build it one piece at a time

Step 1: name the parts that vary. Fuel tanks, batteries and engines are the things that differ. Make each a small class with its own rule.

class FuelTank:
    def __init__(self, litres: float = 0): self.litres = litres
    def refuel(self, litres: float) -> None: self.litres += litres

class Battery:
    def __init__(self, kwh: float = 0): self.kwh = kwh
    def charge(self, kwh: float) -> None: self.kwh += kwh

Step 2: let a vehicle hold the parts it has, and only those. A vehicle is now a container of parts; a trailer simply has fewer.

class Vehicle:
    def __init__(self, tank: FuelTank | None = None, battery: Battery | None = None):
        self.tank = tank
        self.battery = battery

bus = Vehicle(tank=FuelTank(200))
electric_bus = Vehicle(battery=Battery(300))
trailer = Vehicle()

Step 3: put behaviour next to the part it needs. Driving consumes from whichever energy source exists; an energy source is an interface, and the vehicle asks it rather than checking types.

from abc import ABC, abstractmethod

class EnergySource(ABC):
    @abstractmethod
    def consume(self, km: float) -> None: ...

class FuelTank(EnergySource):
    def __init__(self, litres: float = 0): self.litres = litres
    def consume(self, km: float) -> None: self.litres -= km / 10

class Battery(EnergySource):
    def __init__(self, kwh: float = 0): self.kwh = kwh
    def consume(self, km: float) -> None: self.kwh -= km / 5

class Vehicle:
    def __init__(self, sources: list[EnergySource]):
        self.sources = sources
    def drive(self, km: float) -> None:
        if not self.sources:
            raise ValueError("this vehicle cannot move on its own")
        self.sources[0].consume(km)

hybrid = Vehicle([Battery(50), FuelTank(40)])

Step 4: keep inheritance for true kinds. If a school bus and a city bus share every part but differ in a rule such as "must stop at every stop", that is a behaviour difference of the same kind of thing, and a subclass or a strategy is honest. The test is a sentence: "a hybrid is a vehicle" is true; "a hybrid is a bus with a battery bolted on" is a description of parts.

StepClassesMethods that raise NotImplementedAdding a hybrid means
StartVehicle, Bus, ElectricBus, Trailer2a new subclass with more overrides
1 and 2Vehicle, FuelTank, Battery0one constructor call
3plus EnergySource0a list with two parts

Why it works

Composition keeps each class responsible for one thing it fully owns. A FuelTank never has to know about buses. The invariant is simple: an object only exposes the operations of the parts it actually has, so there is no method that exists but must not be called. Inheritance, in contrast, copies the whole parent interface into the child whether it fits or not, and the child can only widen it, never shrink it.

The rule that interviewers quote is "favour composition over inheritance". It does not mean never inherit. It means: inherit when the child really is substitutable for the parent in every use (see Liskov substitution), and compose when you are really describing parts or capabilities. Where it breaks the other way: composition taken to the extreme produces objects that are bags of fields with no behaviour, and a drive method that reaches into three parts to do its job. Behaviour should sit with the part that owns the data.

The trade-off you must say out loud

Composition costs forwarding: if callers need vehicle.refuel(), you write a one-line method that forwards to the tank, and with many parts that is many forwarders. Inheritance gives you all of that for free, at the price of coupling the child to every future change in the parent. Say which you chose and why in one sentence: "pieces that vary independently are composed; a true subtype that only refines a rule inherits". The usual follow-up is "what if a new kind arrives next week", and the answer should be a constructor call, not a new branch of a tree.

Where it shows up in the 100 Google problems

  • Design chess: pieces inherit a common Piece because every piece really is one and only the move rule differs; the board, the move history and the clock are composed, not inherited.
  • Design a parking lot: a spot has a size and a vehicle has a size; compatibility is data on both, not a subclass per combination.
  • Design a deck of cards and Blackjack: a Hand has cards and a scoring rule; the dealer is a player with a different rule object, not a deep subclass.
  • Design a multi-elevator dispatcher: the dispatcher has a scheduling strategy and a list of cars; it does not subclass Elevator.

02Used in these problems