I'll Be Backend
Principles · Python

Composition over inheritance: end the subclass explosion

Build each rental from parts, so a new car is one small class.

15 steps4 chapters~6 min at 1.25×#oop#composition#inheritance#delegation
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–11

Payoff

Steps 12–13

Recap

Steps 14–15

The code: before and after

Before

from abc import ABC, abstractmethod


class Rental(ABC):
    def __init__(self, days: int) -> None:
        self.days = days

    @abstractmethod
    def price(self) -> int: ...


class EconomyRental(Rental):
    def price(self) -> int:
        return 30 * self.days


class SuvRental(Rental):
    def price(self) -> int:
        return 50 * self.days + 20


class InsuredEconomy(EconomyRental):
    def price(self) -> int:
        return super().price() + 15 * self.days


class InsuredSuv(SuvRental):
    def price(self) -> int:
        return super().price() + 15 * self.days


def desk() -> None:
    city = EconomyRental(3)
    trip = InsuredSuv(3)
    print(city.price(), trip.price())  # 90 215


desk()

After

from typing import Protocol


class Car(Protocol):
    def price(self, days: int) -> int: ...


class EconomyCar:
    def price(self, days: int) -> int:
        return 30 * days


class SuvCar:
    def price(self, days: int) -> int:
        return 50 * days + 20


class VanCar:
    def price(self, days: int) -> int:
        return 70 * days


class Cover(Protocol):
    def price(self, days: int) -> int: ...


class Insurance:
    def price(self, days: int) -> int:
        return 15 * days


class NoCover:
    def price(self, days: int) -> int:
        return 0


class Rental:
    def __init__(self, car: Car, cover: Cover, days: int) -> None:
        self.car = car
        self.cover = cover
        self.days = days

    def price(self) -> int:
        return (self.car.price(self.days)
                + self.cover.price(self.days))


def desk() -> None:
    city = Rental(EconomyCar(), NoCover(), 3)
    trip = Rental(SuvCar(), Insurance(), 3)
    move = Rental(VanCar(), Insurance(), 3)
    print(city.price(), trip.price(), move.price())  # 90 215 255


desk()

Transcript, step by step

Problem

Step 1: A car rental desk Before

At the car rental desk from the last lesson, a rental knows its days, and each subclass sets the price. Economy Rental and SUV Rental each price one car kind, and insurance adds fifteen dollars a day on top. Insurance came after the car classes, so extending each one added it without touching them, their callers, or an if inside every price. The desk rents an economy car and an insured SUV for three days, and prints ninety and two hundred fifteen.

Step 2: Vans join the fleet Code smell

The fleet now has vans, at seventy dollars a day, and customers can insure them too. The quick way adds Van Rental, and then Insured Van, which extends it and adds the same fifteen dollars a day. The desk rents an insured van for three days, and the run prints two hundred fifty five as the third price.

Step 3: A subclass for every pair Code smell

This is a subclass explosion, when a class varies in two independent ways and inheritance needs one subclass for every pair. Here the two ways are the car kind and the cover, so every car kind needs a second class for insurance. You can see the insurance rule here, here, and here. A subclass per kind worked last lesson, because rentals varied in only one way, the car, but a second way multiplies the classes. One van needed two classes, and the insurance rule now lives in three places, so changing the insurance price means three edits.

Refactor

Step 4: Refactor first Back it out

With vans, three car kinds times two choices of cover make six rental classes, and a premium cover would make it nine. So let's take the vans out again. We'll refactor first, so car and cover become separate parts, and then vans will need one new class.

Step 5: Insurance gets a class Extract Class

First, the insurance rule moves into a class of its own, Insurance, with a price for a number of days. Both insured subclasses now ask a new Insurance for its price, instead of adding fifteen a day themselves. The rule lives in one place now, and the run still prints ninety and two hundred fifteen.

Step 6: Name the cover Extract Interface

Next, a Protocol named Cover declares one method, price for a number of days. A Protocol only checks the shape, so Insurance fits Cover without inheriting anything, and the type checker confirms it. We need the Protocol because the rental will soon hold a cover of any kind, whatever it charges.

Step 7: A cover for no cover Introduce Null Object

A rental without insurance will still need a cover, so No Cover has the same price method and returns zero. This is a Null Object, an object that stands in for nothing, so an uninsured rental will need no None check and no if.

Step 8: The rental holds a cover Replace Subclass with Delegate

Now Rental gets a cover field, passed in through its constructor. Its price is concrete now, the car's price plus the cover's price for the same days. This is composition, where the rental holds a cover as a field and asks it for its part of the price. The cover is the rental's delegate, the object it hands that part of the work to. Economy Rental and SUV Rental override car price instead, both insured subclasses are deleted, and the desk passes each rental its cover.

Step 9: Each car gets a class Extract Class

The car kind gets the same treatment, so each car's price moves into its own class, Economy Car and SUV Car. Economy Rental and SUV Rental now ask their car object for the price inside car price.

Step 10: Name the car Extract Interface

Both car classes answer the same question, so a Protocol named Car declares it. Economy Car and SUV Car already have that price method, so they match Car the same way Insurance matches Cover.

Step 11: The rental holds a car Replace Subclass with Delegate

Last, Rental holds a car as well, and its price adds the car's price and the cover's price. Nothing extends Rental anymore, so it becomes a plain class without the abstract base, and the car is its second delegate. Economy Rental, SUV Rental and car price are deleted, and the desk builds each rental from a car, a cover and the days. The run still prints ninety and two hundred fifteen, so nothing changed for the desk's customers.

Payoff

Step 12: Vans, as one class Add vans

Now vans come back, and this time they are one new class, Van Car, at seventy dollars a day. The desk rents a van with Insurance for three days, and the run prints two hundred fifty five. Rental, both protocols, the other two cars and both covers didn't change, and no insured van class exists.

Step 13: Parts add up Composition over inheritance

The three copies of the insurance rule are now one, here in Insurance. A premium cover would be one more Cover class, and every car kind could use it. The Gang of Four put it as favor object composition over class inheritance, and it fits here because rentals vary in two independent ways. As fields, each way grows on its own, so three cars plus two covers make five small classes, where inheritance needed six rental subclasses.

Recap

Step 14: Where we started, where we ended Before / after

On the left, every pair of car kind and cover had its own subclass, with the insurance rule copied into each insured one. On the right, Rental holds a car and a cover, and adds their two prices. Each car kind, like SUV Car, and each cover, like Insurance, is one small class of its own.

Step 15: When to reach for it Takeaways

Reach for it when a class varies in two or more independent ways, like car kind and cover. Skip it when there's only one way to vary, like last lesson's rentals, because nothing multiplies and a delegate only adds an object and a call. You'll spot the need by class names that join words from two lists, like Insured SUV, and the same override copied across sibling subclasses. Python's Buffered Reader adds buffering to any raw stream it holds, so there's no buffered subclass for each kind of stream.