bigonotes
CS ConceptsSOLID Principles

Liskov Substitution Principle

Why a subclass must be usable anywhere its parent is, illustrated by the Penguin and Rectangle-Square problems.

3 min readLast updated SOLID Principles

The Liskov Substitution Principle (LSP) says objects of a subclass must be usable wherever the parent is expected, without breaking anything. Formally: if S is a subtype of T, you can replace T objects with S objects and the program still behaves correctly. In plain terms: a child must keep every promise its parent makes.

The idea#

If someone asks for a Bird and you hand them a Sparrow, everything works. Hand them a Penguin when they expect it to fly, and things break.

The simple test: "If someone expects a Parent and I give them a Child, will they be surprised?" If yes, LSP is violated.

Common violations:

  1. Square extends Rectangle: setting the width unexpectedly changes the height.
  2. Penguin extends Bird with fly(): it can't fly.
  3. ToyCar extends Vehicle with deliver(): it can't carry packages.
  4. ElectricCar extends GasolineCar: it can't refuel with gas.

Example 1: birds that can't fly#

The base class promises that every bird can fly:

class Bird:
    def fly(self) -> None:
        print("Flying high!")


class Penguin(Bird):
    def fly(self) -> None:
        raise NotImplementedError("Can't fly!")  # breaks the contract


def make_bird_fly(bird: Bird) -> None:
    bird.fly()  # assumes every Bird can fly; a Penguin crashes this


make_bird_fly(Bird())  # Flying high!
try:
    make_bird_fly(Penguin())
except NotImplementedError as e:
    print("Surprise:", e)

Printing "Error! I can't fly!" instead of throwing is still a violation: the caller asked for flight and silently didn't get it.

Fix: only promise what every subtype can do#

Move flight out of Bird and into a capability only flying birds have:

from abc import ABC, abstractmethod


class Bird:
    def eat(self) -> None:
        print("Bird eating")


class CanFly(ABC):
    @abstractmethod
    def fly(self) -> None: ...


class Sparrow(Bird, CanFly):
    def fly(self) -> None:
        print("Sparrow flying!")


class Penguin(Bird):
    def swim(self) -> None:
        print("Penguin swimming!")


def make_bird_fly(bird: CanFly) -> None:
    bird.fly()  # only accepts things that promise flight


make_bird_fly(Sparrow())
Penguin().swim()

An equivalent approach is a FlyingBird(Bird) subclass that adds fly(). Either way, code that needs flight asks for CanFly (or FlyingBird), and Penguin never claims it can.

Example 2: the Rectangle-Square problem#

Mathematically a square is a rectangle, so inheritance looks natural.

class Rectangle:
    def __init__(self, width: int = 0, height: int = 0):
        self._width = width
        self._height = height

    def set_width(self, w: int) -> None:
        self._width = w

    def set_height(self, h: int) -> None:
        self._height = h

    def area(self) -> int:
        return self._width * self._height


class Square(Rectangle):
    def set_width(self, w: int) -> None:  # keep the sides equal
        self._width = self._height = w

    def set_height(self, h: int) -> None:
        self._width = self._height = h

It breaks callers that rely on Rectangle's behavior:

def check_rectangle(rect: Rectangle) -> None:
    rect.set_width(5)
    rect.set_height(10)
    print("Expected 50, got:", rect.area())
    # Rectangle: 50. Square: 100, because set_height also changed width.


check_rectangle(Rectangle())  # Expected 50, got: 50
check_rectangle(Square())     # Expected 50, got: 100

Fix: make them siblings#

from abc import ABC, abstractmethod


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


class Rectangle(Shape):
    def __init__(self, width: int = 0, height: int = 0):
        self._width = width
        self._height = height

    def set_width(self, w: int) -> None:
        self._width = w

    def set_height(self, h: int) -> None:
        self._height = h

    def area(self) -> int:
        return self._width * self._height


class Square(Shape):
    def __init__(self, side: int = 0):
        self._side = side

    def set_side(self, s: int) -> None:  # only one dimension
        self._side = s

    def area(self) -> int:
        return self._side * self._side


print(Rectangle(5, 10).area(), Square(4).area())  # 50 16

Square no longer pretends to be a Rectangle, so nothing surprises the caller. (Immutable shapes are another fix: if you can't set width and height, the conflict disappears.)

What "keeping the promise" means#

A subclass should not:

  • Throw new exceptions the parent doesn't throw for the same call.
  • Do nothing where the parent does something.
  • Require stronger preconditions (accept less input) than the parent.
  • Deliver weaker postconditions (guarantee less) than the parent.

Key takeaways#

  • A child must be a drop-in replacement for its parent.
  • "Is-a" in the real world doesn't always mean "is-a" in code; model behavior, not taxonomy.
  • Overrides that throw, no-op or change side effects are red flags.
  • Fix violations by restructuring the hierarchy: siblings under a common parent, or capability interfaces.

Found a mistake? Tell me on LinkedIn ↗

Further reading#