Liskov Substitution Principle (LSP) Explained
TL;DR
The Liskov Substitution Principle (LSP) states that objects of a subclass should be replaceable with objects of their superclass without breaking the application. If your code works with a base class, it must work identically with any derived class. Violating LSP leads to fragile inheritance hierarchies and unexpected runtime errors.
Core Concept
What is the Liskov Substitution Principle?
The Liskov Substitution Principle (LSP) is the ‘L’ in SOLID principles. Named after Barbara Liskov, it states: “Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.”
In simpler terms: if class Dog extends class Animal, then anywhere you use an Animal, you should be able to use a Dog instead without the code breaking or behaving unexpectedly.
Why LSP Matters
LSP ensures that inheritance hierarchies are logically sound. When you violate LSP, you create situations where:
- Code that works with base classes fails with derived classes
- You need type-checking (
instanceofortype()) to handle special cases - Subclasses throw unexpected exceptions or return invalid values
- Client code becomes fragile and tightly coupled to specific implementations
The Contract Metaphor
Think of LSP as a contract. The base class establishes a contract (what methods do, what they accept, what they return). Every subclass must honor this contract. This means:
Preconditions cannot be strengthened: If the base method accepts any integer, the subclass can’t require only positive integers.
Postconditions cannot be weakened: If the base method guarantees a non-null return, the subclass can’t return null.
Invariants must be preserved: If the base class maintains certain state rules, subclasses must maintain them too.
History constraint: Subclasses shouldn’t modify state in ways the base class doesn’t allow.
LSP vs. Simple Inheritance
Just because you can use inheritance doesn’t mean you should. The classic example: a Square is mathematically a Rectangle, but in code, Square extending Rectangle often violates LSP because a square’s width and height must always be equal—a constraint rectangles don’t have.
Visual Guide
LSP Compliant vs. Non-Compliant Hierarchies
graph TD
A[Bird] --> B[Sparrow]
A --> C[Penguin]
D[Rectangle] --> E[Square]
style A fill:#90EE90
style B fill:#90EE90
style C fill:#FFB6C6
style D fill:#90EE90
style E fill:#FFB6C6
A1["✓ LSP Compliant"] -.-> A
A2["✗ Penguin can't fly()"] -.-> C
D1["✓ Rectangle has width/height"] -.-> D
D2["✗ Square breaks width/height independence"] -.-> E
Green indicates LSP-compliant relationships; red indicates violations. Penguin violates LSP if Bird has a fly() method. Square violates LSP because setting width independently from height breaks Rectangle’s contract.
Behavioral Substitution Flow
sequenceDiagram
participant Client
participant BaseClass
participant SubClass
Client->>BaseClass: Call method()
BaseClass-->>Client: Expected behavior
Note over Client,SubClass: LSP: Substituting subclass
Client->>SubClass: Call method()
SubClass-->>Client: Same expected behavior
Note over Client,SubClass: Client code unchanged!
LSP ensures that substituting a subclass for a base class requires no changes to client code. The behavior remains consistent with the base class contract.
Examples
Example 1: LSP Violation - Rectangle and Square
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
def set_width(self, width):
self._width = width
def set_height(self, height):
self._height = height
def get_area(self):
return self._width * self._height
class Square(Rectangle):
"""LSP VIOLATION: Square changes Rectangle's behavior"""
def set_width(self, width):
self._width = width
self._height = width # Forced to keep square property
def set_height(self, height):
self._width = height # Forced to keep square property
self._height = height
# Client code that works with Rectangle
def test_rectangle(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
expected_area = 5 * 4 # 20
actual_area = rect.get_area()
assert actual_area == expected_area, f"Expected {expected_area}, got {actual_area}"
print(f"✓ Area is {actual_area}")
# Test with Rectangle - works fine
rect = Rectangle(0, 0)
test_rectangle(rect) # Output: ✓ Area is 20
# Test with Square - FAILS!
square = Square(0, 0)
test_rectangle(square) # AssertionError: Expected 20, got 16
Expected Output:
✓ Area is 20
AssertionError: Expected 20, got 16
Why it violates LSP: The test_rectangle function works correctly with Rectangle but breaks with Square. When we set width to 5, then height to 4, the Square’s overridden methods force both dimensions to 4, giving area 16 instead of 20. The client code must now check types or handle Square specially.
Try it yourself: How would you redesign this to avoid the violation? Hint: Consider making both Rectangle and Square implement a common Shape interface without inheritance between them.
Example 2: LSP Compliant - Payment Processing
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
"""Base class defining the contract for payment processing"""
@abstractmethod
def process_payment(self, amount: float) -> bool:
"""Process payment. Returns True if successful, False otherwise.
Precondition: amount > 0
Postcondition: Returns boolean, never raises exception for valid input
"""
pass
class CreditCardProcessor(PaymentProcessor):
def process_payment(self, amount: float) -> bool:
if amount <= 0:
return False
print(f"Processing ${amount} via Credit Card")
# Simulate payment processing
return True
class PayPalProcessor(PaymentProcessor):
def process_payment(self, amount: float) -> bool:
if amount <= 0:
return False
print(f"Processing ${amount} via PayPal")
# Simulate payment processing
return True
class CryptoProcessor(PaymentProcessor):
def process_payment(self, amount: float) -> bool:
if amount <= 0:
return False
print(f"Processing ${amount} via Cryptocurrency")
# Simulate payment processing
return True
# Client code works with any PaymentProcessor
def checkout(processor: PaymentProcessor, amount: float):
print(f"\nInitiating checkout for ${amount}")
if processor.process_payment(amount):
print("✓ Payment successful!")
else:
print("✗ Payment failed!")
# All processors are substitutable
processors = [
CreditCardProcessor(),
PayPalProcessor(),
CryptoProcessor()
]
for processor in processors:
checkout(processor, 99.99)
Expected Output:
Initiating checkout for $99.99
Processing $99.99 via Credit Card
✓ Payment successful!
Initiating checkout for $99.99
Processing $99.99 via PayPal
✓ Payment successful!
Initiating checkout for $99.99
Processing $99.99 via Cryptocurrency
✓ Payment successful!
Why it’s LSP compliant: All subclasses honor the contract:
- They accept the same preconditions (amount > 0)
- They return the same type (boolean)
- They don’t throw unexpected exceptions
- The
checkoutfunction works identically with any processor
Try it yourself: Add a GiftCardProcessor that violates LSP by throwing an exception when the amount exceeds the card balance. Then refactor it to comply with LSP.
Example 3: LSP Violation - Bird Hierarchy
class Bird:
def fly(self):
return "Flying in the sky"
class Sparrow(Bird):
def fly(self):
return "Sparrow flying fast"
class Penguin(Bird):
"""LSP VIOLATION: Penguins can't fly"""
def fly(self):
raise Exception("Penguins can't fly!")
# Client code expects all birds to fly
def make_bird_fly(bird: Bird):
try:
result = bird.fly()
print(result)
except Exception as e:
print(f"Error: {e}")
birds = [Sparrow(), Penguin()]
for bird in birds:
make_bird_fly(bird)
Expected Output:
Sparrow flying fast
Error: Penguins can't fly!
Why it violates LSP: The client code must handle exceptions for Penguin, even though it works fine with the base Bird class. This forces defensive programming and type-checking.
LSP-Compliant Solution:
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def move(self):
pass
class FlyingBird(Bird):
def move(self):
return self.fly()
def fly(self):
return "Flying in the sky"
class Sparrow(FlyingBird):
def fly(self):
return "Sparrow flying fast"
class Penguin(Bird):
def move(self):
return "Penguin swimming"
# Client code works with any Bird
def make_bird_move(bird: Bird):
print(bird.move())
birds = [Sparrow(), Penguin()]
for bird in birds:
make_bird_move(bird) # Works for all birds!
Expected Output:
Sparrow flying fast
Penguin swimming
Try it yourself: Add an Ostrich class that runs instead of flying. Ensure it fits into the hierarchy without violating LSP.
Java/C++ Notes
Java:
// Use interfaces to define contracts
public interface PaymentProcessor {
boolean processPayment(double amount);
}
public class CreditCardProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
if (amount <= 0) return false;
System.out.println("Processing $" + amount + " via Credit Card");
return true;
}
}
C++:
// Use pure virtual functions for contracts
class PaymentProcessor {
public:
virtual bool processPayment(double amount) = 0;
virtual ~PaymentProcessor() = default;
};
class CreditCardProcessor : public PaymentProcessor {
public:
bool processPayment(double amount) override {
if (amount <= 0) return false;
std::cout << "Processing $" << amount << " via Credit Card\n";
return true;
}
};
Common Mistakes
1. Strengthening Preconditions in Subclasses
Mistake: A subclass requires stricter input than the base class.
class FileReader:
def read(self, filename: str) -> str:
"""Reads any file"""
with open(filename) as f:
return f.read()
class TextFileReader(FileReader):
def read(self, filename: str) -> str:
"""VIOLATION: Only accepts .txt files"""
if not filename.endswith('.txt'):
raise ValueError("Only .txt files allowed")
return super().read(filename)
Why it’s wrong: Code expecting FileReader to handle any file will break with TextFileReader. The subclass strengthened the precondition (must be .txt), violating LSP.
Fix: Either make the base class also require .txt files, or don’t restrict the subclass.
2. Weakening Postconditions in Subclasses
Mistake: A subclass returns less than what the base class promises.
class UserRepository:
def get_user(self, user_id: int) -> dict:
"""Returns user dict with 'id', 'name', 'email'"""
return {'id': user_id, 'name': 'John', 'email': 'john@example.com'}
class CachedUserRepository(UserRepository):
def get_user(self, user_id: int) -> dict:
"""VIOLATION: Might return None if not cached"""
cached = self._check_cache(user_id)
return cached # Could be None!
def _check_cache(self, user_id):
return None # Simulating cache miss
Why it’s wrong: Client code expects a dict but might get None, causing AttributeError when accessing keys.
Fix: Always return a dict, even if empty, or fetch from the database on cache miss.
3. Throwing New Exceptions
Mistake: A subclass throws exceptions the base class doesn’t throw.
class Calculator:
def divide(self, a: float, b: float) -> float:
"""Returns a/b, returns 0 if b is 0"""
return a / b if b != 0 else 0
class StrictCalculator(Calculator):
def divide(self, a: float, b: float) -> float:
"""VIOLATION: Throws exception instead of returning 0"""
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
Why it’s wrong: Code using Calculator doesn’t expect exceptions and will crash with StrictCalculator.
Fix: Match the base class behavior or document the exception in the base class contract.
4. Changing Behavior Semantically
Mistake: A subclass does something fundamentally different than expected.
class Stack:
def __init__(self):
self._items = []
def push(self, item):
"""Adds item to top of stack"""
self._items.append(item)
def pop(self):
"""Removes and returns top item"""
return self._items.pop()
class RandomStack(Stack):
def pop(self):
"""VIOLATION: Returns random item instead of top"""
import random
if not self._items:
raise IndexError("pop from empty stack")
idx = random.randint(0, len(self._items) - 1)
return self._items.pop(idx)
Why it’s wrong: A stack has LIFO semantics. Returning a random item violates the fundamental contract, even if the method signature is the same.
Fix: Don’t call it a Stack. Create a separate RandomCollection class.
5. Refusing to Implement Inherited Methods
Mistake: A subclass makes inherited methods unusable.
class List:
def add(self, item):
pass
def remove(self, item):
pass
class ImmutableList(List):
def add(self, item):
raise NotImplementedError("Cannot modify immutable list")
def remove(self, item):
raise NotImplementedError("Cannot modify immutable list")
Why it’s wrong: Code expecting a List will fail with ImmutableList. The subclass refuses to honor the base class contract.
Fix: Use composition instead of inheritance. Create an ImmutableList that doesn’t inherit from List, or use an interface that only defines read operations.
Interview Tips
1. Recognize LSP Violations in Code Reviews
Interviewers often present code and ask, “What’s wrong here?” Practice identifying these red flags:
- Subclass throws exceptions the base class doesn’t
- Subclass returns null when base class guarantees non-null
- Subclass has empty or no-op implementations of base methods
- Subclass requires type-checking in client code (
if isinstance(obj, SubClass))
Interview Question: “Is this code LSP-compliant? Why or why not?”
Your Answer Framework:
- Identify the base class contract (what it promises)
- Check if the subclass honors all promises
- Consider if client code would break when substituting
- Suggest a fix if violated
2. Explain the Rectangle-Square Problem
This is the most common LSP interview question. Be ready to:
- Explain why Square inheriting from Rectangle violates LSP
- Discuss the difference between mathematical IS-A and behavioral IS-A
- Propose solutions (separate classes, interfaces, immutable objects)
Sample Answer: “While mathematically a square is a rectangle, in code, Square violates LSP if it inherits from Rectangle. Setting width and height independently is part of Rectangle’s contract. Square must keep them equal, breaking that contract. Client code expecting independent dimensions will fail. Better solution: both implement a Shape interface, or use immutable objects where dimensions are set only at construction.”
3. Connect LSP to Real-World Design Decisions
Interviewers want to see you apply LSP to practical scenarios:
- “How would you design a payment processing system?” → Show substitutable processors
- “Design a notification system” → Show substitutable notifiers (email, SMS, push)
- “Implement a caching layer” → Show cache implementations that don’t break the repository contract
Key Point: Always mention that LSP helps you avoid type-checking and makes code extensible.
4. Discuss Trade-offs
Senior interviews expect nuance. Mention:
- When to use composition over inheritance: If you’re forcing LSP compliance, composition might be cleaner
- Interface Segregation: Sometimes LSP violations indicate the base class is too broad
- Performance vs. Purity: In rare cases, slight LSP violations might be acceptable for performance (document them!)
Example: “If strict LSP compliance requires significant refactoring and the violation is isolated and well-documented, I might accept it temporarily. But I’d add a TODO and plan to refactor, because LSP violations tend to spread and cause bugs.”
5. Code the Fix, Not Just Identify the Problem
Interviewers often follow up with “How would you fix this?” Have these patterns ready:
Pattern 1: Extract Interface
# Instead of inheritance, use interface
class Shape(ABC):
@abstractmethod
def area(self): pass
class Rectangle(Shape): ...
class Square(Shape): ...
Pattern 2: Composition
# Instead of inheriting, contain
class ReadOnlyList:
def __init__(self, items):
self._list = list(items) # Composition
def get(self, index):
return self._list[index]
Pattern 3: Split the Hierarchy
# Create more specific base classes
class Bird(ABC): pass
class FlyingBird(Bird): ...
class FlightlessBird(Bird): ...
6. Use Precise Terminology
Impress interviewers with correct terms:
- Preconditions (what must be true before method call)
- Postconditions (what must be true after method call)
- Invariants (what must always be true)
- Behavioral subtyping (LSP’s formal name)
- Contract (the promises a class makes)
Example: “The subclass strengthens the precondition by requiring positive integers when the base class accepts any integer, violating behavioral subtyping.”
7. Relate to Other SOLID Principles
Show you understand how principles interact:
- LSP + Open/Closed: LSP enables OCP by making subclasses safely extensible
- LSP + Interface Segregation: ISP can help fix LSP violations by creating smaller, more focused contracts
- LSP + Dependency Inversion: Both rely on abstractions and contracts
Interview Gold: “LSP violations often indicate we’re violating other SOLID principles too. For example, if a subclass refuses to implement a method, we might need Interface Segregation to split the interface.”
Key Takeaways
-
LSP ensures subclasses are true substitutes: Any code working with a base class must work identically with any subclass without modification or type-checking.
-
Honor the contract: Subclasses cannot strengthen preconditions, weaken postconditions, or throw new exceptions not declared by the base class.
-
Behavioral IS-A vs. mathematical IS-A: Just because something “is a” something else conceptually doesn’t mean inheritance is correct in code (Square/Rectangle problem).
-
Red flags for LSP violations: Empty method implementations, type-checking in client code, unexpected exceptions, and methods that refuse to work are all signs of LSP violations.
-
Fix violations with composition or interfaces: When inheritance doesn’t work, use composition, extract interfaces, or split the hierarchy into more specific base classes.