Skip to main content

L — Liskov Substitution Principle

Editorial

Introduction

Imagine you hire a substitute teacher for a math class. The school expects that the substitute can do everything the regular teacher does — explain concepts, answer questions, grade quizzes. If the substitute shows up and says "I do not grade quizzes" or "I only teach history," the substitution fails. The class breaks down, and students suffer.

In software, the same problem occurs with inheritance. When you create a subclass and claim it is a specialized version of the parent, every piece of code that uses the parent should work identically with the subclass. If it does not — if the subclass silently changes behavior, throws unexpected errors, or ignores parts of the parent's contract — your inheritance hierarchy is broken.

The Liskov Substitution Principle (LSP) is the "L" in SOLID. It was formulated by computer scientist Barbara Liskov in 1987 and states:

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

In simpler terms: a subclass must be usable anywhere the parent class is used, without the calling code knowing or caring that it is dealing with a subclass.

LSP is the principle that validates your inheritance hierarchies. SRP tells you what a class should do. OCP tells you how to extend it. LSP tells you whether your extensions actually work correctly when substituted. If your subclass violates LSP, your OCP-based designs will fail at runtime even though they look correct at compile time.

Liskov Substitution Principle. A resize(r) routine sets width=10 then height=5 and expects area to be 50. A Rectangle has independent sides so area() returns 50; a Square that extends Rectangle overrides the width setter to also set height, so after the two calls both sides are 5 and area() returns 25 — silently breaking the caller. The four contract rules: preconditions cannot be strengthened, postconditions cannot be weakened, invariants must be preserved, and no surprising exceptions. The fix gives Rectangle and Square a common Shape interface with area() (or plain composition) so neither inherits the other's setters. Detect violations with the Surprise Test and watch for subtypes that throw 'not supported' or do less than the base.

What LSP Really Requires

LSP is deeper than "a subclass should have the parent's methods." It defines behavioral subtyping — the subclass must preserve the parent's behavior guarantees. Specifically:

1. Preconditions Cannot Be Strengthened

If the parent method accepts any positive number, the subclass cannot restrict it to only accept numbers above 10. The subclass must accept at least what the parent accepts.

2. Postconditions Cannot Be Weakened

If the parent method guarantees it returns a non-negative number, the subclass cannot return negative numbers. The subclass must provide at least the guarantees the parent provides.

3. Invariants Must Be Preserved

If the parent class guarantees that balance is always non-negative, every subclass must maintain this invariant. A subclass that allows negative balances violates LSP.

4. No Surprise Exceptions

If the parent does not throw exceptions for valid inputs, the subclass should not throw unexpected exceptions for the same inputs either.

These rules might sound abstract, but they have devastating practical consequences when violated — as we will see with two examples.

The Classic Violation — Rectangle and Square

The most famous LSP violation in computer science involves rectangles and squares. In mathematics, every square is a rectangle — a rectangle where width equals height. So it seems natural to model Square as a subclass of Rectangle. But this leads to a subtle and dangerous bug.

# VIOLATION: Square extends Rectangle but breaks its behavior

class Rectangle:
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height

    @property
    def width(self) -> float:
        return self._width

    @width.setter
    def width(self, value: float) -> None:
        self._width = value

    @property
    def height(self) -> float:
        return self._height

    @height.setter
    def height(self, value: float) -> None:
        self._height = value

    def area(self) -> float:
        return self._width * self._height


class Square(Rectangle):
    """A square IS-A rectangle (mathematically), right?"""

    def __init__(self, side: float):
        super().__init__(side, side)

    @Rectangle.width.setter
    def width(self, value: float) -> None:
        # Must keep width == height for a square
        self._width = value
        self._height = value  # Side effect!

    @Rectangle.height.setter
    def height(self, value: float) -> None:
        # Must keep width == height for a square
        self._width = value  # Side effect!
        self._height = value


