Loading learning content...
We've established that method overriding enables runtime polymorphism, learned how virtual dispatch works under the hood, and explored annotations that make override intent explicit. But there's a deeper dimension to overriding that purely technical discussions often miss:
Just because you can override a method doesn't mean any implementation is valid.
Polymorphism works because client code trusts that subtype objects will behave "like" their parent type. When an override violates this trust—when it breaks the behavioral expectations set by the parent—subtle bugs emerge that no compiler or annotation can catch.
This leads us to the Liskov Substitution Principle (LSP), a cornerstone of object-oriented design. In this preview, we'll explore why LSP matters for overriding and what it means to honor behavioral contracts.
By the end of this page, you'll understand the core concept of the Liskov Substitution Principle, why behavioral contracts matter beyond signatures, common override violations that break LSP, a framework for evaluating whether an override is behaviorally correct, and why this principle is crucial for building reliable polymorphic systems.
When we use polymorphism, we make an implicit promise:
Anywhere code expects a parent type, any subtype can be substituted without changing the correctness of the program.
This is the Liskov Substitution Principle, named after computer scientist Barbara Liskov who formalized it in 1987. The formal definition:
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 (correctness, task performed, etc.)
Method overriding is the mechanism that makes or breaks this promise. When you override a method, you're saying "I'll behave like the parent, just differently." The question is: how differently is too different?
123456789101112131415161718192021222324
// Client code that trusts polymorphismpublic class DocumentArchiver { public void archiveAll(List<Document> documents) { for (Document doc : documents) { // We expect EVERY document to: // 1. Return a String from render() // 2. Not throw unexpected exceptions // 3. Not have side effects beyond rendering // 4. Return content related to the document String content = doc.render(); storeInArchive(doc.getId(), content); } } private void storeInArchive(String id, String content) { // Store the rendered content... }} // This works ONLY if subclasses honor the behavioral contract// If HtmlDocument.render() deleted the document instead of rendering it,// this code would fail in production—but compile without errors!The critical insight: The compiler checks that render() exists and returns a String. It cannot check that render() actually renders rather than deleting, mutating, or returning garbage. Those are behavioral expectations that overriding methods must honor by design, not by enforcement.
Let's examine concrete examples of overrides that compile correctly but violate the spirit of substitutability:
The Classic LSP Violation:
Mathematically, a square IS-A rectangle (a rectangle with equal sides). But in OOP, this inheritance breaks LSP:
12345678910111213141516171819202122232425262728293031323334353637383940414243
public class Rectangle { protected int width; protected int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; }} public class Square extends Rectangle { // Must maintain invariant: width == height @Override public void setWidth(int width) { this.width = width; this.height = width; // Also change height! } @Override public void setHeight(int height) { this.height = height; this.width = height; // Also change width! }} // Client code expects Rectangle behavior:public void testRectangle(Rectangle rect) { rect.setWidth(5); rect.setHeight(4); // For a Rectangle, we expect area = 5 * 4 = 20 assert rect.getArea() == 20; // FAILS for Square! // Square.setHeight(4) set BOTH dimensions to 4 // Area is 16, not 20!}Why it violates LSP: Client code expects that setWidth changes ONLY width, and setHeight changes ONLY height. Square's overrides break this expectation. Substituting a Square for a Rectangle changes the program's behavior.
A behavioral contract includes more than the method signature. It encompasses:
Preconditions: What must be true before calling the method? Postconditions: What is guaranteed after the method returns? Invariants: What properties must always hold for the object? Side effects: What external changes might the method cause? Exceptions: Under what conditions are exceptions thrown?
| Aspect | Rule for Valid Override | Violation Example |
|---|---|---|
| Preconditions | Same or weaker (accept more) | Requiring non-null when parent accepts null |
| Postconditions | Same or stronger (guarantee more) | Returning null when parent guaranteed non-null |
| Invariants | Must maintain all parent invariants | Square not maintaining Rectangle's independent dimensions |
| Exceptions | Same or fewer exceptions | Throwing RuntimeException when parent doesn't |
| Return type | Same or subtype (covariant) | Returning Object when parent returns String |
Ask yourself: 'If I replace all uses of the parent type with this subtype, will all client code still work correctly?' If the answer is 'no' or 'maybe,' your override likely violates LSP. The subtype should be indistinguishable from the parent from the client's perspective (except for being more specific/capable).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
/** * A well-documented behavioral contract. */public abstract class PaymentProcessor { /** * Process a payment for the given amount. * * @param amount The payment amount (precondition: amount > 0) * @return Transaction ID (postcondition: non-null, unique) * @throws InsufficientFundsException if funds unavailable * @throws PaymentDeclinedException if payment rejected * * Contract: * - No partial processing: either full success or no change * - Must be idempotent for same order reference * - May take up to 30 seconds to complete */ public abstract String processPayment(BigDecimal amount) throws InsufficientFundsException, PaymentDeclinedException;} // Valid override - honors all aspects of contractpublic class CreditCardProcessor extends PaymentProcessor { @Override public String processPayment(BigDecimal amount) throws InsufficientFundsException, PaymentDeclinedException { // Precondition check (same as parent) if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Amount must be positive"); } // Process payment (details differ, contract same) String txnId = gateway.charge(amount); // Postcondition guaranteed: non-null unique ID Objects.requireNonNull(txnId, "Gateway failed to return transaction ID"); return txnId; }} // INVALID override example:public class BrokenProcessor extends PaymentProcessor { @Override public String processPayment(BigDecimal amount) throws InsufficientFundsException, PaymentDeclinedException { // VIOLATION: Weakened postcondition - returns null sometimes if (Math.random() > 0.9) { return null; // Parent guarantees non-null! } // VIOLATION: New exception type not in parent contract if (amount.compareTo(new BigDecimal("10000")) > 0) { throw new RuntimeException("Amount too large"); } return "TXN-" + UUID.randomUUID(); }}Understanding LSP changes how we approach inheritance and overriding:
Flyable interface instead of Bird.fly()If client code needs instanceof checks to handle different subtypes specially, that's a strong indicator that those subtypes don't truly substitute for the parent. Either the inheritance hierarchy is wrong, or the subtypes are violating LSP. The whole point of polymorphism is to NOT check types at runtime.
Let's look at overrides that properly honor behavioral contracts:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// EXAMPLE 1: Specialization that honors contractpublic class Logger { /** * Log a message. * Postcondition: message is recorded (may be async). * Side effects: may cause I/O. */ public void log(String message) { System.out.println(message); }} public class FileLogger extends Logger { private PrintWriter writer; @Override public void log(String message) { // HONORS CONTRACT: still logs, just to different destination // Postcondition: message recorded (to file) // Side effects: causes file I/O (same category as console I/O) writer.println(timestamp() + ": " + message); writer.flush(); }} public class FilteredLogger extends Logger { private Set<String> blockedWords; @Override public void log(String message) { // HONORS CONTRACT: still logs, just with processing // All messages get logged (contract), but content filtered String filtered = filterBlockedWords(message); super.log(filtered); }} // EXAMPLE 2: Strengthened postcondition (allowed by LSP)public class Collection<E> { /** * Add an element. * Postcondition: element is in collection after call. */ public void add(E element) { internalList.add(element); }} public class SortedCollection<E extends Comparable<E>> extends Collection<E> { @Override public void add(E element) { // STRENGTHENED POSTCONDITION: element is in collection AND // collection remains sorted. More guarantees, not fewer! internalList.add(element); Collections.sort(internalList); }} // EXAMPLE 3: Covariant return type (narrower return, still valid)public class DocumentFactory { public Document create(String content) { return new Document(content); }} public class HtmlDocumentFactory extends DocumentFactory { @Override public HtmlDocument create(String content) { // Returns HtmlDocument, not Document // Valid: HtmlDocument IS-A Document // Client expecting Document gets HtmlDocument—compatible! return new HtmlDocument(content); }}What makes these valid:
FileLogger — Still logs messages (core behavior), just to a different output. Any client calling log() gets what they expect: the message is recorded.
FilteredLogger — Still logs (core behavior), with additional processing. Messages are never lost, just sanitized.
SortedCollection — Provides more guarantees (sorted), not fewer. Clients expecting a collection get a better one.
HtmlDocumentFactory — Returns a more specific type. Clients expecting Document get something compatible and more capable.
We've connected method overriding to the larger principle of safe substitutability. Let's consolidate the key insights:
What's Next:
This preview has introduced LSP concepts as they relate to overriding. In the dedicated SOLID Principles section of this curriculum, we'll explore LSP in complete depth—including formal rules, design patterns that support LSP, and techniques for refactoring LSP violations.
With this module complete, you now have a comprehensive understanding of method overriding as the engine of runtime polymorphism: how it works mechanically, how to declare it explicitly, and how to ensure it maintains behavioral correctness through the Liskov Substitution Principle.
You've mastered Method Overriding (Revisited) — understanding how overriding enables polymorphism, the mechanics of virtual dispatch, the role of override annotations, and the critical connection to behavioral correctness through LSP. This foundation prepares you for the remaining polymorphism topics and the deeper SOLID principles exploration ahead.