Liskov Substitution Principle (LSP)
“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.” — Barbara Liskov
In simpler terms: derived classes must be substitutable for their base classes. Code that works with a base class should work with any derived class without knowing the difference.
Understanding LSP
If you have:
void DoSomething(Animal animal) { animal.Move(); }
Then calling DoSomething(new Dog()) should work just as well as DoSomething(new Cat()). The method shouldn’t need to check what type of animal it received.
Classic Violation: Rectangle-Square Problem
// Base class
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int GetArea() => Width * Height;
}
// Square IS-A Rectangle (mathematically)
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value; // Keep it square!
}
}
public override int Height
{
get => base.Height;
set
{
base.Height = value;
base.Width = value; // Keep it square!
}
}
}
// This code works with Rectangle...
public void ResizeRectangle(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
Console.WriteLine($"Area: {rect.GetArea()}"); // Expected: 50
}
// ...but breaks with Square!
var square = new Square();
ResizeRectangle(square);
// Area: 100 (not 50!) - Square's behavior is different
Problem: Square changes behavior - setting Width also sets Height. Code expecting Rectangle behavior breaks.
Another Classic Violation: Birds That Can’t Fly
// BAD: Not all birds can fly!
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("Flying high!");
}
public virtual int GetAltitude() => 1000;
}
public class Penguin : Bird
{
public override void Fly()
{
throw new NotImplementedException("Penguins can't fly!");
}
public override int GetAltitude() => 0;
}
// This code expects all birds to fly
public void ReleaseBird(Bird bird)
{
bird.Fly(); // Crashes with Penguin!
}
var penguin = new Penguin();
ReleaseBird(penguin); // NotImplementedException!
Applying LSP: Better Bird Design
// GOOD: Separate interfaces for different capabilities
public interface IAnimal
{
void Move();
}
public interface IFlyer
{
void Fly();
int GetAltitude();
}
public interface ISwimmer
{
void Swim();
int GetDepth();
}
// Base class with common behavior
public abstract class Bird : IAnimal
{
public string Name { get; set; }
public abstract void Move();
}
// Eagle can fly
public class Eagle : Bird, IFlyer
{
public override void Move() => Fly();
public void Fly() => Console.WriteLine($"{Name} soars through the sky");
public int GetAltitude() => 8000;
}
// Duck can fly AND swim
public class Duck : Bird, IFlyer, ISwimmer
{
public override void Move() => Fly();
public void Fly() => Console.WriteLine($"{Name} flaps wings");
public int GetAltitude() => 2000;
public void Swim() => Console.WriteLine($"{Name} paddles");
public int GetDepth() => 3;
}
// Penguin can only swim
public class Penguin : Bird, ISwimmer
{
public override void Move() => Swim();
public void Swim() => Console.WriteLine($"{Name} dives into water");
public int GetDepth() => 50;
}
// Now type-safe methods
public void ReleaseFlyingBird(IFlyer flyer)
{
flyer.Fly(); // Safe - only accepts things that can fly
}
public void ReleaseSwimmingBird(ISwimmer swimmer)
{
swimmer.Swim(); // Safe - only accepts things that can swim
}
// Usage
var eagle = new Eagle { Name = "Eddie" };
var penguin = new Penguin { Name = "Pete" };
ReleaseFlyingBird(eagle); // Works!
// ReleaseFlyingBird(penguin); // Compile error - Penguin doesn't implement IFlyer
ReleaseSwimmingBird(penguin); // Works!
LSP Rules (Contract Rules)
1. Preconditions Cannot Be Strengthened
// Base class accepts any positive amount
public class BankAccount
{
public virtual void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive");
// ... deposit logic
}
}
// BAD: Derived class adds more restrictions
public class PremiumAccount : BankAccount
{
public override void Deposit(decimal amount)
{
if (amount < 100) // Stronger precondition!
throw new ArgumentException("Minimum deposit is $100");
base.Deposit(amount);
}
}
// Code that works with BankAccount breaks with PremiumAccount
void MakeDeposit(BankAccount account)
{
account.Deposit(50); // Valid for BankAccount, throws for PremiumAccount!
}
2. Postconditions Cannot Be Weakened
// Base class guarantees non-null return
public class UserRepository
{
public virtual User GetById(int id)
{
var user = _db.Users.Find(id);
return user ?? throw new NotFoundException();
}
}
// BAD: Derived class weakens the guarantee
public class CachedUserRepository : UserRepository
{
public override User GetById(int id)
{
// Might return null from cache!
return _cache.Get<User>($"user_{id}");
}
}
// Code expects non-null, but might get null
void ProcessUser(UserRepository repo)
{
var user = repo.GetById(1);
Console.WriteLine(user.Name); // NullReferenceException possible!
}
3. Invariants Must Be Preserved
// Base class maintains balance >= 0
public class SavingsAccount
{
protected decimal _balance;
public decimal Balance
{
get => _balance;
protected set => _balance = Math.Max(0, value); // Invariant: never negative
}
public virtual void Withdraw(decimal amount)
{
if (amount > _balance)
throw new InvalidOperationException("Insufficient funds");
_balance -= amount;
}
}
// BAD: Derived class breaks the invariant
public class OverdraftAccount : SavingsAccount
{
public override void Withdraw(decimal amount)
{
_balance -= amount; // Can go negative! Breaks invariant.
}
}
Real-World LSP Example
// Good LSP design for payment processing
public interface IPaymentMethod
{
Task<PaymentResult> ProcessAsync(decimal amount);
bool SupportsRefund { get; }
Task<RefundResult> RefundAsync(decimal amount);
}
public class CreditCardPayment : IPaymentMethod
{
public bool SupportsRefund => true;
public async Task<PaymentResult> ProcessAsync(decimal amount)
{
// Process credit card...
return new PaymentResult { Success = true };
}
public async Task<RefundResult> RefundAsync(decimal amount)
{
// Process refund...
return new RefundResult { Success = true };
}
}
public class CryptoPayment : IPaymentMethod
{
public bool SupportsRefund => false; // Crypto can't be refunded!
public async Task<PaymentResult> ProcessAsync(decimal amount)
{
// Process crypto payment...
return new PaymentResult { Success = true };
}
public async Task<RefundResult> RefundAsync(decimal amount)
{
// Instead of throwing, return appropriate result
return new RefundResult
{
Success = false,
Reason = "Cryptocurrency payments cannot be refunded"
};
}
}
// Consumer checks capability before using
public class PaymentService
{
public async Task ProcessRefund(IPaymentMethod payment, decimal amount)
{
if (!payment.SupportsRefund)
{
// Handle gracefully instead of crashing
throw new InvalidOperationException(
"This payment method does not support refunds");
}
await payment.RefundAsync(amount);
}
}
LSP Checklist
When creating a derived class, verify:
| Check | Question |
|---|---|
| Signature Compatibility | Do method signatures match? |
| Preconditions | Are input requirements same or weaker? |
| Postconditions | Are output guarantees same or stronger? |
| Invariants | Are class rules preserved? |
| Exception Compatibility | Are thrown exceptions compatible? |
| History Constraint | Is state change behavior consistent? |
Interview Tips
Common Questions:
- “Explain LSP with an example”
- “What’s wrong with Square inheriting from Rectangle?”
- “How do you detect LSP violations?”
Key Points:
- If it looks like inheritance but breaks substitutability, use composition instead
- Throwing
NotImplementedExceptionin overrides is usually an LSP violation - Type checks (
if (x is ConcreteType)) often indicate LSP problems - Design interfaces around behavior, not hierarchies
Red Flags:
- Methods that throw
NotImplementedException - Type checking with
isorasbefore calling methods - Empty override implementations
- Subclasses that restrict what the base allows