Skip to content
FollowtheDamnCode

Liskov Substitution Principle

A subclass must keep every promise its parent makes, so code written for the parent works unchanged with the child.

Big idea

The Liskov Substitution Principle (the L in SOLID): anywhere you use a parent type, you must be able to drop in any subtype without the program going wrong. A subclass may do more, but it may not demand more from callers or promise less to them.

The problem it solves

The textbook trap: a Square is a rectangle in geometry, so Square(Rectangle) seems natural. A square must keep width and height equal, so its set_width also sets the height. Then this perfectly reasonable function, written for rectangles, breaks:

def stretch(r):
    r.set_width(5)
    r.set_height(4)
    assert r.area() == 20   # fails for a Square: area is 16

The code wasn't wrong; the inheritance was. "Is-a" in the real world isn't the test — behaving like one is.

Watch it

Think of it like

A hire-car booking for "a mid-size car". You'll accept any model — that's substitution. But if they hand you one that only runs on a fuel no station sells, or won't go above 30 km/h, it technically is a car and it's useless to you. A substitute has to work everywhere the original was promised to work.

The rules a subtype must keep

  • Preconditions can't be strengthened — if the parent accepts any positive amount, the child can't demand amounts over 10.
  • Postconditions can't be weakened — if the parent promises a sorted list, the child must return one too.
  • Invariants must hold — whatever is always true of the parent stays true.
  • No new surprise exceptions — callers handling the parent's errors shouldn't meet new ones.
  • History rule — a child can't allow state changes the parent forbids (an immutable parent can't have a mutable child).
Fix the hierarchy so nobody inherits a promise they can't keep

Square and Rectangle become siblings: both are shapes, neither promises the other's resize behaviour.

Hands on

ReadOnlyFile subclasses File but throws where the parent promised to write. The backup routine — written against File — crashes. Restructure so read-only files aren't passed where writable ones are promised (one clean way: a Readable base with read(), and File adding write()), keeping the printed output shown.

Fix it: a subclass that broke its parent's promise
class File:
    def __init__(self, name, text=""):
        self.name, self.text = name, text
    def read(self):
        return self.text
    def write(self, text):
        self.text = text

class ReadOnlyFile(File):
    def write(self, text):
        raise PermissionError(f"{self.name} is read-only")


def print_all(files):
    for f in files:
        print(f"{f.name}: {f.read()}")

def stamp_all(files):                    # written against File: "every file can be written"
    for f in files:
        f.write(f.read() + " [backed up]")


notes = File("notes.txt", "buy milk")
licence = ReadOnlyFile("LICENSE", "MIT")
print_all([notes, licence])
stamp_all([notes, licence])
print_all([notes])
output
notes.txt: buy milk
LICENSE: MIT
notes.txt: buy milk [backed up]

Run and edit it in the app.

Parent: withdraw(amount) accepts any amount up to the balance. Child: withdraw(amount) raises if amount > 500, even with enough balance. What's wrong?

Smells that point to an LSP break

  • A subclass method that raises NotImplementedError or does nothing ("refused bequest").
  • Callers doing isinstance checks to special-case one subtype.
  • Overrides whose docs say "unlike the parent, this…".

When it matters most

Whenever someone else's code holds a reference to your parent type: plug-ins, frameworks, collections of mixed subtypes, anything behind an interface. If no code ever treats the child as the parent, you're not using inheritance for substitution — consider composition instead.

Pros and cons of honouring it

ProsCons
Polymorphic code just works with new subtypesForces you to rethink "obvious" hierarchies
No isinstance special casesSometimes means flatter, sibling hierarchies
Contracts become explicitRequires thinking in pre/postconditions

How it relates

  • It's what makes Open/Closed safe: plugged-in subtypes must honour the contract.
  • Interface Segregation often fixes LSP breaks by splitting the promise (as Readable/File did above).
  • The smell Refused Bequest in OO Abusers is an LSP violation in disguise.

You find "if isinstance(bird, Penguin): skip" before calling bird.fly(). What does that most likely indicate?

Key terms

  • Liskov Substitution Principle — Subtypes must be usable anywhere their parent type is expected, without surprises.
  • Precondition — What must be true before a method is called; a subtype may not demand more.
  • Postcondition — What a method guarantees afterwards; a subtype may not guarantee less.
  • Refused bequest — A subclass that rejects or disables behaviour it inherited.