🔄 Understanding the Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is the third principle in the SOLID design principles, formulated by Barbara Liskov in 1987. It states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application.
In simpler terms, if class B is a subtype of class A, then we should be able to replace A with B without disrupting the behavior of our program. This means that subclasses should extend the functionality of their parent class without changing or removing the parent's behavior.
When LSP is violated, the consequences can be significant:
- Unexpected Behavior: Substituting a derived class for its base class leads to unexpected results, making the code unpredictable and unreliable.
- Broken Polymorphism: The core benefit of polymorphism is lost when derived classes don't properly implement the interface contract of their base class.
- Complex Client Code: Clients need to know about specific subclass implementations and handle them differently, violating the abstraction principle.
- Runtime Errors: Type checking and casting become necessary, leading to potential runtime exceptions and making the code more error-prone.
- Testing Difficulties: Each subclass requires different testing approaches since they can't be treated uniformly, increasing test complexity.
- Maintenance Overhead: Changes to base classes or interfaces require careful analysis of all derived classes to ensure LSP compliance.
By adhering to LSP, you ensure that your inheritance hierarchies are well-designed, your polymorphic code works correctly, and your abstractions remain meaningful and reliable.
💻 Examples of LSP in Action
Let's explore how LSP can be applied in real-world scenarios using multiple programming languages. These examples demonstrate common violations and how to refactor them for proper substitutability.
Basic Example: Rectangle and Square
// ❌ Violates LSP - Square changes behavior of Rectangle
public class Rectangle {
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public double CalculateArea() {
return Width * Height;
}
}
public class Square : Rectangle {
public override double Width {
get => base.Width;
set {
base.Width = value;
base.Height = value; // Unexpected side effect!
}
}
public override double Height {
get => base.Height;
set {
base.Width = value; // Unexpected side effect!
base.Height = value;
}
}
}
// This violates LSP because:
// Rectangle rect = new Square();
// rect.Width = 5;
// rect.Height = 10;
// Console.WriteLine(rect.CalculateArea()); // Expected: 50, Actual: 100
// ✅ Follows LSP - Use composition or redesign the hierarchy
public abstract class Shape {
public abstract double CalculateArea();
}
public class Rectangle : Shape {
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea() {
return Width * Height;
}
}
public class Square : Shape {
public double Side { get; set; }
public override double CalculateArea() {
return Side * Side;
}
}
// Alternative approach using immutable objects
public abstract class ImmutableShape {
public abstract double CalculateArea();
}
public class ImmutableRectangle : ImmutableShape {
public double Width { get; }
public double Height { get; }
public ImmutableRectangle(double width, double height) {
Width = width;
Height = height;
}
public override double CalculateArea() {
return Width * Height;
}
}
public class ImmutableSquare : ImmutableShape {
public double Side { get; }
public ImmutableSquare(double side) {
Side = side;
}
public override double CalculateArea() {
return Side * Side;
}
}
Python Example: Bird Hierarchy
# ❌ Violates LSP - Not all birds can fly
class Bird:
def fly(self):
print("Flying through the sky")
def make_sound(self):
print("Generic bird sound")
class Duck(Bird):
def fly(self):
print("Duck flying")
def make_sound(self):
print("Quack!")
class Ostrich(Bird):
def fly(self):
raise NotImplementedError("Ostriches cannot fly!") # LSP violation!
def make_sound(self):
print("Ostrich sound")
# This breaks when we try to use polymorphism:
# birds = [Duck(), Ostrich()]
# for bird in birds:
# bird.fly() # This will crash when it reaches Ostrich!
# ✅ Follows LSP - Redesign hierarchy based on capabilities
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def make_sound(self):
pass
@abstractmethod
def move(self):
pass
class FlyingBird(Bird):
@abstractmethod
def fly(self):
pass
def move(self):
self.fly()
class FlightlessBird(Bird):
@abstractmethod
def walk(self):
pass
def move(self):
self.walk()
class Duck(FlyingBird):
def fly(self):
print("Duck flying through the air")
def make_sound(self):
print("Quack!")
class Eagle(FlyingBird):
def fly(self):
print("Eagle soaring high")
def make_sound(self):
print("Screech!")
class Ostrich(FlightlessBird):
def walk(self):
print("Ostrich running fast")
def make_sound(self):
print("Deep ostrich call")
class Penguin(FlightlessBird):
def walk(self):
print("Penguin waddling")
def swim(self):
print("Penguin swimming gracefully")
def make_sound(self):
print("Penguin chirp")
def move(self):
# Penguins can both walk and swim
print("Penguin moving: ", end="")
self.walk()
# Now we can use polymorphism safely:
# flying_birds = [Duck(), Eagle()]
# for bird in flying_birds:
# bird.fly() # Safe - all flying birds can fly
#
# all_birds = [Duck(), Eagle(), Ostrich(), Penguin()]
# for bird in all_birds:
# bird.make_sound() # Safe - all birds make sounds
# bird.move() # Safe - all birds can move in their own way
JavaScript Example: Payment Processing
// ❌ Violates LSP - Different error handling behaviors
class PaymentProcessor {
processPayment(amount) {
if (amount <= 0) {
throw new Error('Invalid amount');
}
console.log(`Processing payment of $${amount}`);
return { success: true, transactionId: Date.now() };
}
refundPayment(transactionId) {
console.log(`Refunding transaction ${transactionId}`);
return { success: true };
}
}
class CreditCardProcessor extends PaymentProcessor {
processPayment(amount) {
if (amount <= 0) {
throw new Error('Invalid amount');
}
if (amount > 10000) {
throw new Error('Amount exceeds credit limit');
}
console.log(`Processing credit card payment of $${amount}`);
return { success: true, transactionId: `CC_${Date.now()}` };
}
}
class GiftCardProcessor extends PaymentProcessor {
constructor(balance) {
super();
this.balance = balance;
}
processPayment(amount) {
if (amount <= 0) {
throw new Error('Invalid amount');
}
if (amount > this.balance) {
// LSP violation - returns failure instead of throwing like parent
return { success: false, error: 'Insufficient balance' };
}
this.balance -= amount;
console.log(`Processing gift card payment of $${amount}`);
return { success: true, transactionId: `GC_${Date.now()}` };
}
refundPayment(transactionId) {
// LSP violation - doesn't actually process refunds
console.log(`Gift cards cannot be refunded`);
return { success: false, error: 'Refunds not supported' };
}
}
// ✅ Follows LSP - Consistent behavior and error handling
class PaymentResult {
constructor(success, transactionId = null, error = null) {
this.success = success;
this.transactionId = transactionId;
this.error = error;
}
}
class BasePaymentProcessor {
processPayment(amount) {
if (amount <= 0) {
return new PaymentResult(false, null, 'Invalid amount');
}
return this.doProcessPayment(amount);
}
doProcessPayment(amount) {
throw new Error('doProcessPayment must be implemented by subclass');
}
refundPayment(transactionId) {
if (!transactionId) {
return new PaymentResult(false, null, 'Invalid transaction ID');
}
return this.doRefundPayment(transactionId);
}
doRefundPayment(transactionId) {
throw new Error('doRefundPayment must be implemented by subclass');
}
canProcessAmount(amount) {
return true; // Default implementation
}
supportsRefunds() {
return true; // Default implementation
}
}
class ImprovedCreditCardProcessor extends BasePaymentProcessor {
doProcessPayment(amount) {
if (amount > 10000) {
return new PaymentResult(false, null, 'Amount exceeds credit limit');
}
console.log(`Processing credit card payment of $${amount}`);
return new PaymentResult(true, `CC_${Date.now()}`);
}
doRefundPayment(transactionId) {
console.log(`Refunding credit card transaction ${transactionId}`);
return new PaymentResult(true, `REF_${Date.now()}`);
}
canProcessAmount(amount) {
return amount <= 10000;
}
}
class ImprovedGiftCardProcessor extends BasePaymentProcessor {
constructor(balance) {
super();
this.balance = balance;
}
doProcessPayment(amount) {
if (amount > this.balance) {
return new PaymentResult(false, null, 'Insufficient balance');
}
this.balance -= amount;
console.log(`Processing gift card payment of $${amount}`);
return new PaymentResult(true, `GC_${Date.now()}`);
}
doRefundPayment(transactionId) {
return new PaymentResult(false, null, 'Gift card refunds not supported');
}
canProcessAmount(amount) {
return amount <= this.balance;
}
supportsRefunds() {
return false;
}
}
// Now all processors can be used interchangeably:
function processPayments(processors, amount) {
processors.forEach(processor => {
if (processor.canProcessAmount(amount)) {
const result = processor.processPayment(amount);
console.log(`Payment result:`, result);
} else {
console.log(`Processor cannot handle amount: $${amount}`);
}
});
}
const processors = [
new ImprovedCreditCardProcessor(),
new ImprovedGiftCardProcessor(500)
];
processPayments(processors, 100); // Works consistently for all processors
Java Example: File Operations
// ❌ Violates LSP - ReadOnlyFile changes expected behavior
public abstract class FileOperations {
protected String fileName;
public FileOperations(String fileName) {
this.fileName = fileName;
}
public abstract String read();
public abstract void write(String content);
public abstract void delete();
}
public class RegularFile extends FileOperations {
public RegularFile(String fileName) {
super(fileName);
}
public String read() {
System.out.println("Reading from " + fileName);
return "file content";
}
public void write(String content) {
System.out.println("Writing to " + fileName + ": " + content);
}
public void delete() {
System.out.println("Deleting " + fileName);
}
}
public class ReadOnlyFile extends FileOperations {
public ReadOnlyFile(String fileName) {
super(fileName);
}
public String read() {
System.out.println("Reading from read-only " + fileName);
return "read-only content";
}
public void write(String content) {
// LSP violation - throws exception instead of performing operation
throw new UnsupportedOperationException("Cannot write to read-only file");
}
public void delete() {
// LSP violation - throws exception instead of performing operation
throw new UnsupportedOperationException("Cannot delete read-only file");
}
}
// ✅ Follows LSP - Redesign hierarchy based on capabilities
public interface Readable {
String read();
}
public interface Writable {
void write(String content);
}
public interface Deletable {
void delete();
}
public abstract class BaseFile implements Readable {
protected String fileName;
public BaseFile(String fileName) {
this.fileName = fileName;
}
public String getFileName() {
return fileName;
}
}
public class RegularFile extends BaseFile implements Writable, Deletable {
public RegularFile(String fileName) {
super(fileName);
}
@Override
public String read() {
System.out.println("Reading from " + fileName);
return "file content";
}
@Override
public void write(String content) {
System.out.println("Writing to " + fileName + ": " + content);
}
@Override
public void delete() {
System.out.println("Deleting " + fileName);
}
}
public class ReadOnlyFile extends BaseFile {
public ReadOnlyFile(String fileName) {
super(fileName);
}
@Override
public String read() {
System.out.println("Reading from read-only " + fileName);
return "read-only content";
}
}
public class SystemFile extends BaseFile implements Writable {
public SystemFile(String fileName) {
super(fileName);
}
@Override
public String read() {
System.out.println("Reading system file " + fileName);
return "system file content";
}
@Override
public void write(String content) {
System.out.println("Writing to system file " + fileName + ": " + content);
}
// Note: SystemFile is not Deletable - system files cannot be deleted
}
// Usage with proper abstraction
public class FileManager {
public void readFiles(List files) {
for (Readable file : files) {
String content = file.read(); // Safe - all Readable files can be read
System.out.println("Content: " + content);
}
}
public void writeToFiles(List files, String content) {
for (Writable file : files) {
file.write(content); // Safe - all Writable files can be written to
}
}
public void deleteFiles(List files) {
for (Deletable file : files) {
file.delete(); // Safe - all Deletable files can be deleted
}
}
}
🌍 Real-World LSP Examples
Let's examine complex, real-world scenarios where LSP compliance is crucial for maintainable and reliable software systems.
Example 1: Database Connection Pool
// ❌ Violates LSP - Different connection types have inconsistent behavior
public abstract class DatabaseConnection {
public abstract void Connect();
public abstract void Disconnect();
public abstract void ExecuteQuery(string sql);
public abstract void BeginTransaction();
public abstract void CommitTransaction();
public abstract void RollbackTransaction();
}
public class SqlServerConnection : DatabaseConnection {
public override void Connect() {
Console.WriteLine("Connecting to SQL Server");
}
public override void Disconnect() {
Console.WriteLine("Disconnecting from SQL Server");
}
public override void ExecuteQuery(string sql) {
Console.WriteLine($"Executing SQL Server query: {sql}");
}
public override void BeginTransaction() {
Console.WriteLine("Beginning SQL Server transaction");
}
public override void CommitTransaction() {
Console.WriteLine("Committing SQL Server transaction");
}
public override void RollbackTransaction() {
Console.WriteLine("Rolling back SQL Server transaction");
}
}
public class NoSqlConnection : DatabaseConnection {
public override void Connect() {
Console.WriteLine("Connecting to NoSQL database");
}
public override void Disconnect() {
Console.WriteLine("Disconnecting from NoSQL database");
}
public override void ExecuteQuery(string sql) {
// LSP violation - NoSQL doesn't use SQL!
throw new NotSupportedException("NoSQL doesn't support SQL queries");
}
public override void BeginTransaction() {
// LSP violation - Many NoSQL databases don't support transactions
throw new NotSupportedException("NoSQL doesn't support transactions");
}
public override void CommitTransaction() {
throw new NotSupportedException("NoSQL doesn't support transactions");
}
public override void RollbackTransaction() {
throw new NotSupportedException("NoSQL doesn't support transactions");
}
}
// ✅ Follows LSP - Design based on actual capabilities
public interface IConnection {
void Connect();
void Disconnect();
bool IsConnected { get; }
}
public interface IQueryable {
TResult ExecuteQuery(IQuery query);
}
public interface ITransactional {
ITransaction BeginTransaction();
}
public interface IQuery {
TResult Execute(IConnection connection);
}
public interface ITransaction : IDisposable {
void Commit();
void Rollback();
bool IsActive { get; }
}
public abstract class BaseConnection : IConnection {
protected bool _isConnected;
public bool IsConnected => _isConnected;
public abstract void Connect();
public abstract void Disconnect();
}
public class SqlServerConnection : BaseConnection, IQueryable, ITransactional {
private ITransaction _currentTransaction;
public override void Connect() {
Console.WriteLine("Connecting to SQL Server");
_isConnected = true;
}
public override void Disconnect() {
Console.WriteLine("Disconnecting from SQL Server");
_isConnected = false;
}
public TResult ExecuteQuery(IQuery query) {
if (!IsConnected) {
throw new InvalidOperationException("Not connected to database");
}
return query.Execute(this);
}
public ITransaction BeginTransaction() {
if (!IsConnected) {
throw new InvalidOperationException("Not connected to database");
}
_currentTransaction = new SqlTransaction();
Console.WriteLine("Beginning SQL Server transaction");
return _currentTransaction;
}
}
public class NoSqlConnection : BaseConnection, IQueryable {
public override void Connect() {
Console.WriteLine("Connecting to NoSQL database");
_isConnected = true;
}
public override void Disconnect() {
Console.WriteLine("Disconnecting from NoSQL database");
_isConnected = false;
}
public TResult ExecuteQuery(IQuery query) {
if (!IsConnected) {
throw new InvalidOperationException("Not connected to database");
}
return query.Execute(this);
}
// Note: NoSqlConnection doesn't implement ITransactional because it doesn't support transactions
}
public class InMemoryConnection : BaseConnection, IQueryable, ITransactional {
private readonly Dictionary _data = new();
private ITransaction _currentTransaction;
public override void Connect() {
Console.WriteLine("Connecting to in-memory database");
_isConnected = true;
}
public override void Disconnect() {
Console.WriteLine("Disconnecting from in-memory database");
_isConnected = false;
}
public TResult ExecuteQuery(IQuery query) {
if (!IsConnected) {
throw new InvalidOperationException("Not connected to database");
}
return query.Execute(this);
}
public ITransaction BeginTransaction() {
if (!IsConnected) {
throw new InvalidOperationException("Not connected to database");
}
_currentTransaction = new InMemoryTransaction();
Console.WriteLine("Beginning in-memory transaction");
return _currentTransaction;
}
}
// Usage that respects LSP
public class DatabaseService {
public void ProcessConnections(List connections) {
foreach (var connection in connections) {
connection.Connect(); // Safe - all connections can connect
Console.WriteLine($"Connection status: {connection.IsConnected}");
connection.Disconnect(); // Safe - all connections can disconnect
}
}
public void ExecuteQueries(List queryableConnections, IQuery query) {
foreach (var connection in queryableConnections) {
var result = connection.ExecuteQuery(query); // Safe - all queryable connections support queries
Console.WriteLine($"Query result: {result}");
}
}
public void PerformTransactions(List transactionalConnections) {
foreach (var connection in transactionalConnections) {
using (var transaction = connection.BeginTransaction()) {
// Perform transaction operations
transaction.Commit(); // Safe - all transactional connections support transactions
}
}
}
}
Example 2: Media Player System
# ❌ Violates LSP - Different media types have incompatible interfaces
class MediaPlayer:
def play(self):
raise NotImplementedError
def pause(self):
raise NotImplementedError
def stop(self):
raise NotImplementedError
def seek(self, position):
raise NotImplementedError
def get_duration(self):
raise NotImplementedError
class AudioPlayer(MediaPlayer):
def __init__(self, file_path):
self.file_path = file_path
self.is_playing = False
self.position = 0
self.duration = 180 # 3 minutes
def play(self):
print(f"Playing audio: {self.file_path}")
self.is_playing = True
def pause(self):
print("Pausing audio")
self.is_playing = False
def stop(self):
print("Stopping audio")
self.is_playing = False
self.position = 0
def seek(self, position):
if 0 <= position <= self.duration:
self.position = position
print(f"Seeking to {position} seconds")
else:
raise ValueError("Invalid position")
def get_duration(self):
return self.duration
class LiveStreamPlayer(MediaPlayer):
def __init__(self, stream_url):
self.stream_url = stream_url
self.is_playing = False
def play(self):
print(f"Starting live stream: {self.stream_url}")
self.is_playing = True
def pause(self):
print("Pausing live stream")
self.is_playing = False
def stop(self):
print("Stopping live stream")
self.is_playing = False
def seek(self, position):
# LSP violation - live streams can't seek!
raise NotSupportedError("Cannot seek in live streams")
def get_duration(self):
# LSP violation - live streams don't have duration!
raise NotSupportedError("Live streams don't have duration")
# ✅ Follows LSP - Design based on actual capabilities
from abc import ABC, abstractmethod
from typing import Optional
class Playable(ABC):
@abstractmethod
def play(self):
pass
@abstractmethod
def stop(self):
pass
@abstractmethod
def is_playing(self) -> bool:
pass
class Pausable(ABC):
@abstractmethod
def pause(self):
pass
@abstractmethod
def is_paused(self) -> bool:
pass
class Seekable(ABC):
@abstractmethod
def seek(self, position: int):
pass
@abstractmethod
def get_position(self) -> int:
pass
class HasDuration(ABC):
@abstractmethod
def get_duration(self) -> int:
pass
class VolumeControl(ABC):
@abstractmethod
def set_volume(self, volume: float):
pass
@abstractmethod
def get_volume(self) -> float:
pass
class BaseMediaPlayer(Playable):
def __init__(self, source: str):
self.source = source
self._is_playing = False
def is_playing(self) -> bool:
return self._is_playing
class AudioFilePlayer(BaseMediaPlayer, Pausable, Seekable, HasDuration, VolumeControl):
def __init__(self, file_path: str):
super().__init__(file_path)
self._is_paused = False
self._position = 0
self._duration = 180 # 3 minutes
self._volume = 1.0
def play(self):
print(f"Playing audio file: {self.source}")
self._is_playing = True
self._is_paused = False
def stop(self):
print("Stopping audio file")
self._is_playing = False
self._is_paused = False
self._position = 0
def pause(self):
if self._is_playing:
print("Pausing audio file")
self._is_paused = True
self._is_playing = False
def is_paused(self) -> bool:
return self._is_paused
def seek(self, position: int):
if 0 <= position <= self._duration:
self._position = position
print(f"Seeking to {position} seconds")
else:
raise ValueError("Invalid seek position")
def get_position(self) -> int:
return self._position
def get_duration(self) -> int:
return self._duration
def set_volume(self, volume: float):
if 0.0 <= volume <= 1.0:
self._volume = volume
print(f"Volume set to {volume * 100}%")
else:
raise ValueError("Volume must be between 0.0 and 1.0")
def get_volume(self) -> float:
return self._volume
class LiveStreamPlayer(BaseMediaPlayer, Pausable, VolumeControl):
def __init__(self, stream_url: str):
super().__init__(stream_url)
self._is_paused = False
self._volume = 1.0
def play(self):
print(f"Starting live stream: {self.source}")
self._is_playing = True
self._is_paused = False
def stop(self):
print("Stopping live stream")
self._is_playing = False
self._is_paused = False
def pause(self):
if self._is_playing:
print("Pausing live stream")
self._is_paused = True
self._is_playing = False
def is_paused(self) -> bool:
return self._is_paused
def set_volume(self, volume: float):
if 0.0 <= volume <= 1.0:
self._volume = volume
print(f"Stream volume set to {volume * 100}%")
else:
raise ValueError("Volume must be between 0.0 and 1.0")
def get_volume(self) -> float:
return self._volume
class RadioPlayer(BaseMediaPlayer, VolumeControl):
def __init__(self, frequency: str):
super().__init__(frequency)
self._volume = 1.0
def play(self):
print(f"Tuning to radio frequency: {self.source}")
self._is_playing = True
def stop(self):
print("Stopping radio")
self._is_playing = False
def set_volume(self, volume: float):
if 0.0 <= volume <= 1.0:
self._volume = volume
print(f"Radio volume set to {volume * 100}%")
else:
raise ValueError("Volume must be between 0.0 and 1.0")
def get_volume(self) -> float:
return self._volume
# Usage that respects LSP
class MediaController:
def play_all(self, players: list[Playable]):
"""Play all playable media - works with any Playable implementation"""
for player in players:
player.play()
def pause_all(self, players: list[Pausable]):
"""Pause all pausable media - only works with pausable players"""
for player in players:
if player.is_playing():
player.pause()
def seek_all_to_position(self, players: list[Seekable], position: int):
"""Seek all seekable media - only works with seekable players"""
for player in players:
try:
player.seek(position)
except ValueError as e:
print(f"Seek failed: {e}")
def show_durations(self, players: list[HasDuration]):
"""Show duration for all media with duration - only works with players that have duration"""
for player in players:
duration = player.get_duration()
print(f"Duration: {duration} seconds")
def set_volume_for_all(self, players: list[VolumeControl], volume: float):
"""Set volume for all players with volume control"""
for player in players:
player.set_volume(volume)
# Example usage
audio_player = AudioFilePlayer("song.mp3")
stream_player = LiveStreamPlayer("http://stream.example.com")
radio_player = RadioPlayer("101.5 FM")
controller = MediaController()
# All players can be played
all_players = [audio_player, stream_player, radio_player]
controller.play_all(all_players)
# Only some players can be paused
pausable_players = [audio_player, stream_player] # radio can't be paused
controller.pause_all(pausable_players)
# Only audio files can be seeked
seekable_players = [audio_player]
controller.seek_all_to_position(seekable_players, 30)
# Only audio files have duration
duration_players = [audio_player]
controller.show_durations(duration_players)
# All players have volume control
controller.set_volume_for_all(all_players, 0.8)
Benefits of LSP-Compliant Design
- Predictable Behavior: Subclasses behave consistently with their parent classes, making the code more reliable and easier to understand.
- True Polymorphism: Objects can be used interchangeably through their common interfaces without unexpected behavior.
- Simplified Client Code: Clients can work with abstractions without needing to know about specific implementations.
- Better Testing: All implementations of an interface can be tested using the same test suite, ensuring consistent behavior.
- Easier Maintenance: Changes to interfaces are automatically enforced across all implementations, reducing the risk of breaking changes.
- Enhanced Reusability: Components can be reused in different contexts because they adhere to well-defined contracts.
🎯 LSP Implementation Guidelines
Here are key guidelines and patterns for implementing the Liskov Substitution Principle effectively in your code.
1. Contract Compliance
Ensure that subclasses honor the contracts established by their parent classes or interfaces.
// ✅ Good: Subclass strengthens postconditions, weakens preconditions
public abstract class NumberValidator {
// Precondition: number can be any double
// Postcondition: returns true if valid, false otherwise
public abstract bool IsValid(double number);
}
public class PositiveNumberValidator : NumberValidator {
// Precondition: still accepts any double (not strengthened)
// Postcondition: returns true only for positive numbers (stronger than parent)
public override bool IsValid(double number) {
return number > 0;
}
}
public class NonZeroNumberValidator : NumberValidator {
// Precondition: still accepts any double (not strengthened)
// Postcondition: returns true for any non-zero number (stronger than parent)
public override bool IsValid(double number) {
return number != 0;
}
}
// ❌ Bad: Subclass strengthens preconditions
public class RestrictiveValidator : NumberValidator {
public override bool IsValid(double number) {
// Strengthens precondition - now only accepts numbers between 1 and 100
if (number < 1 || number > 100) {
throw new ArgumentException("Number must be between 1 and 100");
}
return true;
}
}
2. Exception Handling Consistency
Subclasses should not throw new exceptions that are not expected by the parent class contract.
# ✅ Good: Consistent exception handling
class DataProcessor:
def process(self, data: str) -> str:
"""Process data. Raises ValueError for invalid data."""
if not data:
raise ValueError("Data cannot be empty")
return data.upper()
class JsonProcessor(DataProcessor):
def process(self, data: str) -> str:
"""Process JSON data. Raises ValueError for invalid data."""
if not data:
raise ValueError("Data cannot be empty")
import json
try:
# Parse to validate JSON
json.loads(data)
return data.upper()
except json.JSONDecodeError:
# Still raises ValueError as expected by parent contract
raise ValueError("Invalid JSON format")
class XmlProcessor(DataProcessor):
def process(self, data: str) -> str:
"""Process XML data. Raises ValueError for invalid data."""
if not data:
raise ValueError("Data cannot be empty")
import xml.etree.ElementTree as ET
try:
# Parse to validate XML
ET.fromstring(data)
return data.upper()
except ET.ParseError:
# Still raises ValueError as expected by parent contract
raise ValueError("Invalid XML format")
# ❌ Bad: Introduces unexpected exceptions
class ProblematicProcessor(DataProcessor):
def process(self, data: str) -> str:
if not data:
raise ValueError("Data cannot be empty")
# LSP violation - introduces new exception type not in parent contract
if len(data) > 1000:
raise RuntimeError("Data too large") # Unexpected exception!
return data.upper()
3. Interface Segregation with LSP
Design interfaces based on client needs and ensure all implementations can fulfill the complete interface contract.
// ✅ Good: Well-segregated interfaces that support LSP
class Drawable {
draw() {
throw new Error('draw method must be implemented');
}
}
class Resizable {
resize(width, height) {
throw new Error('resize method must be implemented');
}
getWidth() {
throw new Error('getWidth method must be implemented');
}
getHeight() {
throw new Error('getHeight method must be implemented');
}
}
class Colorable {
setColor(color) {
throw new Error('setColor method must be implemented');
}
getColor() {
throw new Error('getColor method must be implemented');
}
}
class Circle extends Drawable {
constructor(radius) {
super();
this.radius = radius;
}
draw() {
console.log(`Drawing circle with radius ${this.radius}`);
}
}
class ColoredCircle extends Circle {
constructor(radius, color) {
super(radius);
this.color = color;
}
draw() {
console.log(`Drawing ${this.color} circle with radius ${this.radius}`);
}
}
// Multiple inheritance through mixins
class ResizableColoredRectangle extends Drawable {
constructor(width, height, color) {
super();
this.width = width;
this.height = height;
this.color = color;
}
draw() {
console.log(`Drawing ${this.color} rectangle ${this.width}x${this.height}`);
}
// Resizable methods
resize(width, height) {
this.width = width;
this.height = height;
}
getWidth() {
return this.width;
}
getHeight() {
return this.height;
}
// Colorable methods
setColor(color) {
this.color = color;
}
getColor() {
return this.color;
}
}
// Usage that respects LSP
class ShapeRenderer {
renderShapes(shapes) {
// All shapes implement Drawable, so this is safe
shapes.forEach(shape => shape.draw());
}
resizeShapes(resizableShapes, newWidth, newHeight) {
// Only works with shapes that implement Resizable
resizableShapes.forEach(shape => {
if (typeof shape.resize === 'function') {
shape.resize(newWidth, newHeight);
}
});
}
changeColors(colorableShapes, newColor) {
// Only works with shapes that implement Colorable
colorableShapes.forEach(shape => {
if (typeof shape.setColor === 'function') {
shape.setColor(newColor);
}
});
}
}
4. Design by Contract
Use explicit contracts to ensure LSP compliance through preconditions, postconditions, and invariants.
// ✅ Good: Explicit contracts ensure LSP compliance
public interface BankAccount {
/**
* Withdraws money from the account.
*
* Precondition: amount > 0 && amount <= getBalance()
* Postcondition: getBalance() == old(getBalance()) - amount
*
* @param amount The amount to withdraw
* @throws IllegalArgumentException if amount <= 0
* @throws InsufficientFundsException if amount > getBalance()
*/
void withdraw(double amount) throws InsufficientFundsException;
/**
* Deposits money into the account.
*
* Precondition: amount > 0
* Postcondition: getBalance() == old(getBalance()) + amount
*
* @param amount The amount to deposit
* @throws IllegalArgumentException if amount <= 0
*/
void deposit(double amount);
/**
* Gets the current balance.
*
* Postcondition: return value >= 0
*
* @return The current balance
*/
double getBalance();
}
public class SavingsAccount implements BankAccount {
private double balance;
public SavingsAccount(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
this.balance = initialBalance;
}
@Override
public void withdraw(double amount) throws InsufficientFundsException {
// Enforce preconditions
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (amount > balance) {
throw new InsufficientFundsException("Insufficient funds");
}
// Perform operation
balance -= amount;
// Postcondition is automatically satisfied
}
@Override
public void deposit(double amount) {
// Enforce precondition
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
// Perform operation
balance += amount;
// Postcondition is automatically satisfied
}
@Override
public double getBalance() {
return balance; // Postcondition: always >= 0
}
}
public class CheckingAccount implements BankAccount {
private double balance;
private final double overdraftLimit;
public CheckingAccount(double initialBalance, double overdraftLimit) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
if (overdraftLimit < 0) {
throw new IllegalArgumentException("Overdraft limit cannot be negative");
}
this.balance = initialBalance;
this.overdraftLimit = overdraftLimit;
}
@Override
public void withdraw(double amount) throws InsufficientFundsException {
// Enforce preconditions (weakened - allows overdraft)
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (amount > balance + overdraftLimit) {
throw new InsufficientFundsException("Exceeds overdraft limit");
}
// Perform operation
balance -= amount;
// Postcondition still satisfied: balance change is correct
}
@Override
public void deposit(double amount) {
// Same implementation as SavingsAccount
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
balance += amount;
}
@Override
public double getBalance() {
// May return negative value, but contract allows this for checking accounts
return balance;
}
public double getAvailableBalance() {
return balance + overdraftLimit;
}
}
// Usage that works with any BankAccount implementation
public class BankingService {
public void transferMoney(BankAccount from, BankAccount to, double amount)
throws InsufficientFundsException {
// This works with any BankAccount implementation due to LSP
from.withdraw(amount); // Contract guarantees this behaves consistently
to.deposit(amount); // Contract guarantees this behaves consistently
}
public void printBalances(List accounts) {
for (BankAccount account : accounts) {
// Contract guarantees getBalance() works consistently
System.out.println("Balance: $" + account.getBalance());
}
}
}
Key LSP Guidelines Summary
- Honor Parent Contracts: Subclasses must fulfill all contracts established by their parent classes.
- Maintain Behavioral Consistency: The behavior of subclasses should be predictable based on the parent class interface.
- Don't Strengthen Preconditions: Subclasses should not require stricter inputs than their parent classes.
- Don't Weaken Postconditions: Subclasses should provide at least the same guarantees as their parent classes.
- Preserve Invariants: Class invariants must be maintained across the inheritance hierarchy.
- Handle Exceptions Consistently: Don't introduce unexpected exception types in subclasses.
- Use Composition When Inheritance Violates LSP: If "is-a" relationship doesn't hold behaviorally, consider "has-a" relationships.