intermediate60 minutesLección 4 de 8

Liskov Substitution Principle

Learn the Liskov Substitution Principle (LSP): subtypes must be substitutable for their base types without altering correctness

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

Introduced by Barbara Liskov in 1987, the Liskov Substitution Principle is the third SOLID principle. It defines a strong behavioral contract for inheritance: subclasses must honor the expectations set by their parent classes.

The Essence of LSP

If you have a function that works with a base class, it should work correctly with any of its subclasses — without knowing which subclass it is.

python
def process_shape(shape: Shape) -> None: print(f"Area: {shape.area()}") print(f"Perimeter: {shape.perimeter()}")

This function should work correctly for Rectangle, Circle, Triangle — any subclass of Shape. If a subclass breaks this expectation, it violates LSP.

BEFORE: The Classic Rectangle-Square Problem

This is the most famous LSP violation:

python
class Rectangle: def __init__(self, width: float, height: float): self.width = width self.height = height def set_width(self, width: float) -> None: self.width = width def set_height(self, height: float) -> None: self.height = height def area(self) -> float: return self.width * self.height class Square(Rectangle): def __init__(self, side: float): super().__init__(side, side) def set_width(self, width: float) -> None: self.width = width self.height = width # Maintains square invariant def set_height(self, height: float) -> None: self.width = height self.height = height # Maintains square invariant

At first glance this seems reasonable. A square is a rectangle, right? But watch what happens:

python
def resize_rectangle(rect: Rectangle, factor: float) -> None: """Doubles width, keeps height — should work for any Rectangle.""" original_height = rect.height rect.set_width(rect.width * factor) assert rect.height == original_height, "Height should not change!" print(f"Area changed from {rect.area() / factor:.2f} to {rect.area():.2f}") r = Rectangle(10, 5) resize_rectangle(r, 2) # Works fine — width=20, height=5 s = Square(5) resize_rectangle(s, 2) # BREAKS! height also becomes 10, assertion fails
⚠️Warning

Square violates LSP because it changes the invariant that set_width only affects width. Code written for Rectangle assumes set_width leaves height unchanged.

100%

The Root Cause

Square weakens the postcondition of set_width and set_height. The parent class guarantees:

MethodRectangle PostconditionSquare PostconditionLSP?
set_width(w)width == wwidth == w AND height == wViolation — stronger constraint
set_height(h)height == hheight == h AND width == hViolation — stronger constraint

AFTER: LSP-Compliant Design

Don't force inheritance where it doesn't fit. Use a common abstraction instead:

python
from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: pass @abstractmethod def perimeter(self) -> float: pass class Rectangle(Shape): def __init__(self, width: float, height: float): self.width = width self.height = height def area(self) -> float: return self.width * self.height def perimeter(self) -> float: return 2 * (self.width + self.height) class Square(Shape): def __init__(self, side: float): self.side = side def area(self) -> float: return self.side ** 2 def perimeter(self) -> float: return 4 * self.side def print_shape_info(shape: Shape) -> None: print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}") print_shape_info(Rectangle(10, 5)) print_shape_info(Square(5))
Success

Both Rectangle and Square are now Shape subclasses. Neither violates LSP because Shape doesn't have set_width/set_height methods that would need different behavior.

Formal LSP Definition

The principle defines behavioral subtyping with these constraints:

ConstraintMeaningExample Violation
Preconditions cannot be strengthenedChild methods shouldn't add restrictionsParent accepts all ints, child rejects negatives
Postconditions cannot be weakenedChild must guarantee at least what parent doesParent returns non-null, child returns null
Invariants must be preservedClass invariants must hold in childParent's immutable field is mutable in child
History constraintChild shouldn't allow state changes parent forbidsParent is read-only, child allows writes

Example 2: Collection with Restrictions

BEFORE: LSP Violation

