3. Liskov Substitution Principle

Kumar RohitKumar Rohit
5 min read

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:

  1. ElectricCar would return an object that is not compatible with what specified by super class (compile time failures)

  2. We modify the natural functions of ElectricCar to allow custom code for supporting TripPlanner class

  3. We 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.

0
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.