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 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, then S is a subtype of T."

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:

  1. 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.

  2. 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.

  3. Fragile Hierarchies - Developers add instanceof checks or type-switching to handle "special" subtypes, defeating the purpose of polymorphism entirely.

  4. 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 UnsupportedOperationException for 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 LSPWhen NOT to Use (or Relax)
Designing class hierarchies for polymorphic useUtility/helper inheritance with no polymorphic intent
Building frameworks/libraries consumed by othersSealed/final classes with no extension points
Defining interfaces or abstract contractsComposition-based designs (no inheritance at all)
Any time client code depends on base-type behaviorPrototyping/throwaway code where correctness is secondary
Collection/container hierarchiesPerformance-critical code where virtual dispatch is avoided
Plugin architectures and strategy patternsValue 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

RuleMeaningExample
Preconditions cannot be strengthenedA subclass cannot demand more from callers than the parentParent accepts any positive int → subclass cannot reject values < 10
Postconditions cannot be weakenedA subclass must deliver at least what the parent promisesParent 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

PrincipleRelationship to LSP
SRPSRP reduces reasons to subclass incorrectly; fewer responsibilities = fewer chances to violate LSP
OCPLSP enables OCP - you can only extend via new subtypes if those subtypes are substitutable
ISPISP prevents "fat interfaces" that force subtypes to stub out methods (a common LSP violation source)
DIPDIP makes you depend on abstractions; LSP ensures those abstractions are trustworthy

LSP vs Design by Contract (DbC)

AspectLSPDesign by Contract
OriginLiskov (1987), type theoryMeyer (1986), Eiffel language
FocusSubstitutability of subtypesFormal method specifications
EnforcementDesign-time discipline, testsRuntime assertions, language support
ScopeSubtype relationships onlyAny method on any class
PreconditionsCannot be strengthened in subtypeExplicitly declared with require
PostconditionsCannot be weakened in subtypeExplicitly declared with ensure
RelationshipDbC is a mechanism to enforce LSPLSP is the principle DbC helps verify

Advantages & Disadvantages

AdvantagesDisadvantages
Enables safe polymorphism - subtypes are drop-in replacementsRequires careful upfront design of base types
Reduces instanceof checks and type-switchingCan 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 naturallyHarder to retrofit into existing inheritance trees
Improves framework/library extensibilityMay 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 refactoringOver-application can lead to interface explosion

Constraints & Edge Cases

When Inheritance Seems Natural but Violates LSP

ScenarioWhy It Violates LSPFix
Square extends RectangleCouples width/height, breaks independence postconditionSibling classes under Shape interface
Penguin extends Bird (with fly)Cannot fulfill fly() contractSegregate FlyingBird interface
Stack extends ArrayListStack restricts access patterns (LIFO only)Composition: Stack has-a List
ReadOnlyCollection extends CollectionThrows on mutating methodsSeparate ReadableCollection interface
ElectricCar extends Car (with shiftGear)No gears to shiftSeparate Transmission interface

How to Detect LSP Violations

  1. The Substitution Test - Run all base-class unit tests against every subclass. If any fail, you have a violation.

  2. Look for Degenerate Methods - Methods that throw UnsupportedOperationException, return null where parent returns a value, or are no-ops when the parent does work.

  3. Check for Strengthened Preconditions - Does the subclass reject inputs the parent accepts?

  4. Check for Weakened Postconditions - Does the subclass guarantee less than the parent?

  5. Smell: instanceof in Client Code - If clients need to check the concrete type, the abstraction is leaking.

  6. 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: Square has the same methods as Rectangle (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

  1. "Are there existing class hierarchies in your codebase where you've encountered substitutability issues? I'd like to understand the scale of the problem."

  2. "Do you use Design by Contract tools or rely primarily on tests to verify behavioral contracts?"

  3. "How deep are your inheritance hierarchies typically - do you favor composition over inheritance as a team guideline?"

  4. "When you encounter an LSP violation in production, what's your typical refactoring strategy - do you break backward compatibility or introduce parallel interfaces?"

  5. "Do you have shared base classes across service boundaries, or are contracts defined via interfaces/protocols?"


References & Whitepapers

  1. Liskov, B. (1987). "Data Abstraction and Hierarchy" - Keynote address, OOPSLA '87. The original formulation of the substitution principle.

  2. 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.

  3. Martin, R.C. (2003). Agile Software Development: Principles, Patterns, and Practices - Chapter 10: The Liskov Substitution Principle. Practical OOP perspective.

  4. Meyer, B. (1988). Object-Oriented Software Construction - Design by Contract formalism that provides mechanisms to enforce LSP.

  5. Gamma, E. et al. (1994). Design Patterns: Elements of Reusable Object-Oriented Software - Patterns like Strategy and Template Method that naturally respect LSP.