def resize_rectangle(rect: Rectangle) -> None:
    """This function expects Rectangle behavior."""
    rect.width = 10
    rect.height = 5
    # For a Rectangle: area should be 10 * 5 = 50
    expected_area = 50
    actual_area = rect.area()
    print(f"Expected: {expected_area}, Got: {actual_area}")
    assert actual_area == expected_area, "LSP VIOLATED!"


# Works fine with Rectangle
resize_rectangle(Rectangle(1, 1))  # Expected: 50, Got: 50

# BREAKS with Square — setting height=5 also set width=5
resize_rectangle(Square(1))  # Expected: 50, Got: 25 — VIOLATION!
// VIOLATION: Square extends Rectangle but breaks its behavior

class Rectangle {
    protected double width;
    protected double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public void setWidth(double width) {
        this.width = width;
    }

    public void setHeight(double height) {
        this.height = height;
    }

    public double getWidth() { return width; }
    public double getHeight() { return height; }

    public double area() {
        return width * height;
    }
}

class Square extends Rectangle {
    public Square(double side) {
        super(side, side);
    }

    @Override
    public void setWidth(double width) {
        this.width = width;
        this.height = width;  // Side effect!
    }

    @Override
    public void setHeight(double height) {
        this.width = height;  // Side effect!
        this.height = height;
    }
}

public class Main {
    static void resizeRectangle(Rectangle rect) {
        rect.setWidth(10);
        rect.setHeight(5);
        // For a Rectangle: area should be 10 * 5 = 50
        double expected = 50;
        double actual = rect.area();
        System.out.printf("Expected: %.0f, Got: %.0f%n",
                          expected, actual);
        assert actual == expected : "LSP VIOLATED!";
    }

    public static void main(String[] args) {
        resizeRectangle(new Rectangle(1, 1));  // 50 — correct
        resizeRectangle(new Square(1));         // 25 — VIOLATION!
    }
}

What went wrong? The Rectangle class has an implicit behavioral contract: setting width does NOT affect height, and vice versa. They are independent dimensions. But Square must keep width equal to height to maintain its invariant — so setting one changes both.

The resize_rectangle() function trusts the Rectangle contract. It sets width to 10, then height to 5, expecting the area to be 50. But when it receives a Square, setting height = 5 silently also sets width = 5, producing area = 25.

The Square is not substitutable for Rectangle. It changes the behavior the caller depends on. This is an LSP violation.

The lesson: An "is-a" relationship in the real world does not always translate to a valid inheritance relationship in code. A square is mathematically a rectangle, but a mutable Square class cannot safely extend a mutable Rectangle class because they have incompatible behavioral contracts.

A Real-World Violation — The Read-Only File Storage

The Rectangle/Square example is classic, but let us see a more realistic LSP violation — one you might encounter in a production codebase.

A file storage system defines an interface for persisting and retrieving documents. A team needs to add a read-only archive store, and they decide to extend the same interface.

# VIOLATION: ReadOnlyArchive extends FileStorage but breaks write

from abc import ABC, abstractmethod


class FileStorage(ABC):
    """Contract: stores AND retrieves files.
    Any implementation must support both operations."""

    @abstractmethod
    def save(self, filename: str, content: bytes) -> None:
        """Save a file. Must succeed for valid inputs."""
        pass

    @abstractmethod
    def load(self, filename: str) -> bytes:
        """Load a file by name."""
        pass

    @abstractmethod
    def delete(self, filename: str) -> None:
        """Delete a file by name."""
        pass


class LocalDiskStorage(FileStorage):
    """Stores files on the local filesystem. Full read-write."""

    def __init__(self):
        self._files: dict[str, bytes] = {}

    def save(self, filename: str, content: bytes) -> None:
        self._files[filename] = content
        print(f"Saved {filename} to disk")

    def load(self, filename: str) -> bytes:
        return self._files[filename]

    def delete(self, filename: str) -> None:
        del self._files[filename]
        print(f"Deleted {filename}")


