Chapter 02 · Article 05 of 55
Liskov Substitution Principle (LSP)
"If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2…
Article outline14 sections on this page+
Overview
Original Definition (Barbara Liskov, 1987 Keynote Address):
"If for each object
o1of typeSthere is an objecto2of typeTsuch that for all programsPdefined in terms ofT, the behavior ofPis unchanged wheno1is substituted foro2, thenSis a subtype ofT."
Simplified Explanation:
Objects of a superclass should be replaceable with objects of a subclass without breaking the correctness of the program. If class B extends class A, then anywhere you use A, you should be able to drop in B and everything still works as expected - no surprises, no exceptions, no violated assumptions.
The key insight: inheritance is not about shared code - it's about shared behavior contracts. A subclass must honor every promise the parent class makes to its clients.
LSP is the "L" in SOLID and arguably the most subtle of the five principles. It forces you to think about what inheritance means semantically, not just syntactically.
Problem It Solves
Without LSP, polymorphism becomes a minefield:
-
Broken Polymorphism - Client code written against a base type suddenly fails when a subtype is introduced. The compiler is happy, but runtime behavior is wrong.
-
Unexpected Side Effects in Subclasses - A subclass overrides a method and changes postconditions (e.g.,
setWidth()also changes height). Callers who relied on the original contract are silently broken. -
Fragile Hierarchies - Developers add
instanceofchecks or type-switching to handle "special" subtypes, defeating the purpose of polymorphism entirely. -
Violation of Client Trust - The base class establishes a contract. When a subclass violates it, every client that depends on that contract is at risk.
Symptom checklist - you likely have an LSP violation if:
- You see
instanceof/ type-checking in client code - A subclass throws
UnsupportedOperationExceptionfor inherited methods - Unit tests for the base class fail when run against a subclass
- Documentation says "don't call X on this subtype"
When to Use / When NOT to Use
| When to Use LSP | When NOT to Use (or Relax) |
|---|---|
| Designing class hierarchies for polymorphic use | Utility/helper inheritance with no polymorphic intent |
| Building frameworks/libraries consumed by others | Sealed/final classes with no extension points |
| Defining interfaces or abstract contracts | Composition-based designs (no inheritance at all) |
| Any time client code depends on base-type behavior | Prototyping/throwaway code where correctness is secondary |
| Collection/container hierarchies | Performance-critical code where virtual dispatch is avoided |
| Plugin architectures and strategy patterns | Value types with structural equality (use composition instead) |
Key Concepts & Theory
Behavioral Subtyping
LSP formalizes behavioral subtyping: a subtype must be substitutable not just structurally (same method signatures) but behaviorally (same observable guarantees). This goes beyond what a compiler can check.
Preconditions and Postconditions
| Rule | Meaning | Example |
|---|---|---|
| Preconditions cannot be strengthened | A subclass cannot demand more from callers than the parent | Parent accepts any positive int → subclass cannot reject values < 10 |
| Postconditions cannot be weakened | A subclass must deliver at least what the parent promises | Parent guarantees sorted output → subclass cannot return unsorted |
Invariants
Class invariants (properties that are always true) must be preserved by subclasses. If Rectangle guarantees width and height are independent, Square violates this invariant by coupling them.
Covariance and Contravariance
- Return types should be covariant (subtype can return a more specific type)
- Parameter types should be contravariant (subtype can accept a more general type)
- Reversing these breaks substitutability.
The Rectangle-Square Problem
The classic LSP thought experiment: mathematically, a square is-a rectangle. But in OOP, if Rectangle has independent setWidth() and setHeight(), then Square (which must keep width == height) cannot be a behavioral subtype of Rectangle. The "is-a" relationship in the real world does not always map to "is-a-subtype-of" in code.
ASCII Class Diagram
Violation: Square extends Rectangle
┌─────────────────────┐
│ Rectangle │
├─────────────────────┤
│ - width: int │
│ - height: int │
├─────────────────────┤
│ + setWidth(w) │ ← contract: only width changes
│ + setHeight(h) │ ← contract: only height changes
│ + getArea(): int │
└─────────┬───────────┘
│ extends
▼
┌─────────────────────┐
│ Square │
├─────────────────────┤
│ + setWidth(w) │ ← VIOLATION: also sets height = w
│ + setHeight(h) │ ← VIOLATION: also sets width = h
└─────────────────────┘
Client code:
func resize(r: Rectangle) {
r.setWidth(5)
r.setHeight(10)
assert r.getArea() == 50 // FAILS for Square! Area = 100
}
Correct Design: Separate Shape Hierarchy
┌─────────────────────┐
│ «interface» │
│ Shape │
├─────────────────────┤
│ + getArea(): int │
│ + getPerimeter() │
└────────┬────────────┘
│ implements
┌────┴─────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│Rectangle │ │ Square │
├──────────┤ ├──────────┤
│ - width │ │ - side │
│ - height │ ├──────────┤
├──────────┤ │+getArea()│
│+getArea()│ └──────────┘
└──────────┘
No inheritance between Rectangle and Square.
Each fulfills the Shape contract independently.
Pseudocode Implementation
Example 1: Rectangle / Square
BAD - Square extends Rectangle
class Rectangle:
protected width: int
protected height: int
method setWidth(w: int):
this.width = w // postcondition: only width changes
method setHeight(h: int):
this.height = h // postcondition: only height changes
method getArea(): int:
return this.width * this.height
class Square extends Rectangle:
method setWidth(w: int):
this.width = w
this.height = w // VIOLATES postcondition of setWidth
method setHeight(h: int):
this.width = h // VIOLATES postcondition of setHeight
this.height = h
// Client code - works for Rectangle, BREAKS for Square
function testArea(rect: Rectangle):
rect.setWidth(5)
rect.setHeight(10)
assert rect.getArea() == 50 // Square gives 100 - LSP VIOLATED
GOOD - Separate Shape Hierarchy
interface Shape:
method getArea(): int
method getPerimeter(): int
class Rectangle implements Shape:
private width: int
private height: int
constructor(w: int, h: int):
this.width = w
this.height = h
method getArea(): int:
return this.width * this.height
method getPerimeter(): int:
return 2 * (this.width + this.height)
class Square implements Shape:
private side: int
constructor(s: int):
this.side = s
method getArea(): int:
return this.side * this.side
method getPerimeter(): int:
return 4 * this.side
// Client code - works for ANY Shape
function printShapeInfo(shape: Shape):
print("Area: " + shape.getArea())
print("Perimeter: " + shape.getPerimeter())
// No assumptions about width/height independence - LSP preserved
Example 2: Bird / Penguin (The fly() Problem)
BAD - Penguin extends Bird with fly()
class Bird:
method fly():
// moves through air
this.altitude += 10
method eat():
// consumes food
class Penguin extends Bird:
method fly():
throw UnsupportedOperationException("Penguins can't fly!")
// Clients calling bird.fly() will crash
// Client code
function migrateFlock(birds: List<Bird>):
for bird in birds:
bird.fly() // Explodes when a Penguin is in the list
GOOD - Segregated Interfaces
interface Bird:
method eat()
method move()
interface FlyingBird extends Bird:
method fly()
class Sparrow implements FlyingBird:
method eat(): // ...
method move(): this.fly()
method fly():
this.altitude += 10
class Penguin implements Bird:
method eat(): // ...
method move():
this.swim() // penguins move by swimming
method swim():
this.depth += 5
// Client code - type-safe, no surprises
function migrateFlock(birds: List<FlyingBird>):
for bird in birds:
bird.fly() // Only FlyingBirds are in this list - safe
function feedAll(birds: List<Bird>):
for bird in birds:
bird.eat() // Works for ALL birds - LSP preserved
Real-World Examples
1. Collection Hierarchies (Java Collections Framework)
java.util.Collections.unmodifiableList() returns a List that throws UnsupportedOperationException on add() / remove(). This is a well-known LSP violation in the JDK - client code expecting a mutable List breaks. A cleaner design would use separate ReadableList and MutableList interfaces (which Kotlin's standard library does).
2. Stream Abstractions (I/O)
An InputStream promises read() returns data. A subclass ClosedInputStream that always throws IOException violates LSP. Correct design: use state checks (isOpen()) as part of the documented contract, or separate ReadableStream from Stream.
3. Database Connections
A Connection interface promises executeQuery(). A ReadOnlyConnection subtype that throws on INSERT/UPDATE statements violates LSP if clients expect full DML support. Better: separate ReadConnection and WriteConnection interfaces, composed into ReadWriteConnection.
4. UI Components
A Button base class promises onClick() triggers an action. A DisabledButton subclass that silently swallows clicks without feedback violates the behavioral contract. Better: model enabled/disabled as state, not as a subtype.
Comparison
LSP vs Other SOLID Principles
| Principle | Relationship to LSP |
|---|---|
| SRP | SRP reduces reasons to subclass incorrectly; fewer responsibilities = fewer chances to violate LSP |
| OCP | LSP enables OCP - you can only extend via new subtypes if those subtypes are substitutable |
| ISP | ISP prevents "fat interfaces" that force subtypes to stub out methods (a common LSP violation source) |
| DIP | DIP makes you depend on abstractions; LSP ensures those abstractions are trustworthy |
LSP vs Design by Contract (DbC)
| Aspect | LSP | Design by Contract |
|---|---|---|
| Origin | Liskov (1987), type theory | Meyer (1986), Eiffel language |
| Focus | Substitutability of subtypes | Formal method specifications |
| Enforcement | Design-time discipline, tests | Runtime assertions, language support |
| Scope | Subtype relationships only | Any method on any class |
| Preconditions | Cannot be strengthened in subtype | Explicitly declared with require |
| Postconditions | Cannot be weakened in subtype | Explicitly declared with ensure |
| Relationship | DbC is a mechanism to enforce LSP | LSP is the principle DbC helps verify |
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Enables safe polymorphism - subtypes are drop-in replacements | Requires careful upfront design of base types |
Reduces instanceof checks and type-switching | Can lead to deeper/wider hierarchies (more interfaces) |
| Makes code more predictable and testable | "Is-a" intuition from the real world often misleads |
| Supports Open/Closed Principle naturally | Harder to retrofit into existing inheritance trees |
| Improves framework/library extensibility | May require composition over inheritance refactoring |
| Catches design errors early (at design time, not runtime) | Behavioral contracts are hard to express in most type systems |
| Enables confident refactoring | Over-application can lead to interface explosion |
Constraints & Edge Cases
When Inheritance Seems Natural but Violates LSP
| Scenario | Why It Violates LSP | Fix |
|---|---|---|
| Square extends Rectangle | Couples width/height, breaks independence postcondition | Sibling classes under Shape interface |
| Penguin extends Bird (with fly) | Cannot fulfill fly() contract | Segregate FlyingBird interface |
| Stack extends ArrayList | Stack restricts access patterns (LIFO only) | Composition: Stack has-a List |
| ReadOnlyCollection extends Collection | Throws on mutating methods | Separate ReadableCollection interface |
| ElectricCar extends Car (with shiftGear) | No gears to shift | Separate Transmission interface |
How to Detect LSP Violations
-
The Substitution Test - Run all base-class unit tests against every subclass. If any fail, you have a violation.
-
Look for Degenerate Methods - Methods that throw
UnsupportedOperationException, returnnullwhere parent returns a value, or are no-ops when the parent does work. -
Check for Strengthened Preconditions - Does the subclass reject inputs the parent accepts?
-
Check for Weakened Postconditions - Does the subclass guarantee less than the parent?
-
Smell: instanceof in Client Code - If clients need to check the concrete type, the abstraction is leaking.
-
Smell: "This subclass is special" - Any documentation that warns callers about subtype-specific behavior.
Interview Follow-ups
Q1: Can you explain LSP with a real code example you've encountered?
Model Answer:
"In a payment processing system I worked on, we had a PaymentGateway base class with a refund() method. We added a PrepaidCardGateway subclass where refunds weren't supported - it threw UnsupportedOperationException. This violated LSP because client code iterating over gateways to process refunds would crash. We fixed it by introducing a RefundableGateway interface. Only gateways that genuinely supported refunds implemented it. The batch refund processor accepted List<RefundableGateway> instead of List<PaymentGateway>, making the type system enforce the contract."
Q2: How does LSP relate to the Open/Closed Principle?
Model Answer: "OCP says classes should be open for extension but closed for modification. LSP is what makes OCP safe. If you extend a system by adding a new subtype, OCP is only preserved if that subtype is substitutable (LSP). Without LSP, adding a new subclass forces you to modify existing client code to handle the new type's quirks - which violates OCP. They're complementary: LSP is the contract that makes OCP's extension mechanism trustworthy."
Q3: How would you refactor a codebase that violates LSP?
Model Answer:
"First, I identify the violation by running base-class tests against subtypes and looking for instanceof checks or exception-throwing overrides. Then I determine whether the relationship is truly 'is-a' or actually 'has-a'. Common refactoring steps: (1) Extract the shared behavior into a narrower interface that all subtypes can honestly fulfill. (2) Move subtype-specific behavior into separate interfaces (ISP). (3) Replace inheritance with composition where the subtype needs only some parent behavior. (4) Update client code to depend on the appropriate narrow interface. I validate by confirming all interface tests pass for all implementations."
Q4: What's the difference between syntactic and behavioral subtyping?
Hints:
- Syntactic = same method signatures (compiler checks this)
- Behavioral = same observable guarantees (compiler cannot check this)
- A class can be a syntactic subtype but not a behavioral subtype
- LSP is about behavioral subtyping
- Example:
Squarehas the same methods asRectangle(syntactic ) but different behavior (behavioral X)
Q5: How do you handle LSP in languages without interfaces (e.g., Python, JavaScript)?
Hints:
- Duck typing means LSP violations are even more dangerous (no compiler help)
- Use Abstract Base Classes (ABCs) in Python to formalize contracts
- Write comprehensive tests that serve as the "contract specification"
- Document preconditions/postconditions explicitly in docstrings
- Use Protocol classes (Python 3.8+) for structural subtyping
- In JS/TS, use TypeScript interfaces or JSDoc contracts
Counter Questions to Ask Interviewer
-
"Are there existing class hierarchies in your codebase where you've encountered substitutability issues? I'd like to understand the scale of the problem."
-
"Do you use Design by Contract tools or rely primarily on tests to verify behavioral contracts?"
-
"How deep are your inheritance hierarchies typically - do you favor composition over inheritance as a team guideline?"
-
"When you encounter an LSP violation in production, what's your typical refactoring strategy - do you break backward compatibility or introduce parallel interfaces?"
-
"Do you have shared base classes across service boundaries, or are contracts defined via interfaces/protocols?"
References & Whitepapers
-
Liskov, B. (1987). "Data Abstraction and Hierarchy" - Keynote address, OOPSLA '87. The original formulation of the substitution principle.
-
Liskov, B. & Wing, J. (1994). "A Behavioral Notion of Subtyping" - ACM Transactions on Programming Languages and Systems (TOPLAS), 16(6), pp. 1811-1841. The formal treatment defining behavioral subtyping with pre/postconditions and invariants.
-
Martin, R.C. (2003). Agile Software Development: Principles, Patterns, and Practices - Chapter 10: The Liskov Substitution Principle. Practical OOP perspective.
-
Meyer, B. (1988). Object-Oriented Software Construction - Design by Contract formalism that provides mechanisms to enforce LSP.
-
Gamma, E. et al. (1994). Design Patterns: Elements of Reusable Object-Oriented Software - Patterns like Strategy and Template Method that naturally respect LSP.