python
class CustomCollection: def add(self, item: str) -> None: self._items.append(item) def remove(self, item: str) -> None: self._items.remove(item) def size(self) -> int: return len(self._items) class ReadOnlyCollection(CustomCollection): def add(self, item: str) -> None: raise NotImplementedError("Read-only collection") def remove(self, item: str) -> None: raise NotImplementedError("Read-only collection")
python
def process_collection(coll: CustomCollection) -> None: coll.add("test") assert coll.size() == 1 print("Item added successfully") process_collection(CustomCollection()) # Works process_collection(ReadOnlyCollection()) # Raises NotImplementedError!
⚠️Warning

ReadOnlyCollection violates LSP by strengthening preconditions and weakening postconditions. Code expecting a CustomCollection that supports add will break with ReadOnlyCollection.

AFTER: LSP-Compliant

python
from abc import ABC, abstractmethod class ReadableCollection(ABC): @abstractmethod def size(self) -> int: pass @abstractmethod def contains(self, item: str) -> bool: pass @abstractmethod def items(self) -> list[str]: pass class WritableCollection(ReadableCollection): @abstractmethod def add(self, item: str) -> None: pass @abstractmethod def remove(self, item: str) -> None: pass class SimpleCollection(WritableCollection): def __init__(self): self._items: list[str] = [] def add(self, item: str) -> None: self._items.append(item) def remove(self, item: str) -> None: self._items.remove(item) def size(self) -> int: return len(self._items) def contains(self, item: str) -> bool: return item in self._items def items(self) -> list[str]: return list(self._items) class ReadOnlyCollection(ReadableCollection): def __init__(self, items: list[str]): self._items = list(items) def size(self) -> int: return len(self._items) def contains(self, item: str) -> bool: return item in self._items def items(self) -> list[str]: return list(self._items) def show_collection_info(coll: ReadableCollection) -> None: print(f"Collection has {coll.size()} items") print(f"Items: {coll.items()}") def add_to_collection(coll: WritableCollection, item: str) -> None: coll.add(item) print(f"Added {item}, now {coll.size()} items") show_collection_info(SimpleCollection()) show_collection_info(ReadOnlyCollection(["a", "b"])) add_to_collection(SimpleCollection(), "test")
100%

Example 3: Account with Withdrawal Restrictions

BEFORE: LSP Violation

python
class BankAccount: def __init__(self, balance: float = 0): self.balance = balance def withdraw(self, amount: float) -> None: if amount <= 0: raise ValueError("Amount must be positive") if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount class FixedDepositAccount(BankAccount): def __init__(self, balance: float, maturity_date: str): super().__init__(balance) self.maturity_date = maturity_date self._matured = False def withdraw(self, amount: float) -> None: if not self._matured: raise ValueError("Cannot withdraw before maturity date") super().withdraw(amount)
python
def process_withdrawal(account: BankAccount, amount: float) -> None: initial = account.balance account.withdraw(amount) assert account.balance == initial - amount print(f"Withdrew {amount}, balance: {account.balance}") process_withdrawal(BankAccount(1000), 200) # Works process_withdrawal(FixedDepositAccount(5000, "2026-01-01"), 500) # Fails!

AFTER: LSP-Compliant

python
from abc import ABC, abstractmethod class Account(ABC): def __init__(self, balance: float = 0): self._balance = balance @property def balance(self) -> float: return self._balance @abstractmethod def can_withdraw(self) -> bool: pass def deposit(self, amount: float) -> None: if amount <= 0: raise ValueError("Amount must be positive") self._balance += amount class WithdrawableAccount(Account): def withdraw(self, amount: float) -> None: if amount <= 0: raise ValueError("Amount must be positive") if amount > self._balance: raise ValueError("Insufficient funds") self._balance -= amount def can_withdraw(self) -> bool: return self._balance > 0 class CheckingAccount(WithdrawableAccount): pass class SavingsAccount(WithdrawableAccount): withdrawal_limit = 6 def withdraw(self, amount: float) -> None: if amount > self._balance * 0.8: raise ValueError("Exceeds withdrawal limit") super().withdraw(amount) class FixedDepositAccount(Account): def __init__(self, balance: float, maturity_date: str): super().__init__(balance) self.maturity_date = maturity_date def can_withdraw(self) -> bool: from datetime import date return date.today() >= date.fromisoformat(self.maturity_date) def withdraw(self, amount: float) -> None: if not self.can_withdraw(): raise ValueError("Account has not matured yet") if amount <= 0: raise ValueError("Amount must be positive") if amount > self._balance: raise ValueError("Insufficient funds") self._balance -= amount def process_account(account: Account) -> None: print(f"Balance: ${account.balance:.2f}") print(f"Can withdraw: {account.can_withdraw()}") def process_withdrawal(account: WithdrawableAccount, amount: float) -> None: initial = account.balance account.withdraw(amount) print(f"Withdrew ${amount:.2f}, remaining: ${account.balance:.2f}") assert account.balance == initial - amount process_account(CheckingAccount(1000)) process_account(FixedDepositAccount(5000, "2026-01-01")) process_withdrawal(CheckingAccount(1000), 200)