class ReadOnlyArchive(FileStorage):
    """Archive storage — read only. Cannot write or delete."""

    def __init__(self, archived_files: dict[str, bytes]):
        self._archived = archived_files

    def save(self, filename: str, content: bytes) -> None:
        # Cannot save to a read-only archive!
        raise PermissionError("Archive is read-only")

    def load(self, filename: str) -> bytes:
        return self._archived[filename]

    def delete(self, filename: str) -> None:
        # Cannot delete from a read-only archive!
        raise PermissionError("Archive is read-only")


def backup_documents(storage: FileStorage,
                     documents: dict[str, bytes]) -> None:
    """Backs up documents to any FileStorage.
    Trusts that save() works as advertised."""
    for name, content in documents.items():
        storage.save(name, content)  # Crashes with ReadOnlyArchive!
    print(f"Backed up {len(documents)} documents")


# Works with LocalDiskStorage
local = LocalDiskStorage()
backup_documents(local, {"report.pdf": b"data"})

# CRASHES with ReadOnlyArchive — LSP VIOLATION
archive = ReadOnlyArchive({"old_report.pdf": b"archived"})
backup_documents(archive, {"report.pdf": b"data"})  # PermissionError!

The backup_documents() function accepts any FileStorage. It trusts the contract: save() stores files. But ReadOnlyArchive throws an exception on save() — violating the parent's behavioral guarantee.

This is not a bug in backup_documents(). It correctly uses the FileStorage interface. The bug is in the design: ReadOnlyArchive should never have implemented FileStorage because it cannot fulfill the full contract.

This violation would be caught by the Interface Segregation Principle (ISP), which we will study two tutorials from now. The fix is to split FileStorage into Readable and Writable interfaces, so ReadOnlyArchive only implements Readable.

Fixing LSP Violations

There are three general approaches to fixing LSP violations:

# FIX 1: Use composition instead of inheritance
# Rectangle and Square are separate, unrelated classes

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Square:
    """Not a subclass of Rectangle — independent class."""

    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side * self.side


# FIX 2: Use a common interface with correct contracts

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass


