SOLIDIntermediate 14 min Lesson 3 of 5

Liskov Substitution — Subclasses Must Fit

Any subclass must work anywhere its parent works. See the classic broken hierarchy, why it fails, and how to model it correctly.

SOLID · Lesson 3 of 5
0/5 done(0%)

What is it? #

The Liskov Substitution Principle says that if code works with a parent type, it must keep working when handed any subclass.

In practice, a subclass must not tighten what callers can pass in, weaken what they get back, throw new unexpected exceptions, or break promises the parent made.

Violations are easy to spot once you know the smell: a subclass that overrides a method to raise NotImplementedError, or calling code that starts checking isinstance before deciding what to do.

The principle is really about honesty in type relationships. Inheritance is a promise of substitutability, and breaking that promise makes polymorphism unreliable.

Think of it like this #

A vending machine accepts anything shaped like a coin. If someone mints a "coin" that is the right size but jams the mechanism, it is not really a coin — even if it passes the size check.

The machine should not need a list of known-bad coins. The problem is that something was declared to be a coin when it does not behave like one.

Simple example #

The textbook case: a Square inheriting from Rectangle. Mathematically a square is a rectangle, which makes the trap convincing. In code, it breaks any function that sets width and height independently.

Code #

PYTHON
# ---------- BAD: mathematically true, behaviourally false ----------
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, w): self.width = w
    def set_height(self, h): self.height = h
    def area(self): return self.width * self.height


class Square(Rectangle):
    def set_width(self, w):
        self.width = self.height = w        # keeps it square...
    def set_height(self, h):
        self.width = self.height = h        # ...and breaks the parent contract


def resize_and_check(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20, f"expected 20, got {rect.area()}"

resize_and_check(Rectangle(1, 1))    # passes
# resize_and_check(Square(1, 1))     # AssertionError: expected 20, got 16
TEXT
What is wrong

- Code written against Rectangle assumes width and height are independent.
- Square silently breaks that assumption, so a correct function starts failing.
- The only "fix" within this design is an isinstance check, which is the smell itself.
- The real issue is the model: "is a" in geometry is not "is a" in behaviour.
PYTHON
# ---------- BETTER: model the behaviour, not the vocabulary ----------
from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width, self.height = width, height

    def with_size(self, width, height) -> "Rectangle":
        return Rectangle(width, height)        # returns a new shape

    def area(self): return self.width * self.height


class Square(Shape):
    def __init__(self, side: float):
        self.side = side

    def with_size(self, side) -> "Square":
        return Square(side)

    def area(self): return self.side ** 2


def total_area(shapes: list[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

print(total_area([Rectangle(5, 4), Square(3)]))     # 29


# ---------- Another common violation ----------
class Storage:
    def save(self, key, data): ...
    def delete(self, key): ...

class ReadOnlyArchive(Storage):
    def save(self, key, data):
        raise NotImplementedError("archive is read-only")   # violates LSP

# Better: do not claim to be a Storage.
class ReadableStorage(ABC):
    @abstractmethod
    def load(self, key): ...

class WritableStorage(ReadableStorage):
    @abstractmethod
    def save(self, key, data): ...

How it works #

In the broken version, resize_and_check is a perfectly reasonable function. It sets two dimensions and expects them to hold. Square cannot honour that, so the function breaks through no fault of its own.

That is the essence of an LSP violation: correct code against the parent becomes incorrect when a subclass arrives.

The fix is not to patch Square. It is to recognise that squares and rectangles share a capability — having an area — but not a mutable shape contract. Both implement Shape, and neither inherits from the other.

with_size returns a new object rather than mutating. Immutability sidesteps the whole class of problems, because there is no shared mutable contract to break.

total_area then works with any shape. Nothing checks types, and adding a triangle requires no change.

The storage example is the version you are far more likely to meet in real code. ReadOnlyArchive claims to be a Storage and then refuses half the contract. Splitting the interface — readable versus writable — means a read-only archive simply does not claim capabilities it lacks. That is also the Interface Segregation Principle, which is the next lesson.

Real-world use #

The most common real violation is a subclass that throws on a method it cannot support. Collections that claim to be mutable but are not, clients that claim to support a protocol but only part of it, and test doubles that behave differently from the real thing in ways tests do not catch.

Another frequent one is tightening preconditions. If the parent accepts any positive number and the subclass rejects anything above 100, callers written against the parent will break.

The practical detection tool is a shared test suite. Write tests against the interface and run them against every implementation. Any subclass that fails them is not substitutable, and you find out in CI rather than in production.

When a subclass genuinely cannot fulfil the contract, the answer is to change the model: split the interface, use composition, or make the type independent.

Common mistakes #

  • Overriding a method to raise NotImplementedError instead of fixing the hierarchy.
  • Tightening input rules in a subclass so valid parent inputs now fail.
  • Returning a narrower or different type than the parent promised.
  • Adding isinstance checks in calling code to work around a bad subclass.
  • Deriving hierarchies from real-world vocabulary rather than from behaviour.

Practice #

Model a Bird base class with fly() and add Sparrow and Penguin. Explain why the obvious hierarchy violates LSP, then redesign it so a function that makes birds fly cannot be handed a penguin. Finally, write one shared test suite and run it against both of your final types.

Quick quiz

  1. 1. What does the Liskov Substitution Principle require?

  2. 2. Why is `Square` inheriting from `Rectangle` a problem?

  3. 3. What is a strong smell of an LSP violation?

  4. 4. How can you detect violations automatically?

  5. 5. What is the right fix when a subclass cannot fulfil the contract?

Summary

  • A subclass must be usable anywhere the parent is, with no surprises.
  • Do not tighten inputs, weaken outputs, or refuse part of the contract.
  • NotImplementedError in an override signals a broken hierarchy.
  • Model behaviour, not real-world vocabulary.
  • Run one shared test suite against every implementation.