The Liskov Substitution Principle (LSP) Explained in Python.
Towards robust software with SOLID principle 3/5.
This post is part 3 of a series on the SOLID principles.
You can find the second post here.
Before we delve into the reasoning of this principle and how it fits in with the last two principles, let's look at the definition.
The Liskov substitution principle is formally defined as:
Let ฯ(x) be a property provable about objects x of type T. Then ฯ(y) should be true for objects y of type S where S is a subtype of T. Source: Barbara Liskov
Let's face it, this makes no sense to the modern software engineer ๐
Here's a simpler definition:
It should be possible to replace an instance of a superclass with an instance of a subclass without causing breaking changes.
Say you have a subclass which derives from a base class/superclass (inheritance). Let's say you make some changes to the subclass such as change of parameter value types and perhaps also the return type. In this case you would have violated the LSP principle because replacing an instance of the superclass with an instance of the subclass would lead to breaking changes. The code that depends on the base class would expect a return type of str
for example but when you replace the instance with the subclass, it returns an int
. This would could cause the code to crash or raise other errors.
In short, the behavior of the base class must conform to the behavior of the superclass. In general, the function signature (function parameters) and return type must be unchanged in the subclass.
The main purpose of this principle is to ensure that old codebases do not break when new code is introduced. Furthermore, it ensures flexible code.
Code
Let's look at a simple example. We change the scenario slightly from previous posts by considering a car rather than a car dealership๐:
The Car
class defines a car. We initialize it with a name, the number of gears, the speed and the current gear position. The changeGear
function allows changing of gears while the accelerate
function increases the speed of the car by 1. If the gear is in the neutral position N
we don't change the speed but inform the user instead.
Pretty simple.
Let's say we want to model a SportsCar
also. We make it inherit from the base class Car
. At initialization we also define a turbos
variable which is a list showing what turbo levels the car supports. Because of the behavior change we need to define a new acceleration function that takes turbo
as a parameter. The speed
is increased by the turbo amount instead of 1
so the sports car can accelerate faster.
In the __main__
function we define a normal car.
Here's the problem:
If we try to replace the Car
instance this with an instance of a SportsCar
it causes the code to break since accelerate
in SportsCar
expects a turbo
argument.
By LSP, replacing the above should not give an error and the code should function as normal.
There are two ways to go about this.
Solution 1
A simple solution would be to replace the turbos
variable with a turbo
variable having a fixed value. And removing the parameter turbo
from the accelerate
function like so:
As you can see, the function signatures are now directly identical to the base class function signatures. This means we can replace the Car
code in the __main__
function with SportsCar
without code breakage. Yay!
However, if you notice we have a fixed turbo value 2
in this case.
What if we want the user to define the turbo value like it was possible before?
This requires using an abstract class. On we go!
Solution 2 - Abstract class
The issue is the way we have modelled the cars. To make the code above conform to the LSP and have the functionality of turbo choice we must create an abstract base class Car
. Then we create two separate subclasses that inherit from it namely: SportsCar
and RegularCar
. ย The diagram illustrates this:
It'll make more sense with the code:
We transform Car
to an abstract class using the ABC
module. The details of this are not important. The init
function is annoated with @abstractmethod
to denote an abstract function. This ensures that a Car
cannot be instantiated. Only classes that inherit from this can be instantiated.
Next, we create two classes that inherit from Car
:
RegularCar
indicating a normal car without turboSportsCar
indicating a sports car with turbo
The RegularCar
has no additional changes.
In the SportsCar
class we can make the modifications we desire. We define the turbos
array and the turboAccelerate
function containing the extra turbo
parameter.
In this case, changing the function signature in SportsCar
is not violating the LSP. Why? Because the base class Car
is abstract so it cannot be instantiated.
If it cannot be instantiated, it cannot be replaced.
Conclusion
In this post we looked at the Liskov substitution principle and what it means.
We saw an example violating the principle and solve it using two methods.
LSP helps makes code flexible and prevents issues when old code is extended with new code. LSP ensures that behavior of code remains the same across both new and old code bases, resulting in code that is less prone to breakage.
Overall, code robustness increases as a result.
This post was a bit more involved than previous posts especially since we approached the problem in a roundabout way. I wanted to explore an interesting scenario which increased complexity slightly. I hope you enjoyed the content!
Enjoyed this article? Please consider subscribing using the form below๐
It is 100% free. You get an email every time I post (about once a week) + access to premium content in the future.