Subtype contracts: when a subclass breaks the promise
Replay one valid borrowing request through several kinds of member, name the promise each one keeps or breaks, and repair a bad subtype by delegation. Behavioural contracts and testing · Object-oriented programming.
Your program was written and checked against Member. A new SuspendedMember, a class that extends StandardMember and so looks like a member, refuses the same valid borrow request that every other member accepts, and the program fails in production. The compiler said nothing. What did the subtype change, and how do you find out before shipping?
Read a promise as three clauses
A type promises more than method names. Take the library's Member and its method borrow(Book). Its promise can be read as three plain statements.
- What the caller must give. The book is available, and the member has fewer than 2 active loans. This is the precondition. A request that meets it is a valid request.
- What the caller gets back. A
LoanwhosedueInDaysis at most 14. This is the result promise. - What changes. The book is no longer available, and
activeLoans()is one higher. This is the state-effect promise.
The result and state promises together are the postcondition. These promises apply only to requests that meet the precondition. Code that uses a Member may rely on these three clauses and on nothing else.
In this lesson a refusal is an exception (unchecked in Java).
Replay one request through four members
Take the world in the scene: Mira has no loans, and Dune is available. The client calls member.borrow(dune), and the request is valid under the promise above. The same call, with the same card, runs against four classes.
One request, four members
Read the steps as text
- Mira has no loans and Dune is available. The same valid borrow request goes to four kinds of member.
- StandardMember.borrow(Mira, Dune) returns Loan(due 14). Every judged row passes. Every clause holds, so the base member keeps its promise.
- SuspendedMember.borrow(Mira, Dune) throws "account suspended". Failing: precondition. It refused a request the base accepts, so its precondition is stronger.
- SlowMember.borrow(Mira, Dune) returns Loan(due 30). Failing: result. The due date is later than promised, so the result clause fails.
- QuietMember.borrow(Mira, Dune) returns Loan(due 14). Failing: state. The loan came back, but Dune stayed available and the loan count did not move.
- The signature matched in every case. The clauses told the four members apart.
StandardMember meets all three clauses. SuspendedMember extends it and adds a rule, "suspended accounts cannot borrow", so it refuses first: its precondition is now stronger, meaning it demands more of the caller than the base did.
SlowMember borrows correctly but returns a loan due in 30 days, so the result is weaker than "at most 14". QuietMember returns a loan due in 14 days but forgets to mark the book taken, so the promised state change did not happen.
class Book {
private boolean available = true;
boolean isAvailable() { return available; }
void take() { available = false; }
}
record Loan(int dueInDays) {}
interface Member {
Loan borrow(Book book);
int activeLoans();
}
class StandardMember implements Member {
private int loans;
public Loan borrow(Book book) {
if (!book.isAvailable() || loans >= 2) throw new IllegalStateException("cannot borrow");
book.take();
loans++;
return new Loan(14);
}
public int activeLoans() { return loans; }
}
class SuspendedMember extends StandardMember {
public Loan borrow(Book book) { throw new IllegalStateException("account suspended"); }
}
class SlowMember extends StandardMember {
public Loan borrow(Book book) { super.borrow(book); return new Loan(30); }
}
class QuietMember extends StandardMember {
public Loan borrow(Book book) { return new Loan(14); } // no take(), no loans++
}from dataclasses import dataclass
@dataclass(frozen=True)
class Loan:
due_in_days: int
class Book:
def __init__(self):
self._available = True
def is_available(self):
return self._available
def take(self):
self._available = False
class StandardMember:
def __init__(self):
self._loans = 0
def active_loans(self):
return self._loans
def borrow(self, book):
if not book.is_available() or self._loans >= 2:
raise RuntimeError("cannot borrow")
book.take()
self._loans += 1
return Loan(14)
class SuspendedMember(StandardMember):
def borrow(self, book):
raise RuntimeError("account suspended")
class SlowMember(StandardMember):
def borrow(self, book):
super().borrow(book)
return Loan(30)
class QuietMember(StandardMember):
def borrow(self, book):
return Loan(14) # no take(), no loans increaseOn a fresh Mira and Dune, StandardMember returns Loan[dueInDays=14] in Java (Loan(due_in_days=14) in Python), leaving the book unavailable and one active loan. SuspendedMember throws account suspended with the book still available and no loans.
SlowMember returns a loan due in 30 days, leaves the book unavailable and shows 1 active loan, so the result clause fails while the state clause holds. QuietMember returns the loan and leaves the book available with zero loans, so a second member could borrow Dune again.
Which changes keep the promise
Judging a subtype by behavior wherever its base is expected is behavioral subtyping, given a precise form by Liskov and Wing. In plain words, a subtype may:
- accept at least as many requests as the base (a weaker precondition, one that accepts more);
- promise at least as much (a result at least as good, and every promised state change made, with no change the base forbids);
- add extras that no clause forbids.
It may not demand more from the caller, return less than the base promised, or skip a promised change. "Is a kind of member" in everyday language says nothing about this.
One more case matters. If Mira already holds 2 loans, the base promise never covered her request: it says what happens when the precondition holds and is silent otherwise. A subtype is free to treat such a request differently, for example by accepting a third loan. That is not a violation, because no client had been promised a refusal.
What the compiler checks, and what it does not
Java checks the shape of an override. The overriding method may not narrow access, its return type must fit the original's, and it may not declare a checked exception the original does not declare (JLS 8.4.8.3). SuspendedMember passes all three.
Python checks even less before a call. A typing.Protocol describes names and signatures for a type checker, and the runtime does not enforce annotations. In both languages the behavioral part of the promise is checked by reading the contract and by tests, which the next lesson writes.
A refusal can be legitimate when the base promise itself allows it. Collection.add is documented as an optional operation that may throw UnsupportedOperationException, so a collection that refuses honors the base promise. Member.borrow promises no refusal for a valid request.
Repair by delegation
Suppose the library wants a suspended account that cannot borrow, a record of each loan, and a tier with a higher limit. Do not subclass and tighten. Only the record uses delegation, a type that forwards to StandardMember. The tier is a separate implementation, and the suspended account gets its own type below.
Repair by delegation
Read the steps as text
- Mira already holds two loans and Dune is available. A premium member with a higher limit gets her third request.
- PremiumMember.borrow(Mira, Dune) returns Loan(due 14). The promise does not cover this request, so nothing is judged. The base never promised a refusal here, so accepting a third loan breaks nothing.
- NoticingMember.borrow(Mira, Dune) forwards to StandardMember and returns Loan(due 14). Every judged row passes. It forwarded to the standard member, so every clause holds and the notice is only an extra.
- Forwarding keeps the promise, and extras that demand nothing are safe.
NoticingMember holds another Member and forwards every call. A valid request gets the same loan and state change, so all three clauses hold, and the extra notices count demands nothing.
PremiumMember, a sibling of StandardMember, raises the limit to 3 loans. It is not delegation: it re-implements the borrow logic and implements Member directly. Accepting more is allowed: Mira's third request lies outside the base precondition, so the base promise never covered it. Member promises no ceiling beyond that precondition; a promised ceiling would be an invariant (see Go deeper).
Delegation, as in NoticingMember, suits extras that demand nothing. A suspended account really does refuse valid requests, so it should not claim to be a Member. Give it an unrelated type, SuspensionGate, that callers choose deliberately and that checks suspension before calling a member.
class NoticingMember implements Member {
private final Member inner;
private int notices;
NoticingMember(Member inner) { this.inner = inner; }
public Loan borrow(Book book) {
Loan loan = inner.borrow(book); // forward: the base behavior
notices++; // extra: no clause forbids it
return loan;
}
public int activeLoans() { return inner.activeLoans(); }
int notices() { return notices; }
}
class PremiumMember implements Member {
private int loans;
public Loan borrow(Book book) {
if (!book.isAvailable() || loans >= 3) throw new IllegalStateException("cannot borrow");
book.take();
loans++;
return new Loan(14);
}
public int activeLoans() { return loans; }
}
class SuspensionGate { // not a Member
Loan borrowFor(Member m, Book b, boolean suspended) {
if (suspended) throw new IllegalStateException("account suspended");
return m.borrow(b);
}
}class NoticingMember:
def __init__(self, inner):
self._inner = inner
self._notices = 0
def borrow(self, book):
loan = self._inner.borrow(book) # forward: the base behavior
self._notices += 1 # extra: no clause forbids it
return loan
def active_loans(self):
return self._inner.active_loans()
def notices(self):
return self._notices
class PremiumMember:
def __init__(self):
self._loans = 0
def active_loans(self):
return self._loans
def borrow(self, book):
if not book.is_available() or self._loans >= 3:
raise RuntimeError("cannot borrow")
book.take()
self._loans += 1
return Loan(14)
class SuspensionGate: # not a member
def borrow_for(self, member, book, suspended):
if suspended:
raise RuntimeError("account suspended")
return member.borrow(book)With a fresh StandardMember inside, borrow(dune) returns the 14-day loan, leaves Dune unavailable, reports 1 active loan, and notices() returns 1. If the inner member refuses, notices() stays 0, because the exception leaves before the increment.
Give Mira two loans and a third available book. StandardMember refuses the third request, while PremiumMember returns a 14-day loan and reports 3 active loans.
Recap
- A type's promise has three clauses: what the caller must give, what the caller gets back and what changes.
- A subtype may accept more and promise more, but may not demand more, return less or skip a promised change.
- A request outside the base precondition is not covered, so a subtype may treat it differently.
- Java checks signature rules and Python checks nothing about behavior, so the promise is checked by contract and by tests.
- Forward to an existing member (
NoticingMember) for extras that demand nothing; put a requirement that tightens the promise in an unrelated type callers choose deliberately.
Go deeper
Liskov and Wing's definition also constrains the subtype's invariants, which are conditions that hold for every state of the object, and its history, the sequences of states it may pass through: a subtype may not let the object change in a way the base forbids. A promised ceiling such as "activeLoans() is never above 2" would be an invariant, and PremiumMember reporting 3 would break it. This lesson checks only a single call.
The direction rule is often summarised as require no more, ensure no less. Languages with built-in contract syntax, such as Eiffel, check these clauses at run time when assertions are enabled; neither Java nor Python does.
Continue to object contract tests, which turns each clause into an assertion that catches a broken subtype, and then to interaction tests for sequences of calls. To revisit how a refusal travels, read exception contracts. Practise the same habit in Queue Fundamentals, where enqueue and dequeue each carry a promise.