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:
SquareextendsRectangle: setting the width unexpectedly changes the height.PenguinextendsBirdwithfly(): it can't fly.ToyCarextendsVehiclewithdeliver(): it can't carry packages.ElectricCarextendsGasolineCar: 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.