The creation of systems that are maintainable, scalable, and robust is essential when developing software. Polymorphism and the Liskov Substitution Principle (LSP) are two essential concepts that facilitate this.This blog post will explain these concepts, gain an understanding of their importance, and learn how to implement them effectively.
Polymorphism is a central concept in object-oriented programming (OOP), which is derived from the Greek words "poly" (many) and "morphe" (form). It enables objects of various classes to be regarded as objects of a shared superclass. Simply put, polymorphism allows a single interface to handle multiple types of objects.
Obtained through method overloading or operator overloading.
class Calculator { int add(int a, int b) { return a + b; } double add(double a, double b) { return a + b; } }
class Animal { void sound() { System.out.println("Animal makes a sound"); } } class Dog extends Animal { @Override void sound() { System.out.println("Dog barks"); } } public class Main { public static void main(String[] args) { Animal myAnimal = new Dog(); myAnimal.sound(); // Output: Dog barks } }
Barbara Liskov coined the Liskov Substitution Principle in 1987, and it is one of the five SOLID principles of object-oriented design. It says:
"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the desirable properties of the program."
Subclasses should essentially act in a way that allows them to take the place of their superclasses without causing unexpected behavior in the application.
class Car { // Parent class D protected String model; protected boolean isRunning; public void startEngine() { isRunning = true; System.out.println("Engine started"); } public void stopEngine() { isRunning = false; System.out.println("Engine stopped"); } public void accelerate() { if(isRunning) { System.out.println("Car is accelerating"); } } } class ElectricCar extends Car { // Subclass C private int batteryLevel; @Override public void startEngine() { isRunning = true; System.out.println("Electric motor powered on"); } @Override public void stopEngine() { isRunning = false; System.out.println("Electric motor powered off"); } // Additional method specific to ElectricCar public void chargeBattery() { System.out.println("Charging battery"); } } // This method expects a Car but works fine with ElectricCar public static void testDrive(Car car) { // Accepts both Car and ElectricCar car.startEngine(); car.accelerate(); car.stopEngine(); } public static void main(String[] args) { Car regularCar = new Car(); Car electricCar = new ElectricCar(); // This works! (LSP in action) testDrive(regularCar); // Works testDrive(electricCar); // Also works! // However: ElectricCar eCar = new Car(); // This won't compile! // But we can do: if(electricCar instanceof ElectricCar) { ElectricCar ec = (ElectricCar)electricCar; ec.chargeBattery(); // Access ElectricCar specific method } }
Rectangle/Square problem:
/** * LSP VIOLATION: Strengthening preconditions and violating invariants * Why it breaks LSP: * 1. Rectangle establishes that width and height can be set independently * 2. Square strengthens this by forcing width = height * 3. Any code that expects to work with a Rectangle's properties independently will break * 4. Violates the expectation that changing width doesn't affect height and vice versa * 5. Client code written against Rectangle interface cannot work correctly with Square */ 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; } } class Square extends Rectangle { @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); // Breaks LSP - changes Rectangle's fundamental behavior } @Override public void setHeight(int height) { super.setHeight(height); super.setWidth(height); // Breaks LSP - changes Rectangle's fundamental behavior } }
Engine Example:
/** * LSP VIOLATION: Weakening postconditions * Why it breaks LSP: * 1. Base class establishes a contract that getFuelType() always returns a valid FuelType * 2. ElectricEngine breaks this by returning null * 3. Client code expecting a non-null FuelType will break * 4. Violates the postcondition that a valid fuel type is always returned */ class Engine { public FuelType getFuelType() { return new Diesel(); } } class ElectricEngine extends Engine { @Override public FuelType getFuelType() { return null; // Breaks LSP - weakens postcondition of returning valid fuel type } }
BankAccount Example
/** * LSP VIOLATION: Adding unexpected restrictions * Why it breaks LSP: * 1. Base class establishes that withdrawals are always possible if funds are available * 2. SavingsAccount adds a new restriction (withdrawal limit) * 3. Throws exceptions in scenarios where base class wouldn't * 4. Client code expecting to make withdrawals based only on balance will break * 5. Violates the principle that subclass operations should not add stricter rules */ class BankAccount { protected double balance; public void withdraw(double amount) { if (amount <= balance) { balance -= amount; } } } class SavingsAccount extends BankAccount { private int withdrawalsThisMonth = 0; private static final int MAX_WITHDRAWALS = 3; @Override public void withdraw(double amount) { if (withdrawalsThisMonth >= MAX_WITHDRAWALS) { throw new IllegalStateException("Exceeded maximum withdrawals!"); // Breaks LSP } super.withdraw(amount); withdrawalsThisMonth++; } }
Rather than imposing a hierarchy of inheritance, we employ composition with behaviors. Every behavior is encapsulated and interchangeable without causing errors.
// 1. COMPOSITION OVER INHERITANCE EXAMPLE // Instead of inheritance hierarchy that might violate LSP: class Bird { public void fly() { System.out.println("Flying"); } } class Penguin extends Bird { } // Problematic! // Better approach using composition: interface MovementBehavior { void move(); } class FlyingBehavior implements MovementBehavior { @Override public void move() { System.out.println("Flying through the air"); } } class SwimmingBehavior implements MovementBehavior { @Override public void move() { System.out.println("Swimming in water"); } } class Bird { private final MovementBehavior movementBehavior; public Bird(MovementBehavior movementBehavior) { this.movementBehavior = movementBehavior; } public void move() { movementBehavior.move(); } } // Usage: Bird eagle = new Bird(new FlyingBehavior()); Bird penguin = new Bird(new SwimmingBehavior());
An example of a bank account demonstrates how to follow rules without violating LSP.
Rules are not inherited; they are composed. Behavior stays steady and predictable. No unforeseen conditions or exceptions
// 2. CONSISTENT BEHAVIOR EXAMPLE // Instead of overriding with different behavior: class BankAccount { protected double balance; public boolean withdraw(double amount) { if (amount <= balance) { balance -= amount; return true; } return false; } } class SavingsAccount extends BankAccount { // DON'T override with new restrictions @Override public boolean withdraw(double amount) { if (isWithinMonthlyLimit()) { // Violates LSP return super.withdraw(amount); } return false; } } // Better approach with clear abstraction: interface WithdrawalRule { boolean canWithdraw(double amount, double balance); } class BasicWithdrawalRule implements WithdrawalRule { @Override public boolean canWithdraw(double amount, double balance) { return amount <= balance; } } class MonthlyLimitRule implements WithdrawalRule { private int withdrawalsThisMonth = 0; private static final int MONTHLY_LIMIT = 3; @Override public boolean canWithdraw(double amount, double balance) { return withdrawalsThisMonth < MONTHLY_LIMIT && amount <= balance; } public void incrementWithdrawals() { withdrawalsThisMonth++; } } class ModernBankAccount { protected double balance; private final List<WithdrawalRule> withdrawalRules; public ModernBankAccount(List<WithdrawalRule> rules) { this.withdrawalRules = rules; } public boolean withdraw(double amount) { // Check all rules boolean canWithdraw = withdrawalRules.stream() .allMatch(rule -> rule.canWithdraw(amount, balance)); if (canWithdraw) { balance -= amount; // Update rules if needed withdrawalRules.stream() .filter(rule -> rule instanceof MonthlyLimitRule) .map(rule -> (MonthlyLimitRule) rule) .forEach(MonthlyLimitRule::incrementWithdrawals); return true; } return false; } }
// 3. CLEAR INTERFACE SEGREGATION // Instead of one large interface: interface Vehicle { void drive(); void fly(); void swim(); } // Better approach with specific interfaces: interface Driveable { void drive(); } interface Flyable { void fly(); } interface Swimmable { void swim(); } // Now classes can implement only what they need: class Car implements Driveable { @Override public void drive() { System.out.println("Driving on road"); } } class Amphibious implements Driveable, Swimmable { @Override public void drive() { System.out.println("Driving on road"); } @Override public void swim() { System.out.println("Moving in water"); } } // Example Usage public class LSPCompliantDemo { public static void main(String[] args) { // Bank account example List<WithdrawalRule> savingsRules = Arrays.asList( new BasicWithdrawalRule(), new MonthlyLimitRule() ); ModernBankAccount savingsAccount = new ModernBankAccount(savingsRules); // Bird example Bird eagle = new Bird(new FlyingBehavior()); Bird penguin = new Bird(new SwimmingBehavior()); // Vehicle example Driveable car = new Car(); Amphibious duck = new Amphibious(); // All objects follow LSP because their behavior is predictable // and matches their abstractions } }
Example: Payment Systems
Consider a payment processing system in which a single payment interface serves as a basis for various payment methods (such as credit cards, PayPal, and cryptocurrency).
interface Payment { processPayment(amount: number): void; } class CreditCardPayment implements Payment { processPayment(amount: number): void { console.log(`Processing credit card payment of $${amount}`); } } class PayPalPayment implements Payment { processPayment(amount: number): void { console.log(`Processing PayPal payment of $${amount}`); } } function processOrder(payment: Payment, amount: number): void { payment.processPayment(amount); } const paymentMethod: Payment = new CreditCardPayment(); processOrder(paymentMethod, 100); // Output: Processing credit card payment of $100
Notification System Example:
interface NotificationTemplate { String getContent(); String getRecipient(); } class EmailTemplate implements NotificationTemplate { private final String email; private final String content; public EmailTemplate(String email, String content) { this.email = email; this.content = content; } @Override public String getContent() { return content; } @Override public String getRecipient() { return email; } } class SMSTemplate implements NotificationTemplate { private final String phoneNumber; private final String message; public SMSTemplate(String phoneNumber, String message) { this.phoneNumber = phoneNumber; this.message = message; } @Override public String getContent() { return message; } @Override public String getRecipient() { return phoneNumber; } } interface NotificationSender { void send(NotificationTemplate template); } class EmailSender implements NotificationSender { @Override public void send(NotificationTemplate template) { System.out.println("Sending email to: " + template.getRecipient()); System.out.println("Content: " + template.getContent()); } } class SMSSender implements NotificationSender { @Override public void send(NotificationTemplate template) { System.out.println("Sending SMS to: " + template.getRecipient()); System.out.println("Message: " + template.getContent()); } }
The Liskov Substitution Principle and polymorphism are fundamental components of object-oriented programming. Following the LSP guarantees that your software remains dependable and predictable whilst polymorphism offers flexibility and extension.
You may create systems that are both scalable and maintainable by becoming knowledgeable in these concepts. If you continue to practice, these concepts will quickly become second nature to you as you progress.
At DevelopersMonk, we share tutorials, tips, and insights on modern programming frameworks like React, Next.js, Spring Boot, and more. Join us on our journey to simplify coding and empower developers worldwide!