Loading learning content...
Throughout this module, we've discussed behavioral expectations, client assumptions, and semantic compatibility. These concepts are powerful, but they can feel subjective. How do you prove that a subclass is behaviorally compatible? How do you specify expectations precisely enough that violations become unambiguous?
The answer lies in Design by Contract (DbC)—a methodology pioneered by Bertrand Meyer (who also gave us the Open/Closed Principle) for specifying behavioral obligations formally.
Contracts provide a rigorous framework for defining and verifying LSP compliance. They transform vague expectations into precise, testable specifications.
By the end of this page, you will understand the three pillars of behavioral contracts—preconditions, postconditions, and invariants—and the precise rules governing how these contracts can evolve through inheritance. You'll gain a formal framework for reasoning about substitutability.
Design by Contract (DbC) is a software design methodology that views the relationship between a class and its clients as a formal contract, similar to a business agreement. Each party has obligations and benefits:
The Client (Caller):
The Supplier (Method/Class):
This mutual agreement creates clear boundaries of responsibility. Neither party has to handle cases outside the contract.
123456789101112131415161718192021222324252627282930313233343536
/** * Contract for square root calculation: * * PRECONDITION: x >= 0 (client's obligation) * POSTCONDITION: result * result == x (supplier's guarantee), within tolerance * * If client passes negative number, contract is violated - behavior undefined * If precondition met, postondition is GUARANTEED */public class MathUtils { /** * Computes the square root of a non-negative number. * * @param x The number (must be >= 0) * @return The square root of x * @throws IllegalArgumentException if x < 0 (precondition violation signal) */ public static double sqrt(double x) { assert x >= 0 : "Precondition violated: x must be non-negative"; double result = Math.sqrt(x); assert Math.abs(result * result - x) < 0.0001 : "Postcondition violated"; return result; }} // CLIENT CODE (honors precondition):double value = getUserInput();if (value >= 0) { // Client ensures precondition double root = MathUtils.sqrt(value); // Safe to call // Client can TRUST: root * root ≈ value} else { handleInvalidInput(); // Client handles case outside contract}Meyer chose the term 'contract' deliberately. Like a business contract, software contracts define mutual obligations, enable trust, and make violations actionable. A contract that one party can change arbitrarily is worthless. Similarly, subclasses cannot arbitrarily change inherited contracts.
Behavioral contracts consist of three elements, each defining different aspects of expected behavior:
Preconditions specify what must be true before a method is called. They define the conditions under which the method is obligated to perform its function.
Characteristics:
Common precondition types:
x >= 0, list != null, id.length() > 0isOpen(), hasCapacity(), isInitialized()start < end, a.length == b.length1234567891011121314151617181920212223242526272829303132
// PRECONDITION EXAMPLES class BankAccount { /** * Withdraws amount from account. * * @precondition amount > 0 * @precondition amount <= getBalance() * @precondition !isFrozen() */ public void withdraw(double amount) { assert amount > 0 : "Pre: amount must be positive"; assert amount <= balance : "Pre: insufficient funds"; assert !frozen : "Pre: account must not be frozen"; this.balance -= amount; }} class SortedList<T> { /** * Binary search for element. * * @precondition list is sorted (invariant) * @precondition element != null */ public int binarySearch(T element) { assert element != null : "Pre: element cannot be null"; // Relies on sorted invariant (caller doesn't check) return Collections.binarySearch(data, element); }}Contracts provide the formal foundation for LSP. When Barbara Liskov formulated the substitution principle, she provided a specific condition (sometimes called the "Liskov Signature Rules" or "behavioral subtyping rules"):
For a subclass S to be substitutable for a parent class T, the following must hold:
- S must not strengthen preconditions
- S must not weaken postconditions
- S must preserve all invariants of T
These rules can be remembered as:
Why these rules? They ensure that any code written against the parent's contract will work unchanged with the subclass.
| Contract Type | Parent | Subclass Can... | Subclass Cannot... |
|---|---|---|---|
| Preconditions | Requires A | Accept A or more (weaken) | Require more than A (strengthen) |
| Postconditions | Guarantees B | Guarantee B or more (strengthen) | Guarantee less than B (weaken) |
| Invariants | Maintains C | Maintain C (preserve exactly) | Allow C to be false (break) |
Think of it from the client's perspective. If client code satisfies parent preconditions, it must automatically satisfy subclass preconditions (so weaken/accept more). If client code relies on parent postconditions, subclass must provide at least those guarantees (so strengthen/deliver more). Invariants are part of the type's identity—change them and you have a different type.
When a subclass overrides a method, it can accept more inputs than the parent—but never fewer.
Why?
If client code is written against the parent, it satisfies the parent's preconditions. If the subclass requires stricter preconditions, the client code might not satisfy them, causing failures.
The Rule:
In practical terms:
x > 0? Subclass can accept x > 0 or x >= 0 (weaker), not x > 10 (stronger).list != null? Subclass can accept list != null or even list including null (weaker), not list.size() > 0 (stronger).123456789101112131415161718192021222324252627282930313233
// VALID: Precondition weakened class Calculator { /** * @pre divisor != 0 */ public double divide(double dividend, double divisor) { assert divisor != 0; return dividend / divisor; }} class LenientCalculator extends Calculator { /** * @pre none (weakened) * Handles zero divisor gracefully */ @Override public double divide(double dividend, double divisor) { if (divisor == 0) { return Double.POSITIVE_INFINITY; } return dividend / divisor; }} // Client code written for Calculator:calc.divide(10, value);// Works with LenientCalculator even // if value occasionally is 0 ✓123456789101112131415161718192021222324252627282930313233
// INVALID: Precondition strengthened class Calculator { /** * @pre divisor != 0 */ public double divide(double dividend, double divisor) { assert divisor != 0; return dividend / divisor; }} class StrictCalculator extends Calculator { /** * @pre divisor > 0 (strengthened!) * Rejects negative divisors */ @Override public double divide(double dividend, double divisor) { if (divisor <= 0) { // Stricter! throw new IllegalArgumentException( "Divisor must be positive"); } return dividend / divisor; }} // Client code written for Calculator:calc.divide(10, -5); // Valid for parent// BREAKS with StrictCalculator! ✗Strengthening preconditions is one of the most common LSP violations. It often happens when developers add validation: 'Let me add a check for negative numbers—that's more robust!' But this breaks substitutability for clients that relied on negative numbers being valid.
When a subclass overrides a method, it can guarantee more than the parent—but never less.
Why?
Client code trusts parent postconditions. If the subclass provides weaker guarantees, client code that relies on those guarantees will fail.
The Rule:
In practical terms:
result >= 0? Subclass must guarantee result >= 0 or stronger (e.g., result > 0).list is sorted? Subclass must guarantee sorted, can add more (e.g., sorted and unique).12345678910111213141516171819202122232425262728293031
// VALID: Postcondition strengthened class NumberGenerator { /** * @post result >= 0 (non-negative) */ public int generate() { return random.nextInt(100); // Returns 0-99 }} class PositiveGenerator extends NumberGenerator { /** * @post result > 0 (positive) * Strengthened: > instead of >= */ @Override public int generate() { return random.nextInt(99) + 1; // Returns 1-99 (never 0) }} // Client code:int x = generator.generate();if (x >= 0) { // Parent's guarantee process(x); // Works with both ✓}// Subclass guarantees MORE, not less123456789101112131415161718192021222324252627282930313233
// INVALID: Postcondition weakened class Repository { /** * @post result != null * Always returns valid entity */ public Entity findById(Long id) { Entity e = db.find(id); if (e == null) { throw new NotFoundException(); } return e; }} class NullableRepository extends Repository { /** * @post result may be null * Weakened: null possible */ @Override public Entity findById(Long id) { return db.find(id); // May return null! }} // Client code trusts non-null:Entity e = repo.findById(id);e.process(); // NPE with subclass!// Subclass guarantees LESS ✗123456789101112131415161718192021222324252627282930313233343536373839404142
// MORE POSTCONDITION EXAMPLES // 1. Side effect postconditionsclass Logger { /** @post message appears in log */ public void log(String msg) { /* writes to file */ }}class NullLogger extends Logger { /** @post (nothing guaranteed) - VIOLATION */ @Override public void log(String msg) { /* does nothing */ } // Weakened: parent guarantees log entry, child doesn't} // 2. Exception postconditionsclass PaymentProcessor { /** @post if success: payment is charged and confirmed */ public void process(Payment p) { /* ... */ }}class AsyncPaymentProcessor extends PaymentProcessor { /** @post payment is QUEUED (not yet charged) - VIOLATION */ @Override public void process(Payment p) { queue.add(p); // Not actually processed yet! } // Weakened: parent guarantees charged, child only queues} // 3. State postconditionsclass Stack<T> { /** @post size() == old(size()) + 1 */ public void push(T item) { /* ... */ }}class CapacityLimitedStack<T> extends Stack<T> { /** @post size() may be unchanged if full - VIOLATION */ @Override public void push(T item) { if (size >= capacity) return; // Silently fails! super.push(item); } // Weakened: parent guarantees size increase, child doesn't}Subclasses must maintain all invariants established by their parent classes. Invariants define what makes an object of that type valid—if an invariant can be false, you don't really have an object of that type.
Why preservation, not strengthening?
In practical terms:
size >= 0. Subclass must maintain size >= 0 (and can add size <= maxCapacity).balance >= 0. Subclass cannot allow negative balances.elements are sorted. Subclass cannot allow unsorted states.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// THE CLASSIC EXAMPLE: Rectangle-Square problem class Rectangle { protected int width; protected int height; // INVARIANT: width and height are independent // Setting width does not affect height, and vice versa public void setWidth(int w) { this.width = w; } public void setHeight(int h) { this.height = h; } public int getWidth() { return width; } public int getHeight() { return height; } public int getArea() { return width * height; }} class Square extends Rectangle { // SQUARE INVARIANT: width == height (always!) // This CONTRADICTS the Rectangle invariant! @Override public void setWidth(int w) { this.width = w; this.height = w; // Must keep square } @Override public void setHeight(int h) { this.width = h; // Must keep square this.height = h; }} // Client code written for Rectangle:void resizeRectangle(Rectangle r) { r.setWidth(10); r.setHeight(5); // RECTANGLE INVARIANT: width and height are independent // Therefore: assert r.getWidth() == 10; // Should pass assert r.getHeight() == 5; // Should pass assert r.getArea() == 50; // Should pass} // With Square:Square s = new Square();resizeRectangle(s);// After setWidth(10): width=10, height=10// After setHeight(5): width=5, height=5assert s.getWidth() == 10; // FAILS! Width is 5assert s.getArea() == 50; // FAILS! Area is 25 // The Square breaks the Rectangle's invariant:// "setting width does not affect height"Mathematically, every square IS a rectangle. But behaviorally—when considering mutability and operations—Square cannot fulfill the Rectangle contract. This is why LSP is about behavioral subtyping, not mathematical classification. The type hierarchy must reflect behavioral compatibility, not just conceptual relationships.
Solutions to the Rectangle-Square problem:
Make Rectangle immutable — No setters means no invariant about setter independence. Squares and rectangles both work.
Don't inherit — Square and Rectangle are siblings, both implementing a Shape interface. No IS-A claim.
Accept the constraint — If Square extends Rectangle, it must honor Rectangle's behavioral contract, which it can't. Don't use this hierarchy.
Composition — Square contains a Rectangle rather than extending it. Square controls how the Rectangle is used.
The contract rules (weaken preconditions, strengthen postconditions) relate to fundamental type theory concepts: covariance and contravariance.
Covariance (varies in the same direction as the type):
Animal getAnimal() can become Dog getAnimal()Contravariance (varies in the opposite direction):
void process(Dog) can become void process(Animal)Note: Most languages (Java, C#) don't support contravariant parameter types in overriding. This is a limitation of the language type systems, not of LSP theory.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// COVARIANCE: Return types class AnimalShelter { public Animal adopt() { return new Animal(); }} class DogShelter extends AnimalShelter { @Override public Dog adopt() { // VALID: Dog is subtype of Animal return new Dog(); // Covariant return type } // Strengthened postcondition: guarantees Dog, not just Animal} // Client code works:Animal a = shelter.adopt(); // Works for both// With DogShelter, you GET a Dog (valid Animal) // CONTRAVARIANCE: Parameter types (theoretical—most languages don't allow) interface Handler { void handle(Dog dog); // Accepts Dog} // Theoretically valid LSP-wise (but Java doesn't allow):class GeneralHandler implements Handler { void handle(Animal animal) { /* handle any animal */ } // Weakened precondition: accepts Animal (Dog is Animal)} // What Java allows instead: overloadingclass GeneralHandler implements Handler { @Override void handle(Dog dog) { handleAnimal(dog); } // Required by interface void handleAnimal(Animal animal) { /* ... */ } // Additional method} // INVARIANCE: When types must match exactly interface Cage<T> { void put(T animal); // T as parameter T get(); // T as return type} // Cage<Animal> is NOT a Cage<Dog> even though Dog extends Animal// Because put(Dog) would accept Dog but put(Animal) expects Animal// This is why Java generics are invariant by default| Position | Variance | LSP Rule | Direction |
|---|---|---|---|
| Return types | Covariant | Strengthen postcondition | Can narrow to subtype |
| Exception types | Covariant | Strengthen postcondition | Can narrow to subtype |
| Parameter types | Contravariant (theory) | Weaken precondition | Can widen to supertype |
| Generic types | Invariant (default) | Must match exactly | Neither (without wildcards) |
While few languages have native Design by Contract support, you can apply contract thinking in any language:
assert statements to document and verify preconditions, postconditions, and invariants. Enable assertions during development and testing.checkPreconditions(), checkPostconditions(), and checkInvariant() methods. Call them at method boundaries.@pre, @post, @invariant tags (custom or from libraries like Guava). Make contracts visible.IllegalArgumentException, IllegalStateException). Make violations explicit.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// TECHNIQUE: Assertions and documentation /** * @invariant size >= 0 && size <= capacity * @invariant elements[0..size-1] are non-null */class BoundedStack<T> { private Object[] elements; private int size; private int capacity; /** * Pushes an item onto the stack. * * @param item The item to push * @pre item != null * @pre !isFull() * @post !isEmpty() * @post peek() == item * @post size() == old(size()) + 1 */ public void push(T item) { // Precondition checks if (item == null) { throw new IllegalArgumentException("Item cannot be null"); } if (isFull()) { throw new IllegalStateException("Stack is full"); } int oldSize = size; // Capture for postcondition // Core logic elements[size++] = item; // Postcondition checks (in debug/test mode) assert !isEmpty() : "Post: stack should not be empty"; assert peek() == item : "Post: top should be pushed item"; assert size == oldSize + 1 : "Post: size should increase by 1"; // Invariant check assert checkInvariant() : "Invariant violated after push"; } private boolean checkInvariant() { if (size < 0 || size > capacity) return false; for (int i = 0; i < size; i++) { if (elements[i] == null) return false; } return true; }} // TECHNIQUE: Testing contract inheritance@Testvoid subclassShouldHonorParentContract() { // Parent contract test List<String> parentList = new ArrayList<>(); testListContract(parentList); // Pass // Subclass must also pass parent's contract tests List<String> subclassList = new CustomArrayList<>(); testListContract(subclassList); // Must also pass!} void testListContract(List<String> list) { // Postcondition: add increases size int beforeSize = list.size(); list.add("test"); assertEquals(beforeSize + 1, list.size()); // Postcondition: added element is retrievable assertTrue(list.contains("test")); // Postcondition: get(old size) returns added element assertEquals("test", list.get(beforeSize));}Module Complete:
You've now completed the Behavioral Subtyping module. You understand:
These concepts form the theoretical foundation of LSP. In the next module, we'll examine the classic Rectangle-Square problem in depth—the canonical LSP violation example that illuminates what goes wrong when behavioral subtyping is ignored.
You now have a complete understanding of behavioral subtyping—from the distinction between syntax and semantics, through expected behavior and client expectations, to the formal contract framework. This knowledge enables you to design type hierarchies that honor substitutability: subclasses that truly can stand in for their parents without breaking client code.