class RectangleShape(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class SquareShape(Shape):
    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side * self.side


# Both can be used wherever Shape is expected — LSP satisfied
def print_area(shape: Shape) -> None:
    print(f"Area: {shape.area()}")

print_area(RectangleShape(10, 5))  # Area: 50
print_area(SquareShape(7))         # Area: 49
// FIX: Use a common interface with correct contracts

interface Shape {
    double area();
}

class RectangleShape implements Shape {
    private final double width;
    private final double height;

    public RectangleShape(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }

    public double getWidth() { return width; }
    public double getHeight() { return height; }
}

class SquareShape implements Shape {
    private final double side;

    public SquareShape(double side) {
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }

    public double getSide() { return side; }
}

public class Main {
    static void printArea(Shape shape) {
        System.out.printf("Area: %.1f%n", shape.area());
    }

    public static void main(String[] args) {
        printArea(new RectangleShape(10, 5));  // Area: 50.0
        printArea(new SquareShape(7));          // Area: 49.0
    }
}

The three common fixes for LSP violations:

  1. Break the inheritance relationship: If the subclass cannot truly substitute for the parent, they should not have an inheritance relationship. Make them independent classes (possibly sharing a common interface with a narrower contract).

  2. Use a more abstract common interface: Instead of Square extends Rectangle, have both implement a Shape interface. The interface defines only what is truly common (area()), not operations that differ (setWidth() / setHeight()).

  3. Make the parent immutable: If Rectangle is immutable (no setters), the Square problem disappears because there is no way to independently change width and height. An immutable Square(5) correctly reports area() = 25, just like an immutable Rectangle(5, 5) does.

Visualization

Substitute a subtype and watch the contract. The caller's resize() sets width then height and expects area = width × height. Pick Rectangle (independent sides) and it holds; pick Square (whose width setter also sets height) and setting width silently changes height — the area comes out wrong and LSP is violated. Change the target width and height and see the actual shape (solid) diverge from the expected one (dashed).

How to Detect LSP Violations

Use these practical checks to spot LSP violations in your code:

Check 1: The "Surprise" Test

Imagine you are a developer using a function that accepts the parent type. Would you be surprised if the function broke when you passed a particular subclass? If yes — that subclass likely violates LSP.

Check 2: Subclass Methods That Throw "Not Supported"

Any subclass method that throws NotImplementedError, UnsupportedOperationException, or returns a hardcoded dummy value (e.g., return None where the parent promises a real value) is a strong LSP violation signal.

Check 3: isinstance / instanceof Checks in Client Code

If code that uses the parent type needs to check if isinstance(obj, SpecificSubclass) to handle special cases, the subclass is not truly substitutable. The calling code should not need to know the concrete type.

Check 4: Overridden Methods That Do Less

If a subclass overrides a method to do nothing (empty body) or to do less than the parent, it is likely violating LSP. The caller expects the full behavior.

Common Mistakes

Mistake 1: Modeling Real-World IS-A Relationships Directly as Inheritance

The trap: "An ostrich IS-A bird, so Ostrich extends Bird." But if Bird has a fly() method, Ostrich cannot fulfill it. Real-world taxonomy does not equal software type hierarchy.

The fix: Model based on behavioral compatibility, not real-world categories. An ostrich and a penguin implement Walkable but not Flyable.

Mistake 2: "Just Throw an Exception in Methods I Cannot Implement"

The trap: ReadOnlyArchive.save() throws PermissionError. "The interface requires save(), so I have to implement it somehow."

The fix: If you cannot fulfill the contract, you should not implement the interface. Split the interface (ISP — next tutorial) or use a different abstraction.

Mistake 3: Ignoring LSP Because "Our Code Checks the Type Anyway"

The trap: "We always check if isinstance(storage, ReadOnlyArchive) before calling save(), so it's fine."

The fix: If you need type checks, you are not getting the benefit of polymorphism at all. You have reintroduced the if/elif branching that OCP was supposed to eliminate.

LSP and Other SOLID Principles

LSP connects directly to every other SOLID principle:

  • LSP validates OCP: OCP says extend behavior through new implementations. LSP says those implementations must actually be valid substitutes. Without LSP, OCP-based designs fail at runtime.

  • LSP violations often signal ISP violations: If a subclass cannot implement all methods of an interface, the interface is too broad. The Interface Segregation Principle (next tutorial) provides the fix — split the interface so subtypes only implement what they can genuinely fulfill.

  • LSP connects to DIP: When high-level modules depend on abstractions (DIP), LSP ensures any concrete implementation of that abstraction works correctly. DIP creates the dependency on abstractions; LSP ensures the abstractions are trustworthy.

  • LSP builds on S3's polymorphism: Runtime polymorphism (S3) enables substitution. LSP constrains that substitution — not every subclass is a valid substitute, only those that preserve the parent's behavioral contract.

You will see LSP concerns arise frequently in the Design Problems course. For example, when designing the Parking Lot system, if HandicappedSpot extends ParkingSpot, the HandicappedSpot must support all operations a generic ParkingSpot supports (park a vehicle, check availability) — it cannot reject vehicles that a regular spot would accept.

Key Takeaways

  • The Liskov Substitution Principle says subtypes must be fully substitutable for their base types — any code using the parent type should work correctly with any subclass
  • LSP defines behavioral subtyping: preconditions cannot be strengthened, postconditions cannot be weakened, invariants must be preserved
  • The classic Rectangle/Square example shows that mathematical IS-A relationships do not always translate to valid inheritance in code
  • Real-world violations include subclass methods that throw "not supported" exceptions, do nothing, or change the parent's behavioral guarantees
  • Fix LSP violations by breaking inheritance, using narrower interfaces, or making parent types immutable
  • When you see an LSP violation where a subclass cannot implement all parent methods, it often signals an ISP violation — which we will fix in the next tutorial: Interface Segregation Principle