Example 4: Bird Hierarchy Problem

BEFORE: LSP Violation

python
class Bird: def fly(self) -> str: return "Flying" def eat(self) -> str: return "Eating" class Penguin(Bird): def fly(self) -> str: raise NotImplementedError("Penguins can't fly!") def let_bird_fly(bird: Bird) -> None: print(bird.fly()) let_bird_fly(Bird()) # "Flying" let_bird_fly(Penguin()) # NotImplementedError!

AFTER: LSP-Compliant

python
from abc import ABC, abstractmethod class Bird(ABC): @abstractmethod def eat(self) -> str: pass class FlyingBird(Bird): @abstractmethod def fly(self) -> str: pass class SwimmingBird(Bird): @abstractmethod def swim(self) -> str: pass class Sparrow(FlyingBird): def eat(self) -> str: return "Sparrow eating seeds" def fly(self) -> str: return "Sparrow flying" class Penguin(SwimmingBird): def eat(self) -> str: return "Penguin eating fish" def swim(self) -> str: return "Penguin swimming" def feed_bird(bird: Bird) -> None: print(bird.eat()) def let_fly(bird: FlyingBird) -> None: print(bird.fly()) def let_swim(bird: SwimmingBird) -> None: print(bird.swim()) feed_bird(Sparrow()) feed_bird(Penguin()) let_fly(Sparrow()) let_swim(Penguin())

LSP vs Inheritance Guidelines

GuidelineDescription
Subtype requiresThe child should require no more than the parent (preconditions)
Subtype providesThe child should provide no less than the parent (postconditions)
Subtype preservesThe child should preserve all invariants of the parent
Subtype doesn't throwThe child should not throw new exception types the parent doesn't throw
Subtype returns same typeThe child's return type should be a subtype of the parent's return type (covariance)

LSP Violations: Warning Signs

SignProblem
Child overrides method to do nothing or raiseWeakens postcondition
Child overrides method to reject valid inputsStrengthens precondition
isinstance checks in client codeClient knows subclass breaks contract
"Is-a" relationship feels wrongSquare-Rectangle, Bird-Penguin
Child throws new exception typesClient can't handle them
Child breaks parent invariantsMutable field in supposedly immutable class
💡Tip

If you find yourself writing if isinstance(obj, SpecificType): to handle exceptions, it's almost always an LSP violation. Polymorphism should handle the variation; typeof checks are a code smell.

Behavioral Contracts

LSP is about behavioral contracts, not just type signatures:

python
from typing import Protocol, runtime_checkable @runtime_checkable class Container(Protocol): def add(self, item: str) -> None: ... def remove(self, item: str) -> None: ... def __len__(self) -> int: ... def __contains__(self, item: str) -> bool: ... class ListContainer: def __init__(self): self._items: list[str] = [] def add(self, item: str) -> None: self._items.append(item) def remove(self, item: str) -> None: self._items.remove(item) def __len__(self) -> int: return len(self._items) def __contains__(self, item: str) -> bool: return item in self._items class SetContainer: def __init__(self): self._items: set[str] = set() def add(self, item: str) -> None: self._items.add(item) def remove(self, item: str) -> None: self._items.discard(item) def __len__(self) -> int: return len(self._items) def __contains__(self, item: str) -> bool: return item in self._items def add_duplicate(cont: Container, item: str) -> None: """Add item twice — should work for any Container.""" cont.add(item) cont.add(item) print(f"Count after adding {item!r} twice: {len(cont)}") add_duplicate(ListContainer(), "hello") # Count: 2 add_duplicate(SetContainer(), "hello") # Count: 1 — still correct

