Concepts / SOLID and design principles / Liskov substitution

Liskov substitution

A subclass must be usable wherever its parent is.

01The intuition

The problem it solves

You have a Rectangle with a width and a height, and a function that tests it. Then someone says a square is a rectangle, so Square inherits from Rectangle and keeps its sides equal.

class Rectangle:
    def __init__(self, w, h): self.w, self.h = w, h
    def set_w(self, w): self.w = w
    def set_h(self, h): self.h = h
    def area(self): return self.w * self.h

class Square(Rectangle):
    def set_w(self, w): self.w = w; self.h = w
    def set_h(self, h): self.w = h; self.h = h

def stretch(r: Rectangle):
    r.set_w(4); r.set_h(5)
    assert r.area() == 20      # fails for Square: area is 25

stretch was written against the promise of Rectangle: setting width does not touch height. Square is a rectangle in geometry but breaks that promise in code, so any function that worked with rectangles can now silently fail. The same bug appears in interviews as a ReadOnlyList that inherits from List and throws on add, or a Penguin that inherits fly and raises.

The Liskov substitution principle says: wherever the code expects the parent, you must be able to hand it any child and nothing observable changes except for the better.

Build it one piece at a time

Step 1: write down the parent's promises. Not the method names, the behaviour. For Rectangle: "after set_w(x), w == x and h is unchanged". A child must keep every such sentence true.

Step 2: check each child against the list. Square.set_w changes h, so Square fails. The fix is not a cleverer override; it is to stop pretending.

from abc import ABC, abstractmethod

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

class Rectangle(Shape):
    def __init__(self, w: float, h: float): self.w, self.h = w, h
    def area(self) -> float: return self.w * self.h

class Square(Shape):
    def __init__(self, side: float): self.side = side
    def area(self) -> float: return self.side ** 2

Step 3: move shared behaviour to the promise both can keep. Both really can promise area. Neither needs to promise independent setters, so that promise is dropped from the shared parent and the stretch function takes a Rectangle explicitly.

Step 4: when you do subclass, only strengthen what you give and only loosen what you demand. A child may accept more inputs and return more specific results, never fewer or vaguer.

class Parser:
    def parse(self, text: str) -> dict:
        if not text: raise ValueError("empty")
        return {"raw": text}

class LenientParser(Parser):
    def parse(self, text: str) -> dict:     # accepts more: empty is fine
        if not text: return {}
        return {"raw": text.strip()}         # returns a dict as promised

class StrictParser(Parser):
    def parse(self, text: str) -> dict:     # breaks LSP: demands more
        if len(text) < 3: raise ValueError("too short")
        return {"raw": text}
ChildInputs it acceptsPromises it keepsSubstitutable
Square(Rectangle) with linked setterssamebreaks "h unchanged"no
LenientParsermore (empty ok)all, returns a dictyes
StrictParserfewer (rejects short)breaks "non-empty parses"no

Why it works

Callers are written against the parent's contract and never read the child's code. If every child keeps the contract, a caller that is correct for the parent is correct for all children, which is the whole reason polymorphism is safe. The invariant has three parts, worth memorising: a child's method may require no more than the parent (preconditions not strengthened), must guarantee at least what the parent does (postconditions not weakened), and must keep the parent's invariants.

Where it breaks in practice: overriding a method to throw NotImplementedError, overriding to do nothing, and type checks like isinstance(x, Square) inside code that takes a Rectangle. Each one is a sign that the thing is not really a subtype and should be composed instead; see inheritance versus composition.

The trade-off you must say out loud

Obeying Liskov sometimes means a shallower hierarchy than the real-world taxonomy suggests, and a few duplicated lines, which feels wrong to people who learned that inheritance exists to remove duplication. Say the rule: "I inherit only when the child keeps every promise of the parent; otherwise I share an interface or compose". The follow-up is usually a concrete trap, "can an ElectricCar extend Car", and the right move is to ask which Car methods a caller relies on and whether the child can honour each.

Where it shows up in the 100 Google problems

  • Design chess: every Piece promises legal_moves(board) returns only moves that keep the king safe; a knight that returned raw squares would break check detection for the whole board.
  • Design a deck of cards and Blackjack: a Dealer that is a Player must still honour take_turn; its fixed hit-until-17 rule narrows behaviour without breaking any caller.
  • Design Monopoly: each Square subclass must complete on_land without leaving the player in an invalid state, or the turn loop cannot trust any square.
  • Design a multi-elevator dispatcher: a maintenance-mode car is still an Elevator that answers "can you take this call" with no, rather than one whose assign raises.

02Used in these problems