The Liskov Substitution Principle (LSP) is the “L” in the SOLID acronym and a cornerstone of robust object-oriented design. It provides a critical guideline for creating reliable inheritance hierarchies.
At its core, the principle states:
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 that program (correctness, task performed, etc.).
In simpler terms, a subclass should be able to stand in for its parent class without causing errors or unexpected behavior. If your “child” class can’t do everything its “parent” can, you have a flawed abstraction that will lead to fragile code.
The Analogy: Transportation Breakdown
Imagine a base class, SeaTransport, designed to move passengers over water. It has a method travelAcrossWater().
Now, let’s look at two potential subclasses:
Speedboat: A speedboat is a type of sea transport. It can seamlessly implementtravelAcrossWater().Car: A car is a form of transport, but it’s not sea transport. It cannot fulfill thetravelAcrossWater()contract.
If we try to force Car to inherit from SeaTransport, we violate LSP. A function expecting to move passengers across the sea will fail if you substitute a Car for a Speedboat.
This diagram illustrates the core conflict: while both are forms of transport, they don’t share the same fundamental capabilities required by the parent class.
graph TD;
subgraph LSP Violation
A[SeaTransport <br> travelAcrossWater()] --> B[Speedboat];
A --> C{Car};
end
subgraph LSP Compliant
D[SeaTransport] --> E[Speedboat];
D --> F[Sailboat];
G[LandTransport] --> H[Car];
G --> I[Bus];
end
style C fill:#f9f,stroke:#333,stroke-width:2px
The Code Problem: A Real-World LSP Violation
Let’s examine a more practical example with shipping providers. We have an abstract ShippingProvider class that defines the contract for all shipping companies. It includes methods to ship a package, track it, and cancel the shipment.
We have three implementers:
DHLandAramex: Global carriers that support all three operations.LocalCompany: A small, local delivery service that can ship and track packages but does not have a cancellation feature.
Here’s the initial, flawed structure:
flawed-design/
└── src/
├── providers/
│ ├── ShippingProvider.java
│ ├── DHL.java
│ ├── Aramex.java
│ └── LocalCompany.java
└── Main.java
ShippingProvider.java
// Abstract Parent Class
public abstract class ShippingProvider {
public abstract void shipPackage();
public abstract void trackPackage();
public abstract void cancelShipment();
}
LocalCompany.java (The Problem Child)
public class LocalCompany extends ShippingProvider {
@Override
public void shipPackage() {
System.out.println("LocalCompany is shipping the package.");
}
@Override
public void trackPackage() {
System.out.println("LocalCompany is tracking the package.");
}
@Override
public void cancelShipment() {
// This violates LSP!
throw new UnsupportedOperationException("LocalCompany does not support shipment cancellation.");
}
}
When a client uses this code, they operate under the assumption that any ShippingProvider can handle all three methods. This leads to a runtime disaster.
Main.java
public class Main {
public static void main(String[] args) {
ShippingProvider provider = new LocalCompany();
provider.shipPackage(); // Works fine
provider.trackPackage(); // Works fine
try {
// This line will crash the program!
provider.cancelShipment();
} catch (UnsupportedOperationException e) {
System.err.println("Error: " + e.getMessage());
}
}
}
[!WARNING] Throwing a new, unexpected exception in a subclass method is a classic LSP violation. The client code is written against the parent class’s contract, which promises a
cancelShipmentcapability. The subclass breaks that promise, making it an imperfect substitute.
The Solution: Segregating Interfaces
To fix this, we must remodel our abstractions to be more precise. Not all providers are cancellable, so we shouldn’t force that behavior on them. The solution is to segregate the interfaces based on capability.
- Create a base
ShippingProviderwith only the truly universal methods. - Create a new, more specific
CancellableShippingProviderthat extends the base and adds the cancellation functionality.
Here is the new, robust project structure:
lsp-compliant-design/
└── src/
├── providers/
│ ├── ShippingProvider.java // Base contract
│ ├── CancellableShippingProvider.java // Extended contract
│ ├── DHL.java
│ ├── Aramex.java
│ └── LocalCompany.java
└── Main.java
This class diagram shows the new, compliant hierarchy. LocalCompany now correctly inherits from the base provider, while DHL and Aramex inherit from the more feature-rich cancellable provider.
classDiagram
class ShippingProvider {
<<abstract>>
+shipPackage()
+trackPackage()
}
class CancellableShippingProvider {
<<abstract>>
+cancelShipment()
}
class LocalCompany
class DHL
class Aramex
ShippingProvider <|-- CancellableShippingProvider
ShippingProvider <|-- LocalCompany
CancellableShippingProvider <|-- DHL
CancellableShippingProvider <|-- Aramex
Code Refactoring (Before vs. After)
Let’s see the code changes using diffs.
ShippingProvider.java Refactoring
We remove the cancelShipment method from the base contract.
--- a/ShippingProvider.java
+++ b/ShippingProvider.java
public abstract class ShippingProvider {
public abstract void shipPackage();
public abstract void trackPackage();
- public abstract void cancelShipment();
}
CancellableShippingProvider.java (New File)
This new abstract class extends the base and adds the specific functionality.
// New Abstract Class for cancellable providers
public abstract class CancellableShippingProvider extends ShippingProvider {
public abstract void cancelShipment();
}
LocalCompany.java Refactoring
LocalCompany now extends the base ShippingProvider and no longer needs to deal with a method it can’t implement.
--- a/LocalCompany.java
+++ b/LocalCompany.java
-public class LocalCompany extends ShippingProvider {
+public class LocalCompany extends ShippingProvider { // Correct parent
@Override
public void shipPackage() {
System.out.println("LocalCompany is shipping the package.");
}
@Override
public void trackPackage() {
System.out.println("LocalCompany is tracking the package.");
}
- @Override
- public void cancelShipment() {
- throw new UnsupportedOperationException("LocalCompany does not support shipment cancellation.");
- }
}
DHL.java Refactoring
DHL now extends the more specific CancellableShippingProvider to signal that it supports the full feature set.
--- a/DHL.java
+++ b/DHL.java
-public class DHL extends ShippingProvider {
+public class DHL extends CancellableShippingProvider { // More specific parent
@Override
public void shipPackage() {
System.out.println("DHL is shipping the package.");
}
@Override
public void trackPackage() {
System.out.println("DHL is tracking the package.");
}
@Override
public void cancelShipment() {
System.out.println("DHL has cancelled the shipment.");
}
}
The Safe Client Code
Now, the client code is inherently safe. The compiler itself prevents you from making a mistake.
public class Main {
public static void main(String[] args) {
// This object's type only exposes ship() and track()
ShippingProvider local = new LocalCompany();
local.shipPackage();
local.trackPackage();
// local.cancelShipment(); // COMPILE ERROR! Method does not exist.
// To cancel, you must use a type that guarantees the feature.
CancellableShippingProvider dhl = new DHL();
dhl.shipPackage();
dhl.trackPackage();
dhl.cancelShipment(); // Works perfectly!
}
}
[!TIP] The Benefit of LSP: By adhering to LSP, you create predictable and reliable code. The type system becomes your safety net. You no longer need
instanceofchecks ortry-catchblocks to handle subtype-specific behavior, leading to cleaner, more maintainable, and truly polymorphic code.
Best Practices & Deeper Insights
The Liskov Substitution Principle is about more than just method signatures; it’s about behavioral subtyping. A subtype must not only have the same methods but also behave in a way that is consistent with the parent’s contract.
Here are some rules to follow:
- Preconditions cannot be strengthened in a subtype. A subclass method shouldn’t be more restrictive about its input parameters than the superclass method.
- Postconditions cannot be weakened in a subtype. A subclass method must meet or exceed the guarantees made by the superclass method.
- Invariants of the superclass must be preserved. An invariant is a condition that is always true for an object. A subclass should not change this state in a way that violates the superclass’s assumptions.
- Don’t throw new, unexpected exceptions. As seen in our example, this is a direct violation.
Quiz: Test Your LSP Knowledge
Question: You have a `Rectangle` class with `setWidth(w)` and `setHeight(h)` methods. You create a `Square` subclass that inherits from `Rectangle`. To maintain the property of a square (where width always equals height), you override both setters:
setWidth(w)also sets the height tow.setHeight(h)also sets the width toh.
Does this design violate the Liskov Substitution Principle? Why or why not?
Answer: Yes, this is a classic LSP violation. A function that takes a `Rectangle` object might do the following:
void resizeRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert(r.getWidth() == 5); // This assertion will fail if a Square is passed in!
}
The `Square` subclass changes the behavior of the setters in an unexpected way, breaking the client's reasonable assumptions about how a `Rectangle` works. It is not a valid substitute.