Liskov Substitution Principle
Overview
The Liskov Substitution Principle (LSP) states that objects of a superclass must be replaceable with objects of its subclasses without breaking the application. If `Square` inherits from `Rectangle`, a function expecting a `Rectangle` must work perfectly when passed a `Square`. To maintain this principle, a subclass must strictly honor three rules: - Match the parent's parameter count and types, ensuring callers can pass the expected arguments. - Return the same data type as the parent, never returning `None` when an object is expected. - Preserve the parent's behavioral contracts, meaning it must not introduce new unexpected side effects. Think of it as an electrical outlet: if you swap a standard wall outlet (superclass) with a smart outlet (subclass), it must still accept the same standard plugs and deliver the same voltage, or devices will fry. An often-missed edge case is raising new exception types in a subclass: class Cache: def get(self, key): return None class StrictCache(Cache): def get(self, key): raise KeyError(key) StrictCache().get('user') # Crashes expecting None! If your subclass raises an exception that the parent doesn't, callers won't have a `try/except` block to catch it and the application will crash. Another sharp edge is implicitly tightening parameter types in a subclass: class Parser: def parse(self, data): return len(data) # Accepts any sequence class ListParser(Parser): def parse(self, data): data.append(1) # Trap: Assumes a mutable list! ListParser().parse((1, 2)) # Crashes: 'tuple' object has no attribute 'append' If a subclass requires a stricter type than the parent, it will break callers who pass the broader types that the parent originally allowed.
It prevents unexpected runtime crashes and confusing bugs when working with inheritance, ensuring that polymorphism actually works safely.
Where used: `FastAPI` dependency injection, `Pydantic` custom validators, Standard library `abc` module
Why learn this
- Write reliable subclass implementations
- Design safe and predictable class hierarchies
- Avoid the fragile base class problem in object-oriented code
Code walkthrough
class Bird:
def fly(self):
return 'Flapping wings'
class Penguin(Bird):
def fly(self):
raise NotImplementedError('Cannot fly')
def make_bird_fly(bird):
print(bird.fly())
penguin = Penguin()
make_bird_fly(penguin) # Crashes!
Focus: raise NotImplementedError('Cannot fly')
Aha moment
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = 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.h = h; self.w = h
def resize(rect):
rect.set_w(5)
rect.set_h(4)
print(rect.area())
sq = Square(2, 2)
resize(sq)
Prediction: What area is printed when `resize(sq)` is called?
Common guess: 20
The `resize` function assumes independent width and height, but `Square` links them. By the time `rect.area()` is called, the square's sides are `4x4`, printing `16`, showing why a `Square` is a mathematical rectangle but a bad programmatic subclass of `Rectangle`.
Common mistakes
- Changing parameter types or counts: A subclass overrides a parent method but requires extra mandatory arguments. Code expecting the parent class will fail because it won't pass those new arguments.
- Returning a different type: A parent method returns a `list`, but the overridden subclass method returns a single item or `None`. The caller will crash when trying to iterate over the result.
Glossary
- superclass
- The original or parent class that other classes are built upon and inherit features from, e.g. `class Animal` is the superclass of `class Dog(Animal)`.
- polymorphism
- The ability of different objects to respond to the exact same method call in their own specific way, e.g. `dog.speak()` prints `'Woof'` while `cat.speak()` prints `'Meow'`.
Recall questions
- What is the core rule of the Liskov Substitution Principle regarding subclasses?
- If a subclass changes the return type of an inherited method, why does this violate LSP?
- Why is raising a new exception in an overridden subclass method considered a violation of LSP?
Understanding checks
A subclass overrides a parent method `process(data: list)`. The subclass changes the signature to `process(data: list, strict: bool = False)`. Does this violate LSP?
No, this does not violate LSP.
Because `strict` has a default value (`False`), any caller that expects the parent class can still call `process(data)` with a single argument. The subclass honors the original contract.
What happens when this code is executed?
It raises an `AttributeError`.
`Child.get_status()` returns `None` (because it has only a `pass` statement), violating the implicit contract of `Parent` which returns a `str`. Calling `.lower()` on `None` crashes.
A parent class method `filter_items(self, items)` is designed to accept any iterable (like lists, tuples, or sets). A subclass overrides it with `filter_items(self, items: list)`, explicitly using `.sort()` which only exists on lists. Does this violate LSP?
Yes, it violates LSP by tightening the parameter type.
The parent contract allowed any iterable. By demanding a `list` (and using list-specific methods like `.sort()`), the subclass will crash if a caller passes a tuple, breaking the promise made by the parent class.
Practice tasks
Fix the Return Type Violation
The `SMSNotification` class violates LSP. The parent `Notification` class specifies that `send` should return a `bool` indicating success, but `SMSNotification` returns nothing (`None`). Modify `SMSNotification.send` so it returns `True` after printing the message, repairing the contract.
Challenge
Safe Export System
Create a base class `DataExporter` with a method `export(data: dict)`. Then implement a subclass `JSONExporter` that prints the `dict`, and a subclass `CSVExporter`. The base class contract requires `export` to accept a `dict`. `CSVExporter` must handle the `dict` properly without adding extra required parameters to `export`. Implement both classes so `process_export(exporter, {'id': 1})` works seamlessly for both.
Continue learning
Previous: Single inheritance and super()
Previous: Method Overriding