🧑💻 Author: RK ROY
Liskov Substitution Principle (LSP)
🎯 Definition
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
- Barbara Liskov
The Liskov Substitution Principle 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.
🤔 What Does "Substitutable" Mean?
- Behavioral Substitutability: Subclasses must behave consistently with their parent class
- Contract Compliance: Subclasses must honor the same contracts (preconditions, postconditions)
- No Surprising Behavior: Clients should work the same way with both parent and child objects
Key Concepts
❌ LSP Violation: Broken Substitution
Let's look at a classic example that violates LSP:
// ❌ VIOLATION: Rectangle-Square problem
public class Rectangle {
protected double width;
protected double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public double getWidth() { return width; }
public double getHeight() { return height; }
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getArea() {
return width * height;
}
}
// ❌ VIOLATION: Square violates LSP
public class Square extends Rectangle {
public Square(double side) {
super(side, side);
}
@Override
public void setWidth(double width) {
this.width = width;
this.height = width; // ❌ Violates expected behavior!
}
@Override
public void setHeight(double height) {
this.width = height; // ❌ Violates expected behavior!
this.height = height;
}
}
Why This Violates LSP
public class GeometryCalculator {
public void demonstrateViolation() {
Rectangle rectangle = new Rectangle(5, 10);
testRectangle(rectangle); // Works fine
Rectangle square = new Square(5); // Square as Rectangle
testRectangle(square); // ❌ Breaks expectations!
}
private void testRectangle(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(10);
// Expected: area = 50 (5 * 10)
// With Square: area = 100 (10 * 10) ❌ Wrong!
double area = rect.getArea();
System.out.println("Expected area 50, got: " + area);
// This assertion fails with Square
assert area == 50 : "Rectangle area calculation failed!";
}
}
Problems with This Design
- Behavioral Inconsistency: Square doesn't behave like a Rectangle
- Contract Violation: Setting width shouldn't affect height in a Rectangle
- Surprise Behavior: Clients get unexpected results
- Broken Polymorphism: Can't substitute Square for Rectangle safely
- Violation of Expectations: Square changes both dimensions when setting one
✅ LSP Solution: Proper Abstraction
Let's refactor using proper inheritance hierarchy:
1. Create Common Shape Interface
// ✅ GOOD: Common interface for all shapes
public interface Shape {
double getArea();
double getPerimeter();
String getShapeType();
}
2. Separate Rectangle and Square
// ✅ GOOD: Rectangle with consistent behavior
public class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
this.width = width;
this.height = height;
}
public double getWidth() { return width; }
public double getHeight() { return height; }
public void setWidth(double width) {
if (width <= 0) {
throw new IllegalArgumentException("Width must be positive");
}
this.width = width;
}
public void setHeight(double height) {
if (height <= 0) {
throw new IllegalArgumentException("Height must be positive");
}
this.height = height;
}
@Override
public double getArea() {
return width * height;
}
@Override
public double getPerimeter() {
return 2 * (width + height);
}
@Override
public String getShapeType() {
return "Rectangle";
}
@Override
public String toString() {
return String.format("Rectangle(%.2f x %.2f)", width, height);
}
}
// ✅ GOOD: Square with its own consistent behavior
public class Square implements Shape {
private double side;
public Square(double side) {
if (side <= 0) {
throw new IllegalArgumentException("Side must be positive");
}
this.side = side;
}
public double getSide() { return side; }
public void setSide(double side) {
if (side <= 0) {
throw new IllegalArgumentException("Side must be positive");
}
this.side = side;
}
@Override
public double getArea() {
return side * side;
}
@Override
public double getPerimeter() {
return 4 * side;
}
@Override
public String getShapeType() {
return "Square";
}
@Override
public String toString() {
return String.format("Square(%.2f)", side);
}
}
3. Shape Calculator
// ✅ GOOD: Works with any shape consistently
public class ShapeCalculator {
public double calculateTotalArea(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::getArea)
.sum();
}
public double calculateTotalPerimeter(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::getPerimeter)
.sum();
}
public void printShapeInfo(Shape shape) {
System.out.printf("%s - Area: %.2f, Perimeter: %.2f%n",
shape.toString(), shape.getArea(), shape.getPerimeter());
}
// ✅ Works consistently with all Shape implementations
public List<Shape> filterLargeShapes(List<Shape> shapes, double minArea) {
return shapes.stream()
.filter(shape -> shape.getArea() >= minArea)
.collect(Collectors.toList());
}
}
🏗️ Class Diagram After LSP
🎯 Using the LSP Solution
public class GeometryApplication {
public static void main(String[] args) {
ShapeCalculator calculator = new ShapeCalculator();
// Create different shapes
List<Shape> shapes = Arrays.asList(
new Rectangle(5, 10),
new Square(7),
new Circle(3),
new Rectangle(8, 6),
new Square(4)
);
// ✅ All shapes work consistently
System.out.println("All Shapes:");
shapes.forEach(calculator::printShapeInfo);
System.out.printf("\nTotal Area: %.2f%n", calculator.calculateTotalArea(shapes));
System.out.printf("Total Perimeter: %.2f%n", calculator.calculateTotalPerimeter(shapes));
// ✅ Filtering works with all shapes
List<Shape> largeShapes = calculator.filterLargeShapes(shapes, 40);
System.out.println("\nLarge Shapes (Area >= 40):");
largeShapes.forEach(calculator::printShapeInfo);
}
}
🎨 More LSP Examples
Example 1: Bird Hierarchy
❌ Violation
// ❌ VIOLATION: Not all birds can fly
public abstract class Bird {
public abstract void fly();
public abstract void eat();
}
public class Sparrow extends Bird {
@Override
public void fly() {
System.out.println("Sparrow flying");
}
@Override
public void eat() {
System.out.println("Sparrow eating seeds");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// ❌ Penguins can't fly! This violates LSP
throw new UnsupportedOperationException("Penguins can't fly!");
}
@Override
public void eat() {
System.out.println("Penguin eating fish");
}
}
// ❌ This will break with Penguin
public void makeBirdFly(Bird bird) {
bird.fly(); // Throws exception with Penguin!
}
✅ Solution
// ✅ GOOD: Separate flying and non-flying birds
public interface Bird {
void eat();
void makeSound();
String getSpecies();
}
public interface FlyingBird extends Bird {
void fly();
double getFlightSpeed();
}
public interface SwimmingBird extends Bird {
void swim();
double getSwimSpeed();
}
// Concrete implementations
public class Sparrow implements FlyingBird {
@Override
public void eat() {
System.out.println("Sparrow eating seeds");
}
@Override
public void makeSound() {
System.out.println("Chirp chirp!");
}
@Override
public String getSpecies() {
return "House Sparrow";
}
@Override
public void fly() {
System.out.println("Sparrow flying gracefully");
}
@Override
public double getFlightSpeed() {
return 24.0; // km/h
}
}
public class Penguin implements SwimmingBird {
@Override
public void eat() {
System.out.println("Penguin eating fish");
}
@Override
public void makeSound() {
System.out.println("Squawk squawk!");
}
@Override
public String getSpecies() {
return "Emperor Penguin";
}
@Override
public void swim() {
System.out.println("Penguin swimming underwater");
}
@Override
public double getSwimSpeed() {
return 9.0; // km/h
}
}
// ✅ GOOD: Methods work with appropriate bird types
public class BirdWatcher {
public void observeFlyingBird(FlyingBird bird) {
System.out.println("Watching " + bird.getSpecies());
bird.fly(); // ✅ Safe - all FlyingBirds can fly
System.out.println("Flight speed: " + bird.getFlightSpeed() + " km/h");
}
public void observeSwimmingBird(SwimmingBird bird) {
System.out.println("Watching " + bird.getSpecies());
bird.swim(); // ✅ Safe - all SwimmingBirds can swim
System.out.println("Swim speed: " + bird.getSwimSpeed() + " km/h");
}
public void observeAnyBird(Bird bird) {
System.out.println("General observation of " + bird.getSpecies());
bird.eat();
bird.makeSound();
// ✅ Only calls methods that ALL birds can do
}
}
Example 2: Vehicle Inheritance
❌ Violation
// ❌ VIOLATION: Not all vehicles have engines
public abstract class Vehicle {
protected String model;
protected int year;
public abstract void startEngine();
public abstract void accelerate();
public abstract void brake();
public abstract double getMaxSpeed();
}
public class Car extends Vehicle {
@Override
public void startEngine() {
System.out.println("Car engine started");
}
@Override
public void accelerate() {
System.out.println("Car accelerating");
}
@Override
public void brake() {
System.out.println("Car braking");
}
@Override
public double getMaxSpeed() {
return 200.0;
}
}
public class Bicycle extends Vehicle {
@Override
public void startEngine() {
// ❌ Bicycles don't have engines!
throw new UnsupportedOperationException("Bicycles don't have engines!");
}
@Override
public void accelerate() {
System.out.println("Pedaling faster");
}
@Override
public void brake() {
System.out.println("Using hand brakes");
}
@Override
public double getMaxSpeed() {
return 50.0;
}
}
✅ Solution
// ✅ GOOD: Proper vehicle hierarchy
public interface Vehicle {
void accelerate();
void brake();
double getMaxSpeed();
String getModel();
int getYear();
}
public interface MotorizedVehicle extends Vehicle {
void startEngine();
void stopEngine();
double getFuelEfficiency();
String getFuelType();
}
public interface HumanPoweredVehicle extends Vehicle {
void pedal();
double getEffort(); // effort required (1-10 scale)
}
// Implementations
public class Car implements MotorizedVehicle {
private String model;
private int year;
private boolean engineRunning;
public Car(String model, int year) {
this.model = model;
this.year = year;
this.engineRunning = false;
}
@Override
public void startEngine() {
engineRunning = true;
System.out.println("Car engine started");
}
@Override
public void stopEngine() {
engineRunning = false;
System.out.println("Car engine stopped");
}
@Override
public void accelerate() {
if (engineRunning) {
System.out.println("Car accelerating");
} else {
System.out.println("Start engine first!");
}
}
@Override
public void brake() {
System.out.println("Car braking with disc brakes");
}
@Override
public double getMaxSpeed() {
return 200.0;
}
@Override
public double getFuelEfficiency() {
return 15.5; // km/l
}
@Override
public String getFuelType() {
return "Gasoline";
}
@Override
public String getModel() { return model; }
@Override
public int getYear() { return year; }
}
public class Bicycle implements HumanPoweredVehicle {
private String model;
private int year;
public Bicycle(String model, int year) {
this.model = model;
this.year = year;
}
@Override
public void pedal() {
System.out.println("Pedaling the bicycle");
}
@Override
public void accelerate() {
pedal();
System.out.println("Bicycle speeding up");
}
@Override
public void brake() {
System.out.println("Using hand brakes on bicycle");
}
@Override
public double getMaxSpeed() {
return 50.0;
}
@Override
public double getEffort() {
return 6.0; // Medium effort
}
@Override
public String getModel() { return model; }
@Override
public int getYear() { return year; }
}
🛡️ LSP Rules and Contracts
1. Preconditions Cannot Be Strengthened
// ❌ BAD: Subclass strengthens preconditions
public class Rectangle {
public void setDimensions(double width, double height) {
// Precondition: width > 0 AND height > 0
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
this.width = width;
this.height = height;
}
}
public class Square extends Rectangle {
@Override
public void setDimensions(double width, double height) {
// ❌ VIOLATION: Strengthened precondition (width must equal height)
if (width <= 0 || height <= 0 || width != height) {
throw new IllegalArgumentException("Square dimensions must be positive and equal");
}
super.setDimensions(width, height);
}
}
// ✅ GOOD: Separate classes with appropriate preconditions
public class Rectangle {
public void setDimensions(double width, double height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
this.width = width;
this.height = height;
}
}
public class Square {
public void setDimension(double side) {
if (side <= 0) {
throw new IllegalArgumentException("Side must be positive");
}
this.side = side;
}
}
2. Postconditions Cannot Be Weakened
// ✅ GOOD: Consistent postconditions
public abstract class SortingAlgorithm {
// Postcondition: Returns array in ascending order
public abstract int[] sort(int[] array);
protected boolean isAscending(int[] array) {
for (int i = 1; i < array.length; i++) {
if (array[i] < array[i-1]) return false;
}
return true;
}
}
public class QuickSort extends SortingAlgorithm {
@Override
public int[] sort(int[] array) {
int[] result = array.clone();
quickSort(result, 0, result.length - 1);
// ✅ Maintains postcondition: result is sorted in ascending order
assert isAscending(result) : "QuickSort postcondition violated";
return result;
}
private void quickSort(int[] arr, int low, int high) {
// QuickSort implementation
}
}
// ❌ BAD: Would violate postcondition
public class BrokenSort extends SortingAlgorithm {
@Override
public int[] sort(int[] array) {
// ❌ Returns descending order - violates postcondition!
return Arrays.stream(array)
.boxed()
.sorted(Collections.reverseOrder())
.mapToInt(Integer::intValue)
.toArray();
}
}
3. Invariants Must Be Preserved
// ✅ GOOD: Invariants preserved across inheritance
public class BankAccount {
protected double balance;
// Invariant: balance >= 0 (no overdraft allowed)
public BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
this.balance = initialBalance;
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (balance - amount < 0) {
throw new IllegalStateException("Insufficient funds");
}
balance -= amount;
// Invariant preserved: balance >= 0
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
balance += amount;
// Invariant preserved: balance >= 0
}
public double getBalance() {
return balance;
}
}
public class SavingsAccount extends BankAccount {
private static final double MINIMUM_BALANCE = 100.0;
// Additional invariant: balance >= 100
public SavingsAccount(double initialBalance) {
super(Math.max(initialBalance, MINIMUM_BALANCE));
}
@Override
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (balance - amount < MINIMUM_BALANCE) {
throw new IllegalStateException("Withdrawal would violate minimum balance");
}
balance -= amount;
// ✅ All invariants preserved: balance >= 0 AND balance >= 100
}
// deposit method inherited - automatically preserves invariants
}
🔍 How to Identify LSP Violations
Questions to Ask
- Can I use a subclass anywhere I use the parent class without issues?
- Does the subclass throw exceptions that the parent class doesn't?
- Does the subclass have stronger preconditions than the parent?
- Does the subclass have weaker postconditions than the parent?
- Does the subclass change the expected behavior in surprising ways?
Warning Signs
- ❌
instanceof
checks in client code - ❌ Empty or exception-throwing method implementations
- ❌ Subclasses that require special handling
- ❌ Conditional logic based on concrete types
- ❌ Methods that don't make sense for the subclass
🎯 Benefits of Following LSP
1. Reliable Polymorphism
// ✅ Can use any Shape implementation reliably
public void processShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
// ✅ Works consistently with all Shape implementations
double area = shape.getArea();
double perimeter = shape.getPerimeter();
System.out.printf("%s: Area=%.2f, Perimeter=%.2f%n",
shape.getShapeType(), area, perimeter);
}
}
2. Easier Testing
@Test
public void testAllShapes() {
List<Shape> shapes = Arrays.asList(
new Rectangle(5, 10),
new Square(7),
new Circle(3)
);
// ✅ Same test works for all implementations
for (Shape shape : shapes) {
double area = shape.getArea();
assertTrue("Area should be positive", area > 0);
double perimeter = shape.getPerimeter();
assertTrue("Perimeter should be positive", perimeter > 0);
}
}
3. Flexible Design
// ✅ Can add new shapes without changing existing code
public class Triangle implements Shape {
private double base, height, side1, side2, side3;
@Override
public double getArea() {
return 0.5 * base * height;
}
@Override
public double getPerimeter() {
return side1 + side2 + side3;
}
@Override
public String getShapeType() {
return "Triangle";
}
}
🚨 Common LSP Mistakes
❌ Inappropriate Inheritance
// ❌ BAD: Stack is not a proper List
public class Stack<T> extends ArrayList<T> {
public T push(T item) {
add(item);
return item;
}
public T pop() {
return remove(size() - 1);
}
// ❌ Stack shouldn't allow insertion at arbitrary positions
// But inherits add(index, element) from ArrayList
}
// ✅ BETTER: Composition over inheritance
public class Stack<T> {
private List<T> elements = new ArrayList<>();
public T push(T item) {
elements.add(item);
return item;
}
public T pop() {
if (elements.isEmpty()) {
throw new IllegalStateException("Stack is empty");
}
return elements.remove(elements.size() - 1);
}
public T peek() {
if (elements.isEmpty()) {
throw new IllegalStateException("Stack is empty");
}
return elements.get(elements.size() - 1);
}
public boolean isEmpty() {
return elements.isEmpty();
}
public int size() {
return elements.size();
}
}
🛠️ Refactoring to LSP
Step-by-Step Process
- Identify Problematic Inheritance: Find classes that can't substitute their parents
- Analyze Contracts: Check preconditions, postconditions, and invariants
- Extract Common Interface: Create interfaces for common behavior
- Separate Hierarchies: Split into appropriate inheritance trees
- Use Composition: Consider composition over inheritance
- Test Substitutability: Verify all subclasses work in parent's place
Example Refactoring
// BEFORE: LSP Violation
public class Document {
protected boolean isReadOnly;
public void save() {
if (isReadOnly) {
throw new UnsupportedOperationException("Cannot save read-only document");
}
// Save logic
}
public void edit(String content) {
if (isReadOnly) {
throw new UnsupportedOperationException("Cannot edit read-only document");
}
// Edit logic
}
}
public class ReadOnlyDocument extends Document {
public ReadOnlyDocument() {
this.isReadOnly = true;
}
// ❌ These methods throw exceptions - violates LSP
@Override
public void save() {
throw new UnsupportedOperationException("Read-only document cannot be saved");
}
@Override
public void edit(String content) {
throw new UnsupportedOperationException("Read-only document cannot be edited");
}
}
// AFTER: LSP Compliant
public interface Document {
String getContent();
String getTitle();
LocalDateTime getCreatedDate();
}
public interface EditableDocument extends Document {
void edit(String content);
void save();
LocalDateTime getLastModified();
}
public class StandardDocument implements EditableDocument {
private String content;
private String title;
private LocalDateTime createdDate;
private LocalDateTime lastModified;
// All methods implemented properly
@Override
public void edit(String content) {
this.content = content;
this.lastModified = LocalDateTime.now();
}
@Override
public void save() {
// Save implementation
System.out.println("Document saved");
}
// Other methods...
}
public class ReadOnlyDocument implements Document {
private final String content;
private final String title;
private final LocalDateTime createdDate;
public ReadOnlyDocument(String title, String content) {
this.title = title;
this.content = content;
this.createdDate = LocalDateTime.now();
}
// Only implements methods it can actually support
@Override
public String getContent() { return content; }
@Override
public String getTitle() { return title; }
@Override
public LocalDateTime getCreatedDate() { return createdDate; }
}
🎓 Practice Exercise
Exercise: Fix the Employee Hierarchy
Here's a hierarchy that violates LSP. Can you refactor it?
public abstract class Employee {
protected String name;
protected double salary;
public abstract double calculatePay();
public abstract void clockIn();
public abstract void clockOut();
public abstract int getVacationDays();
}
public class FullTimeEmployee extends Employee {
@Override
public double calculatePay() {
return salary;
}
@Override
public void clockIn() {
System.out.println("Full-time employee clocked in");
}
@Override
public void clockOut() {
System.out.println("Full-time employee clocked out");
}
@Override
public int getVacationDays() {
return 20;
}
}
public class Contractor extends Employee {
private double hourlyRate;
private int hoursWorked;
@Override
public double calculatePay() {
return hourlyRate * hoursWorked;
}
@Override
public void clockIn() {
// ❌ Contractors don't clock in/out
throw new UnsupportedOperationException("Contractors don't clock in");
}
@Override
public void clockOut() {
// ❌ Contractors don't clock in/out
throw new UnsupportedOperationException("Contractors don't clock out");
}
@Override
public int getVacationDays() {
// ❌ Contractors don't get vacation days
return 0; // or throw exception?
}
}
Solution Approach
- Extract common
Worker
interface - Create separate interfaces for different capabilities
- Implement appropriate combinations in concrete classes
- Use composition where inheritance doesn't fit
📚 Summary
The Liskov Substitution Principle ensures that inheritance hierarchies are logically sound and behaviorally consistent. By following LSP:
- ✅ Reliable Polymorphism: Subclasses work wherever parent classes work
- ✅ Predictable Behavior: No surprises when using subclasses
- ✅ Maintainable Code: Changes don't break existing functionality
- ✅ Better Design: Leads to more logical inheritance hierarchies
- ✅ Easier Testing: Consistent behavior across implementations
Remember: Subclasses should be substitutable for their parent classes without altering program correctness!
The key is to ensure that inheritance represents a true "is-a" relationship with consistent behavior, not just code reuse. When in doubt, favor composition over inheritance.