🔄 The LSP Rule
“Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.”In simple terms: If you expect a Bird, any type of bird (Sparrow, Eagle, Parrot) should work the same way!
The Test: If your code works with a parent class, it should work with ANY child class without knowing the difference!
🐧 The Famous Penguin Problem
The classic LSP violation:Copy
class Bird:
def fly(self):
print("Flying high! 🦅")
class Sparrow(Bird):
def fly(self):
print("Sparrow flying! 🐦")
class Penguin(Bird): # 🚨 PROBLEM!
def fly(self):
raise Exception("I can't fly! 🐧") # Surprise!
# Code that works with Bird:
def make_bird_fly(bird: Bird):
bird.fly() # Expects all birds to fly
# Works fine:
make_bird_fly(Sparrow()) # ✅ Works!
# CRASHES:
make_bird_fly(Penguin()) # 💥 Exception! LSP violated!
✅ The Correct Solution
Copy
from abc import ABC, abstractmethod
class Bird(ABC):
"""Base bird - only common behavior"""
@abstractmethod
def move(self):
pass
@abstractmethod
def eat(self):
pass
class FlyingBird(Bird):
"""Birds that can fly"""
def move(self):
self.fly()
def fly(self):
print("Flying through the air! 🦅")
def eat(self):
print("Pecking at food 🌾")
class SwimmingBird(Bird):
"""Birds that swim"""
def move(self):
self.swim()
def swim(self):
print("Swimming in water! 🏊")
def eat(self):
print("Catching fish 🐟")
class Sparrow(FlyingBird):
def fly(self):
print("Sparrow flying! 🐦")
class Eagle(FlyingBird):
def fly(self):
print("Eagle soaring! 🦅")
class Penguin(SwimmingBird):
def swim(self):
print("Penguin diving deep! 🐧")
class Duck(FlyingBird, SwimmingBird): # Can do both!
def fly(self):
print("Duck flying! 🦆")
def swim(self):
print("Duck paddling! 🦆")
def move(self):
self.fly() # Prefers flying
# Now any Bird can move() - no surprises!
def make_bird_move(bird: Bird):
bird.move() # Works for ALL birds!
make_bird_move(Sparrow()) # ✅ Flying!
make_bird_move(Penguin()) # ✅ Swimming!
make_bird_move(Eagle()) # ✅ Flying!
📏 The Rectangle-Square Problem
Another famous LSP violation:❌ BAD: Square extends Rectangle
Copy
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
def area(self):
return self._width * self._height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
@Rectangle.width.setter
def width(self, value):
# 🚨 Must keep width = height for square!
self._width = value
self._height = value # Surprise side effect!
@Rectangle.height.setter
def height(self, value):
# 🚨 Must keep width = height for square!
self._width = value # Surprise side effect!
self._height = value
# Code that works with Rectangle:
def resize_rectangle(rect: Rectangle, new_width, new_height):
rect.width = new_width
rect.height = new_height
# Expect area = width * height
expected_area = new_width * new_height
actual_area = rect.area()
print(f"Expected: {expected_area}, Actual: {actual_area}")
assert actual_area == expected_area # 💥 Fails for Square!
# Test:
rect = Rectangle(10, 20)
resize_rectangle(rect, 5, 10) # ✅ Works! Expected: 50, Actual: 50
square = Square(10)
resize_rectangle(square, 5, 10) # 💥 Fails! Expected: 50, Actual: 100
# Because setting height=10 ALSO set width=10!
✅ GOOD: Separate Shapes
Copy
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def resize(self, new_width, new_height):
self.width = new_width
self.height = new_height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def resize(self, new_side):
self.side = new_side
# Now each shape behaves correctly!
rect = Rectangle(10, 20)
rect.resize(5, 10)
print(f"Rectangle area: {rect.area()}") # 50 ✅
square = Square(10)
square.resize(5)
print(f"Square area: {square.area()}") # 25 ✅
🎯 LSP Rules to Follow
Rule 1: Method Signatures
Child methods should accept the same or broader input types:Copy
class Parent:
def process(self, data: list):
pass
class GoodChild(Parent):
def process(self, data: list): # ✅ Same type
pass
class BadChild(Parent):
def process(self, data: list[int]): # ❌ More restrictive!
pass
Rule 2: Return Types
Child methods should return the same or narrower types:Copy
class Parent:
def get_value(self) -> object:
return "anything"
class GoodChild(Parent):
def get_value(self) -> str: # ✅ More specific (str is object)
return "string only"
class BadChild(Parent):
def get_value(self) -> str | None: # ❌ Might return None!
return None
Rule 3: No New Exceptions
Children shouldn’t throw exceptions parents didn’t:Copy
class Parent:
def save(self, data):
# Might raise IOError
pass
class GoodChild(Parent):
def save(self, data):
# Also might raise IOError - same as parent
pass
class BadChild(Parent):
def save(self, data):
raise PermissionError("Surprise!") # ❌ New exception type!
Rule 4: Honor Invariants
If parent guarantees something, child must too:Copy
class BankAccount:
"""Invariant: balance >= 0"""
def __init__(self, balance):
self._balance = max(0, balance)
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
class BadOverdraftAccount(BankAccount):
def withdraw(self, amount):
self._balance -= amount # ❌ Breaks invariant! Balance can be negative!
class GoodOverdraftAccount(BankAccount):
def __init__(self, balance, overdraft_limit=100):
super().__init__(balance)
self.overdraft_limit = overdraft_limit
def withdraw(self, amount):
if amount > self._balance + self.overdraft_limit:
raise ValueError("Exceeds overdraft limit")
self._balance -= amount # ✅ New invariant: balance >= -overdraft_limit
🎮 Real Example: File System
❌ BAD: ReadOnlyFile breaks File contract
Copy
class File:
def read(self):
pass
def write(self, content):
pass
class RegularFile(File):
def __init__(self, path):
self.path = path
self.content = ""
def read(self):
return self.content
def write(self, content):
self.content = content
class ReadOnlyFile(File): # 🚨 Problem!
def __init__(self, path):
self.path = path
self.content = "Read only content"
def read(self):
return self.content
def write(self, content):
raise PermissionError("Cannot write to read-only file!") # 💥
# Code expecting File:
def save_to_file(file: File, data):
file.write(data) # Works for File...
print("Saved!")
save_to_file(RegularFile("test.txt"), "Hello") # ✅ Works
save_to_file(ReadOnlyFile("config.txt"), "Hello") # 💥 Crashes!
✅ GOOD: Separate Interfaces
Copy
from abc import ABC, abstractmethod
class Readable(ABC):
@abstractmethod
def read(self) -> str:
pass
class Writable(ABC):
@abstractmethod
def write(self, content: str):
pass
class ReadWriteFile(Readable, Writable):
def __init__(self, path):
self.path = path
self.content = ""
def read(self):
return self.content
def write(self, content):
self.content = content
class ReadOnlyFile(Readable): # Only implements Readable
def __init__(self, path, content):
self.path = path
self.content = content
def read(self):
return self.content
# Now functions specify what they need:
def read_data(source: Readable) -> str:
return source.read() # Works for anything Readable
def save_data(target: Writable, data: str):
target.write(data) # Only accepts Writable!
# Usage:
rw_file = ReadWriteFile("data.txt")
ro_file = ReadOnlyFile("config.txt", "settings...")
print(read_data(rw_file)) # ✅
print(read_data(ro_file)) # ✅
save_data(rw_file, "new data") # ✅
# save_data(ro_file, "new data") # ❌ Type error! ReadOnlyFile isn't Writable
🐾 Real Example: Pet Store
Copy
from abc import ABC, abstractmethod
class Pet(ABC):
"""Contract: All pets can be fed and can play"""
@abstractmethod
def feed(self, food: str):
pass
@abstractmethod
def play(self):
pass
@abstractmethod
def make_sound(self):
pass
class Dog(Pet):
def feed(self, food):
print(f"🐕 Dog eating {food}")
def play(self):
print("🐕 Dog playing fetch!")
def make_sound(self):
print("🐕 Woof woof!")
class Cat(Pet):
def feed(self, food):
print(f"🐱 Cat eating {food}")
def play(self):
print("🐱 Cat chasing laser!")
def make_sound(self):
print("🐱 Meow!")
class Parrot(Pet):
def feed(self, food):
print(f"🦜 Parrot eating {food}")
def play(self):
print("🦜 Parrot solving puzzle!")
def make_sound(self):
print("🦜 Hello! Pretty bird!")
# Pet store can handle ANY pet the same way!
class PetStore:
def __init__(self):
self.pets = []
def add_pet(self, pet: Pet):
self.pets.append(pet)
def morning_routine(self):
print("🌅 Morning at the pet store!\n")
for pet in self.pets:
pet.feed("breakfast")
pet.make_sound()
print()
def playtime(self):
print("🎮 Playtime!\n")
for pet in self.pets:
pet.play()
print()
# Any Pet subclass works perfectly!
store = PetStore()
store.add_pet(Dog())
store.add_pet(Cat())
store.add_pet(Parrot())
store.morning_routine()
store.playtime()
# 🎉 Adding new pets is easy and safe!
class Fish(Pet):
def feed(self, food):
print(f"🐠 Fish eating {food}")
def play(self):
print("🐠 Fish swimming around!")
def make_sound(self):
print("🐠 Blub blub...")
store.add_pet(Fish())
store.playtime() # ✅ Works perfectly!
🧪 Practice Exercise
Challenge: Fix the Employee Hierarchy
Challenge: Fix the Employee Hierarchy
This code violates LSP. Fix it!
Copy
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def calculate_bonus(self):
return self.salary * 0.1 # 10% bonus
def get_annual_salary(self):
return self.salary * 12 + self.calculate_bonus()
class FullTimeEmployee(Employee):
def calculate_bonus(self):
return self.salary * 0.15 # 15% bonus ✅ OK
class Intern(Employee): # 🚨 Problem!
def calculate_bonus(self):
raise NotImplementedError("Interns don't get bonuses!")
def get_annual_salary(self):
# Only 6 months internship
return self.salary * 6 # No bonus call here
# This code expects Employee:
def print_employee_report(employee: Employee):
print(f"Employee: {employee.name}")
print(f"Annual Salary: ${employee.get_annual_salary()}")
print(f"Bonus: ${employee.calculate_bonus()}") # 💥 Crashes for Intern!
print_employee_report(FullTimeEmployee("Alice", 5000)) # ✅
print_employee_report(Intern("Bob", 2000)) # 💥 NotImplementedError!
📝 Quick LSP Checklist
| Check | Question |
|---|---|
| ✅ Same behavior? | Does child method behave like parent’s? |
| ✅ No surprises? | Does child throw unexpected exceptions? |
| ✅ Replaceable? | Can you swap parent for child anywhere? |
| ✅ Same contract? | Does child honor parent’s promises? |
🏃 Next: Interface Segregation Principle
Let’s learn why smaller, focused interfaces are better than big ones!Continue to Interface Segregation →
Learn why many small interfaces beat one big interface!