Liskov substitution in depth
Module 03 showed one subclass that couldn't do its parent's job. This module gives the full rule: the four promises every subtype must keep, why the dangerous violations don't crash, how a single contract test catches them, and two ways to repair a hierarchy that lies.
1. The idea
Barbara Liskov's rule: code written against a type must keep working, unchanged, with any subtype. A compiler checks that a subclass has the right methods. It can't check that they keep the parent's promises. Those promises are the contract, and a subclass has four rules to follow:
| Rule | The subclass may… | …but may not | Library example that breaks it |
|---|---|---|---|
| Preconditions (what callers must provide) | Accept more | Demand more | A parent promises loans of 1–21 days; a subclass refuses anything over 7 |
| Postconditions (what it guarantees back) | Guarantee more | Guarantee less | A parent returns "due in exactly the days asked"; a subclass quietly pushes weekend due dates to Monday |
| Invariants (what's always true) | Add its own | Break the parent's | "Copies on loan never exceed copies owned"; a subclass lends the last copy twice |
| Exceptions (how it can fail) | Throw fewer, or narrower ones | Throw kinds callers weren't told about | A subclass throws PaymentRequired from borrow(), which callers never catch |
Notice that two of the four never crash: a shortened loan or a moved due date just gives callers wrong answers. That's why the reliable check is a contract test: one test suite, written against the parent type, run against every subclass.
2. How it works
A reference atlas can be looked up but never lent. Putting it under Book because "it's a book" looks harmless. Follow a checkout loop that trusts the type.
Two ways to fix a hierarchy that lies: split it by what things can do (Item for everything in the catalogue, LendableItem only for things that can be lent), or move the varying behaviour into a part the item has (a LendingPolicy: one copy, unlimited copies), so subclasses don't need to override the promise at all. The code below does both.
3. Try it yourself
Does this subclass keep the contract?
The parent, LendableItem, promises: loans of 1–21 days accepted; due date = today + days; never more copies out than owned; fails only with NotAvailable. Judge each subclass.
4. In code
Library items with a written contract. A paper book and an e-book share one LendableItem class and differ only in the lending policy they hold; both pass the same contract test. The atlas is an Item but never a LendableItem, so checkout can't be handed one.
from __future__ import annotations
from abc import ABC, abstractmethod
class NotAvailable(Exception):
"""The only failure callers of borrow() are promised."""
# How copies are lent: a separate object, so items can differ without breaking the contract.
class LendingPolicy(ABC):
@abstractmethod
def available(self) -> bool: ...
@abstractmethod
def take(self) -> None: ...
class SingleCopy(LendingPolicy):
def __init__(self) -> None:
self.on_loan = False
def available(self) -> bool:
return not self.on_loan
def take(self) -> None:
self.on_loan = True
class UnlimitedCopies(LendingPolicy): # e-books: lending one never runs out
def available(self) -> bool:
return True
def take(self) -> None:
pass
class Item: # everything in the catalogue can be looked up
def __init__(self, title: str) -> None:
self.title = title
class LendableItem(Item):
"""Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
never lends more copies than it has (invariant); fails only with NotAvailable (exceptions)."""
def __init__(self, title: str, policy: LendingPolicy) -> None:
super().__init__(title)
self.policy = policy
def borrow(self, today: int, days: int) -> int:
if not 1 <= days <= 21:
raise ValueError("loans are 1-21 days")
if not self.policy.available():
raise NotAvailable(self.title)
self.policy.take()
return today + days
class ReferenceBook(Item): # in the catalogue, never lent: not a LendableItem at all
pass
def contract_holds(item: LendableItem) -> bool:
"""The same test runs against every lendable item: that is how substitution is checked."""
due = item.borrow(today=10, days=21) # the longest loan the contract allows must be accepted
if due != 31:
return False
try:
item.borrow(today=11, days=7) # a second loan either succeeds or raises NotAvailable
except NotAvailable:
pass
return True
if __name__ == "__main__":
shelf = [LendableItem("Dune (paper)", SingleCopy()), LendableItem("Dune (e-book)", UnlimitedCopies())]
for item in shelf:
print(f"{item.title}: contract {'holds' if contract_holds(item) else 'broken'}, available now: {item.policy.available()}")
catalogue: list[Item] = shelf + [ReferenceBook("World Atlas")]
print("can borrow:", ", ".join(i.title for i in catalogue if isinstance(i, LendableItem)))
class NotAvailable extends Error {} // the only failure callers of borrow() are promised
// How copies are lent: a separate object, so items can differ without breaking the contract.
interface LendingPolicy { available(): boolean; take(): void; }
class SingleCopy implements LendingPolicy {
private onLoan = false;
available(): boolean { return !this.onLoan; }
take(): void { this.onLoan = true; }
}
class UnlimitedCopies implements LendingPolicy { // e-books: lending one never runs out
available(): boolean { return true; }
take(): void {}
}
class Item { constructor(readonly title: string) {} } // everything in the catalogue can be looked up
// Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
// never lends more copies than it has (invariant); fails only with NotAvailable (exceptions).
class LendableItem extends Item {
constructor(title: string, readonly policy: LendingPolicy) { super(title); }
borrow(today: number, days: number): number {
if (days < 1 || days > 21) throw new Error("loans are 1-21 days");
if (!this.policy.available()) throw new NotAvailable(this.title);
this.policy.take();
return today + days;
}
}
class ReferenceBook extends Item {} // in the catalogue, never lent: not a LendableItem at all
// The same test runs against every lendable item: that is how substitution is checked.
function contractHolds(item: LendableItem): boolean {
const due = item.borrow(10, 21); // the longest loan the contract allows must be accepted
if (due !== 31) return false;
try { item.borrow(11, 7); } catch (e) { if (!(e instanceof NotAvailable)) throw e; } // succeed or NotAvailable
return true;
}
const shelf = [new LendableItem("Dune (paper)", new SingleCopy()), new LendableItem("Dune (e-book)", new UnlimitedCopies())];
for (const item of shelf) console.log(`${item.title}: contract ${contractHolds(item) ? "holds" : "broken"}, available now: ${item.policy.available() ? "True" : "False"}`);
const catalogue: Item[] = [...shelf, new ReferenceBook("World Atlas")];
console.log("can borrow: " + catalogue.filter((i) => i instanceof LendableItem).map((i) => i.title).join(", "));import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
// The only failure callers of borrow() are promised.
class NotAvailable extends RuntimeException {
private static final long serialVersionUID = 1L;
NotAvailable(String title) { super(title); }
}
// How copies are lent: a separate object, so items can differ without breaking the contract.
interface LendingPolicy {
boolean available();
void take();
}
class SingleCopy implements LendingPolicy {
private boolean onLoan = false;
public boolean available() { return !onLoan; }
public void take() { onLoan = true; }
}
class UnlimitedCopies implements LendingPolicy { // e-books: lending one never runs out
public boolean available() { return true; }
public void take() {}
}
class Item { // everything in the catalogue can be looked up
final String title;
Item(String title) { this.title = title; }
}
// Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
// never lends more copies than it has (invariant); fails only with NotAvailable (exceptions).
class LendableItem extends Item {
final LendingPolicy policy;
LendableItem(String title, LendingPolicy policy) { super(title); this.policy = policy; }
int borrow(int today, int days) {
if (days < 1 || days > 21) throw new IllegalArgumentException("loans are 1-21 days");
if (!policy.available()) throw new NotAvailable(title);
policy.take();
return today + days;
}
}
class ReferenceBook extends Item { // in the catalogue, never lent: not a LendableItem at all
ReferenceBook(String title) { super(title); }
}
public class Main {
// The same test runs against every lendable item: that is how substitution is checked.
static boolean contractHolds(LendableItem item) {
int due = item.borrow(10, 21); // the longest loan the contract allows must be accepted
if (due != 31) return false;
try { item.borrow(11, 7); } catch (NotAvailable e) { /* allowed */ }
return true;
}
public static void main(String[] args) {
List<LendableItem> shelf = List.of(new LendableItem("Dune (paper)", new SingleCopy()), new LendableItem("Dune (e-book)", new UnlimitedCopies()));
for (LendableItem item : shelf)
System.out.println(item.title + ": contract " + (contractHolds(item) ? "holds" : "broken") + ", available now: " + (item.policy.available() ? "True" : "False"));
List<Item> catalogue = new ArrayList<>(shelf);
catalogue.add(new ReferenceBook("World Atlas"));
System.out.println("can borrow: " + catalogue.stream().filter(i -> i instanceof LendableItem).map(i -> i.title).collect(Collectors.joining(", ")));
}
}Output (the same in all three languages)
Dune (paper): contract holds, available now: False Dune (e-book): contract holds, available now: True can borrow: Dune (paper), Dune (e-book)
5. In the interview
- Test "is-a" with behaviour, not with words. "An atlas is a book" is true in English and false in code if books can be borrowed. Ask: can every caller of the parent use this without knowing?
- Spot the warning signs: a method that throws "not supported", an
if (x instanceof Special)in a caller, an override that does nothing. - Offer the fix in one line: "Lendable is a capability, not a kind of book, so it's its own type" or "how copies work is a policy the item holds".
- Mention the contract test if you write tests: the same tests for every implementation of an interface catch silent violations.
6. Remember it
How this hook works: what “Keep the parent's promises” means
What the slogan means, phrase by phrase
- “Keep”
- Callers written for the parent must work unchanged.
- “the parent's promises”
- Preconditions, postconditions, invariants and the exceptions it may throw.
Why this picture fits: The hire-car class: any car in it seats four and takes standard fuel, or the booking system breaks.
The story → the concept
| Hire cars | The code |
|---|---|
| The car class | LendableItem |
| Seats four, standard fuel | 1–21 days, due = today + days |
| A two-seater in the class | A subclass that refuses long loans |
| A different class of car | ReferenceBook: an Item, not a LendableItem |
Watch it on an example
- A paper copy and an e-book both pass the same contract test.
- The atlas is in the catalogue but never offered for loan.
- Copy rules differ through a policy object, not overridden promises.
The trap it saves you from
Inheriting because the nouns sound related, then overriding methods to refuse the parent's job.
It still works when…
In interfaces too: every implementation of an interface must keep the interface's contract, not just its method names.
7. Common mistakes
8. Practice exercise (25 minutes)
- Write the contract (5 min). For the vending machine's PaymentMethod, write its four promises in one line each.
- Break it on purpose (5 min). Write a subclass that breaks one rule without throwing. Would your tests notice?
- Write a contract test (10 min). One test function that takes any PaymentMethod and checks the promises. Run it on two implementations.
- Fix with composition (5 min). Move one varying behaviour into an object the class holds instead of an override.
9. Self-check quiz
The parent accepts loans of 1–21 days. A subclass accepts 1–28. Is that allowed?
A subclass returns a later due date than the parent promises, without any error. Which rule is broken?
ReferenceBook can't be lent. What's the best model?
How do you catch violations that never throw?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Module 03d: Interface segregation in depth | Next | Small interfaces make substitution easier to keep. |
| Module 02: OOP essentials | Review | Is-a vs has-a, the start of the composition fix. |
| P09: Library management | 45 min | The full problem this module's example comes from. |
| A behavioral notion of subtyping (Liskov and Wing, 1994) | Paper | The formal version of the four rules. |