3. Liskov Substitution Principle
Premise
In our previous article on Open-Closed Principle (OCP), we establish how to add new feature by only adding new code. This design, results in a loosely coupled code which allows seamless extensibility and maintainability. Let's take another scenario where we are designing a system around cars and try to push extensibility even further.
Let us suppose that we have usecase where we need to get the range of a trip that can be done by a given car. Therefore, we want a guaruntee that we should always be able to get the available range of a Car
using its calculateRange
method for planning the trip. The input to calculate the current range depends on the amout of fuel left in the car (along with many other factors we aren't getting into). Following the Open-Closed Principle we TripPlanner
class has one fixed implementation which is open for extension(for more Car
types) and closed for modification. The design would like this:
If we observe relationships carefully, we find the class TripPlanner
uses the Car
class's object. Described more specifically in the code below (and here), we see the getRange
method of TripPlanner
uses the calculateRange
method of the Car
class. Therefore, any object of the class Car
and its subclasses must guarantee the contract that the getRange
method from the TripPlanner
class will always be able to use their objects.
// Enforce cars to have method to return mileage
interface Refuelables {
float getMileage();
}
// Car has mileage
abstract class Car implements Refuelables {
float fuel;
abstract float calculateRange();
}
// Petrol car is a car
class PetrolCar extends Car {
public PetrolCar(float fuel) { this.fuel = fuel; }
@Override
public float getMileage() { /* some complex logic*/ return 10f; }
@Override
public float calculateRange() { return this.getMileage() * this.fuel; }
}
// Diesel car is a car
class DieselCar extends Car {
public DieselCar(float fuel) { this.fuel = fuel; }
@Override
public float getMileage() { /* some complex logic*/ return 15; }
@Override
public float calculateRange() { return this.getMileage() * this.fuel; }
}
// TripPlanner class uses Car
class TripPlanner {
public float getRange(Car car){ return car.calculateRange(); }
}
Now imagine, we want to add ElectricCar
to our system as well which do not use fossil fuels. These cars do not consume fuel
, but they hold charge in battery
. By nature, ElectricCar
is not compatible with the above design and if we still try to cram in this Car
type into the system then one of these would happen:
ElectricCar
would return an object that is not compatible with what specified by super class (compile time failures)We modify the natural functions of
ElectricCar
to allow custom code for supportingTripPlanner
classWe throw new exceptions that’s not thrown by the superclass method
Introduction
The scenario that we discussed above is the unintentional violation of Liskov Substitution Principle (LSP). More formally, this principle states:
Subtypes must be substitutable for their base types.
It is easy to fall into the scenario where the future requirements for the concept might not fit the class hierarchy we have created. If the subclasses cannot be substituted freely, then we would be forced to do checks like isInstanceOf
and custom handling of subclasses.
Having conditionals like this makes the code difficult to maintain and extend. Adding and removing features becomes tedious and error-prone. Such design also defeats the purpose of (easy integrations by) introducing a supertype abstraction in the first place!
Let us again try to redesign the system by not only following Open-Closed Principle but Liskov Substitution Principle. We have to ensure that regardless of which subclass of Car
is provided, the getRange
method of TripPlanner
is not broken.
In the above design, we have introduced new subclass FossilCar
and ElectricCar
of Car
. FossilCar
is the parent class for PetrolCar
& DieselCar
while ElectricCar
as parent class for FixedBatteryCar
and SwappableBatteryCar
. Here, we can guaruntee the contract between TripPlanner
and Car
that getRange
method would be able to function regardless of the type of Car
object is passed on to it (while maintaining loose coupling between cars heirarchy and still allowing extensibility)! This is how the sample code (also available here) would look like:
// Enforce fossil cars to have method to return mileage
interface Refuelables { float getMileage(); }
// Car has mileage
abstract class Car { abstract float calculateRange(); }
// Fossil car is a car
abstract class FossilCar extends Car { float fuel; abstract float getMileage(); }
// Petrol car is a fossil car
class PetrolCar extends FossilCar implements Refuelables{
public PetrolCar(float fuel) { this.fuel = fuel; }
@Override
public float getMileage() { /* some complex logic*/ return 10f; }
@Override
public float calculateRange() { return this.getMileage() * this.fuel; }
}
// Diesel car is a fossil car
class DieselCar extends FossilCar {
public DieselCar(float fuel) { this.fuel = fuel; }
@Override
public float getMileage() { /* some complex logic*/ return 15; }
@Override
public float calculateRange() { return this.getMileage() * this.fuel; }
}
// Electric car is a car
abstract class ElectricCar extends Car { float battery; }
// Fixed battery car is an electric car
class FixedBatteryCar extends ElectricCar {
public FixedBatteryCar(float battery) { this.battery = battery; }
@Override
float calculateRange() { /* some complex logic */ return this.battery * 8f; }
}
// Fixed battery car is an electric car
class SwappableBatteryCar extends ElectricCar {
public SwappableBatteryCar(float battery) { this.battery = battery; }
@Override
float calculateRange() { /* some other complex logic */ return this.battery * 6f; }
}
// TripPlanner class uses Car
class TripPlanner {
public float getRange(Car car){ return car.calculateRange(); }
}
Advantages
Consistent Behavior
Objects of derived classes can be used interchangeably with base class objects
Code Flexibility
Promotes scalable and adaptable code, allowing for easier additions of new classes
Caveat/Note
Initial Design Effort
Introducing interfaces or abstract classes may add complexity during the initial design phase
Learning Curve
Developers need to understand and apply proper interface design
Summary
It's not possible to get class heirarchy right as future changes might be disruptive. It is not always guarunteed that the subclasses would behave exactly like their superclasses. For the subclasses to be substitutable, they must behave like their supertype!
The Liskov Substitution Principle, though seemingly abstract, plays a crucial role in creating resilient and scalable software. By fostering consistency among objects, it ensures a codebase that gracefully accommodates change. Devote some time, embrace LSP, and let your code navigate the lanes of adaptability with ease.
Subscribe to my newsletter
Read articles from Kumar Rohit directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Kumar Rohit
Kumar Rohit
I am a Data Engineer by profession and a lifelong learner by passion. To begin, I'd like to share some of the problems that have kept me pondering for a while and the valuable lessons I have learnt along the way.