SOLID Principles
Liskov Substitution Principle
Subtypes must be substitutable for base types
The Liskov Substitution Principle (LSP) is the third of the SOLID design principles, introduced by Turing Award winner Barbara Liskov in her 1987 conference keynote. It states that "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." In plain terms, a subclass must stand in for its parent class without causing the program to fail or behave incorrectly.
1. Learning Objectives
- Define the Liskov Substitution Principle and its relationship to subtyping.
- Identify LSP violations (e.g. throwing
UnsupportedOperationException, weakening postconditions). - Understand Design by Contract (DbC): Preconditions, Postconditions, and Invariants.
- Analyze "Before vs. After" class UML diagrams for LSP compliance.
- Implement substitute-safe class structures in Java, Python, and C++.
2. Problem Statement
Inheritance is often taught simply as a tool for code reuse. This leads developers to create subclasses purely to inherit fields and methods, even if the subclass doesn't share the same behavior as the parent.
For example, if you create a base Account class with a withdraw() method, and then inherit a FixedDepositAccount (which does not allow early withdrawals), you are forced to throw an UnsupportedOperationException inside the subclass's withdraw() method.
This creates a major problem: any client method that processes withdrawals for a list of Accounts will crash at runtime if a FixedDepositAccount is passed in. The client can no longer trust the parent class's contract, forcing them to add ugly type checks (instanceof), violating the Open/Closed Principle.
3. Real-world Analogy
Imagine a Real Duck and a Toy Duck:
- The Violation: A toy duck looks like a duck, quacks like a duck, and floats like a duck. However, if the toy duck requires batteries, it cannot substitute a real duck in a pond without changing the environment's parameters (the pond does not supply batteries). If you treat them identically, your system will fail when the toy duck drowns.
- The Fix: Do not force the Toy Duck to inherit from a base
Duckclass if the base class assumes biological behaviors. Instead, separate them or derive both from a higher-levelWaterFloatingObjectinterface.
4. Theory (Design by Contract)
LSP is closely tied to Bertrand Meyer's Design by Contract (DbC). A subclass must respect the contract of its parent. In practice, this means:
- Preconditions cannot be strengthened in a subtype: The subclass cannot demand *more* from the caller than the parent. For example, if the parent's
withdraw(amount)method allows any positive amount, the subclass cannot restrict the amount to only multiples of 100. - Postconditions cannot be weakened in a subtype: The subclass must guarantee *at least* as much as the parent. If the parent method guarantees that the account balance will decrease by the exact withdrawal amount, the subclass cannot charge hidden fees that decrease the balance further without updating the contract.
- Invariants of the supertype must be preserved: Conditions that are always true of the parent must remain true for the child (e.g. account balance must never be negative).
- No new exceptions: The subclass cannot throw exceptions that are not part of the parent class's signature.
5. Visual Diagrams (Before vs. After LSP structures)
Before: Rigid Inheritance (Violates LSP)
FixedDepositAccount inherits from Account but throws an exception on withdraw, breaking client code:
After: Clean Abstraction (LSP Compliant)
We separate accounts into checking/withdrawable accounts and general assets, so the client only invokes withdraw on withdrawable types:
+ withdraw(amount)
(No withdraw method)
6. Syntax Explanation
LSP safety is enforced by strict class structures and method overriding contracts:
- Java: Overridden methods must not throw new checked exceptions. The return type in subclasses can be a subtype of the parent's return type (covariance), but parameter types must remain identical to match class overriding rules.
- Python: Because Python uses duck typing, developers must manually verify contracts. Type annotations (e.g.
def process(account: WithdrawableAccount)) document expected interfaces. - C++: Subclass overrides must use the
overridekeyword to verify signatures at compile time. Virtual destructors must be declared (e.g.virtual ~Account() = default;) to avoid memory leaks when subclasses are destroyed through parent pointers.
7. Step-by-Step Implementation
- Step 1: Identify inheritance hierarchies where a subclass overrides a parent method to throw an unsupported exception or bypass business logic.
- Step 2: Redefine the base parent contract to contain only methods shared by all children (e.g.
getBalance()). - Step 3: Extract actor-specific operations into a sub-interface (e.g.,
WithdrawableAccountrepresenting accounts that support withdrawals). - Step 4: Update client orchestrators to target the specific sub-interface (e.g. withdrawal processes only accept
WithdrawableAccount, preventing runtime errors).
8. Complete Code (Mini Project)
9. Code Walkthrough
Let's review the refactored banking project:
- The original design inherits
FixedDepositAccountViolationfromGodAccount. Because fixed deposits don't allow withdrawals, overridingwithdraw()to throw an exception breaks the parent class's contract. Any client usingGodAccountfaces unexpected runtime crashes. - The refactored design splits the hierarchy:
Accountserves as the base contract containing onlygetBalance(), which is supported by all accounts. - We introduced a sub-interface
WithdrawableAccountto add thewithdraw()contract.SavingsAccountimplements this interface, whileFixedDepositAccountimplements only the baseAccountinterface. - The client orchestrator
BankingProcessoraccepts a list ofWithdrawableAccounts. This preventsFixedDepositAccountfrom being passed in, resolving potential runtime errors at compile time.
10. Execution Flow
- Allocation: Client creates instances of
SavingsAccountandFixedDepositAccount. - List Creation: Client gathers
WithdrawableAccountreferences. If the developer tries to addFixedDepositAccountto this list, the compiler throws an error. - Execution: The processor loops over the withdrawable list and calls
withdraw(). The runtime resolves and executes the correct overridden subclass methods, ensuring a safe transaction path.
11. Internal Working (Subtype constraints)
At the compiler and JVM verifier level, subtype substitution is governed by strict type checks:
- Covariance of Return Types: A subclass can override a method to return a subtype of the parent's return type. For example, if the parent returns
Account, the subclass can returnSavingsAccount. This is safe becauseSavingsAccountis anAccount. - Contravariance of Arguments: In theory, a subclass should accept broader argument types than the parent. However, languages like Java and C++ enforce exact parameter matches for method overrides (otherwise treating different arguments as method overloading rather than overriding).
- Vtable Integrity: C++ virtual table layouts expect subclass virtual functions to match the parent's signatures exactly. If a subclass method behaves differently than expected (e.g. throwing unexpected exceptions), it disrupts the caller's stack frame execution sequence.
12. Complexity Analysis
- Time Complexity: $O(1)$ dynamic dispatch overhead. Designing clean interfaces has no impact on execution speed.
- Space Complexity: $O(1)$ stack/heap allocation. Splitting interfaces is a compile-time structure change and does not consume extra runtime memory.
13. Best Practices
- Validate contracts: Ensure subclasses conform to the expectations set by the parent class's interface and documentation.
- Prefer composition over inheritance: If a subclass needs only some of the parent class's behavior, avoid inheritance. Instead, use composition to embed the parent class as a helper component.
- Write parent-level tests: Run unit tests written for the parent class against instances of the subclass to verify they behave consistently.
- Never throw exceptions for unsupported features: If you must override a method only to throw an unsupported exception, the inheritance hierarchy is incorrect. Refactor the parent interface.
14. Common Mistakes
- The Refuse-to-Behave Subclass: Overriding a method with an empty block or throwing an exception (e.g.
Ostrich.fly() { throw new CantFlyException(); }). - Strengthening Preconditions: Adding extra requirements in the subclass. For example, if the parent allows withdrawing any amount, the subclass throwing an exception if the amount is less than $10.
- Type checking inside client functions: Writing code like
if (acc instanceof FixedDepositAccount)to bypass methods. This indicates a poor abstraction that violates both LSP and OCP.
15. Interview Questions
Answer: Introduced by Barbara Liskov, it states that 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, performance, etc.).
Answer: LSP requires subclass methods to respect the parent class's contract: preconditions cannot be strengthened (made more restrictive), postconditions cannot be weakened (made less restrictive), and parent class invariants must be preserved.
UnsupportedOperationException in a subclass an LSP violation?Answer: It violates the parent class's contract by failing to support a method that the parent class guarantees is available. If a client calls this method expecting it to work, the application will crash at runtime.
16. Practice Exercises
- Easy: Identify and refactor a
Birdclass with afly()method that is inherited by aPenguinsubclass. - Medium: Design a
Fileinterface withread()andwrite()methods. Refactor it to support aReadOnlyFilesubclass without violating LSP. - Hard: Refactor a
Vehiclehierarchy where the base class definesstartEngine(), but electric vehicles (which do not have a traditional engine) and bicycles are forced to inherit it. Split concerns using clean, role-based interfaces.
17. Challenge Problem
Design a smart home automation library. The library controls devices like light bulbs, smart plugs, thermostats, and surveillance cameras. The base SmartDevice interface has methods like turnOn(), turnOff(), setTemperature(int t), and recordVideo(). Explain why this design violates LSP, and write a refactored, type-safe hierarchy using role-based interfaces that ensures smart switches cannot be set to a temperature or commanded to record video at runtime.
18. Summary
- LSP states that subclasses must be substitutable for their parent classes without breaking correctness.
- LSP violations lead to fragile code, type-checking workarounds, and runtime crashes.
- Design by Contract dictates: weaken preconditions, strengthen postconditions, and maintain invariants.
- Composition and role-based interfaces are excellent tools to resolve inheritance-based LSP violations.
19. Cheat Sheet
| Principle | Contract Constraint | LSP Violation Symptom | Solution Technique |
|---|---|---|---|
| SRP | Single reason to change | God Classes, bloated file layouts | Separate concerns into distinct classes |
| OCP | Extend without modifying core | if-else checks matching type enums |
Polymorphic interfaces and dynamic dispatch |
| LSP | Subclass substituted safely | Throwing UnsupportedOperationException in child classes |
Extract sub-interfaces, prefer composition |
20. Quiz
1. Who formally introduced the Liskov Substitution Principle?
A) Bertrand Meyer
B) Barbara Liskov (Correct)
C) Alan Turing
2. What does LSP state about subtyping?
A) Subclasses must inherit all fields from their parent classes
B) Objects of a parent class must be replaceable with objects of a subclass without affecting correctness (Correct)
C) Subclasses must not have any private methods
3. Which methodology is LSP closely associated with?
A) Test Driven Development
B) Design by Contract (DbC) (Correct)
C) Agile Scrum planning
4. Under Design by Contract, what are the constraints on method preconditions in a subclass?
A) They can be strengthened
B) They cannot be strengthened (made more restrictive) (Correct)
C) They must be deleted entirely
5. Under Design by Contract, what are the constraints on method postconditions in a subclass?
A) They cannot be weakened (made less restrictive) (Correct)
B) They can be weakened to improve performance speed
C) They must be identical down to the bytecode line
6. What is a common symptom of violating the Liskov Substitution Principle?
A) Returning unmodifiable collections
B) Throwing UnsupportedOperationException or CantPerformException in subclasses (Correct)
C) Using static helper utility methods
7. Why are virtual destructors needed in C++ base classes when inheritance is used?
A) To speed up compilation checks
B) To ensure subclass resource cleanups run when deleted through parent pointers (Correct)
C) To implement dynamic casts
8. If a client needs to write if (obj instanceof Subclass) code, what design rule is violated?
A) DRY Principle
B) both LSP and OCP (Correct)
C) YAGNI Principle
9. How does covariance apply to method overrides?
A) A subclass override can return a subtype of the parent method's return type (Correct)
B) A subclass override must accept broader parameter types
C) Subclasses can return static constants
10. What is a recommended alternative to inheritance when LSP is violated?
A) Write complex try-catch statements around client calls
B) Use composition and role-based interfaces (Correct)
C) Convert all classes to abstract classes
21. Next Lesson Preview
In the next lesson, we will explore the Interface Segregation Principle (ISP) to learn why clients should not be forced to depend on interfaces they do not use!