Skip to main content

🔄 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:
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 Problem: The code expects ALL birds to fly, but Penguin breaks that expectation!

✅ The Correct Solution

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

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

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:
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:
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:
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:
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

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

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

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

This code violates LSP. Fix it!
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

CheckQuestion
✅ 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!