Both ListContainer and SetContainer satisfy the Container protocol. The function works correctly with both because neither violates the behavioral contract.

Comparing Design Approaches

ApproachLSP StatusNotes
Square inherits RectangleViolationMutating one dimension changes the other
Shape → Rectangle, SquareSatisfiedBoth are subtypes of Shape
Bird → Penguin (fly)ViolationPenguin can't fly
Bird → FlyingBird, SwimmingBirdSatisfiedSeparate interfaces for separate behaviors
CustomCollection → ReadOnlyCollectionViolationThrows on add/remove
ReadableCollection → WritableCollectionSatisfiedInterface segregation
100%

LSP and Design by Contract

Design by Contract (DbC) formalizes LSP:

TermMeaning
PreconditionWhat must be true before calling a method
PostconditionWhat must be true after calling a method
InvariantWhat must always be true about an object

For LSP:

  • Child cannot strengthen preconditions (can't reject what parent accepts)
  • Child cannot weaken postconditions (must guarantee at least what parent guarantees)
  • Child must preserve all invariants (can't break parent's guarantees)
ℹ️Note

While Python doesn't enforce contracts natively, you can use assert statements, dataclass validation, or libraries like icontract to enforce them. The important thing is to design with contracts in mind.

Practice Exercises

  1. Does the following code violate LSP? Why? Refactor it.

    python
    class Stack: def push(self, item): ... def pop(self): ... class NoPopStack(Stack): def pop(self): raise RuntimeError("Cannot pop from this stack")
  2. The Rectangle-Square problem is the classic LSP violation. Create a proper design using a Shape base class with area() and perimeter().

  3. Identify the LSP violation in this code and fix it:

    python
    class FileWriter: def write(self, data: str) -> None: with open("output.txt", "w") as f: f.write(data) class ReadOnlyFileWriter(FileWriter): def write(self, data: str) -> None: pass # Does nothing
  4. A Vehicle class has start_engine() and drive(). A Bicycle subclass raises NotImplementedError for start_engine(). How would you refactor this?

  5. Explain the relationship between LSP and the "is-a" rule of inheritance. When should you NOT use inheritance?

  6. Create a proper hierarchy: DatabaseConnection with methods connect(), query(), disconnect(). Create subclasses MySQLConnection, PostgreSQLConnection, and RedisConnection. Ensure LSP is satisfied.

  7. What design pattern(s) help avoid LSP violations? Give a concrete example.

  8. Refactor this to be LSP-compliant:

    python
    class Discount: def apply(self, price: float) -> float: return price class NoDiscount(Discount): def apply(self, price: float) -> float: return price class PercentageDiscount(Discount): def __init__(self, percent: float): self.percent = percent def apply(self, price: float) -> float: return price * (1 - self.percent / 100) class FixedDiscount(Discount): def __init__(self, amount: float): self.amount = amount def apply(self, price: float) -> float: result = price - self.amount return result if result > 0 else 0

Summary

  • LSP: Subtypes must be substitutable for their base types
  • Behavioral contract: Preconditions can't be strengthened, postconditions can't be weakened, invariants must be preserved
  • Classic violation: Square-Rectangle, Bird-Penguin
  • Fix: Favor composition, use separate interfaces for separate behaviors
  • Detection: isinstance checks, overrides that raise or do nothing, broken invariants
  • Key insight: "Is-a" from a modeling perspective doesn't always mean "is-a" from a behavioral perspective
Success

LSP teaches us that inheritance is about behavior, not just structure. A well-designed hierarchy ensures that any subclass can safely stand in for its parent without surprises.

Progreso50%