Liskov Substitution Principle in Python


Introduction
The Liskov Substitution Principle (LSP) is a fundamental principle of software design that deals with the relationship between a base class and its derived classes. It is named after Barbara Liskov, who first introduced the concept in a 1987 paper. This blog will discuss the Liskov Substitution Principle and provide an example in Python.
Definition of the Liskov Substitution Principle:
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, if a program is using a base class, it should be able to work correctly with any derived class without causing unexpected behaviors or breaking the program’s functionality.
Image from a blog
If it’s look like duck, swim like duck, quacks like duck, but it needs batteries to function, you probably have the wrong abstraction. While the object may seems same as duck in many ways, the dependency on batteries indicates that it is not a real duck. This voilates the Liskov Substitution Principle (LSP) as the two objects are not interchangeable.
Using the LSP Principle Correctly:
To use the LSP effectively, consider the following guidelines:
1. Maintain the Same Behavior: Derived classes should honor the contracts and behaviors defined by the base class. This means that the derived class should implement all the methods specified by the base class and behave in a manner consistent with the base class’s expectations. Let’s see what this means with an example
class Animal:
def make_sound(self):
pass
class Dog(Animal):
def make_sound(self):
return "Woof!"
class Cat(Animal):
def make_sound(self):
return "Meow!"
def animal_sounds(animals):
for animal in animals:
print(animal.make_sound())
# Using the base class Animal
animal = Animal()
animal_sounds([animal]) # Output: (no sound)
# Using the derived classes Dog and Cat
dog = Dog()
cat = Cat()
animal_sounds([dog, cat]) # Output: Woof! Meow!
In this example, we have a base class Animal
with a method make_sound()
. The base class provides the structure for creating different animal objects, but it does not specify any particular sound.
We then have two derived classes, Dog
and Cat
, which inherit from the Animal
class. Each derived class overrides the make_sound()
method and provides its own implementation of making a sound.
By maintaining the same behavior, both Dog
and Cat
classes implement the make_sound()
method specified by the base class Animal
. They behave consistently with the base class's expectations, returning the appropriate sound for a dog and a cat, respectively.
We can use the animal_sounds()
function, which expects a list of Animal
objects, to print the sounds made by different animals. This demonstrates that the derived classes honor the contracts and behaviors defined by the base class and can be used interchangeably.
2. Avoid Stronger Preconditions: Derived classes should not have more restrictive requirements (input requirements) than the base class. This ensures that any code expecting the base class can still use the derived class without encountering unexpected failures due to stricter input conditions. Let’s see what this means with an example
class Shape:
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side_length):
self.side_length = side_length
def area(self):
return self.side_length * self.side_length
def print_area(shape):
print("Area:", shape.area())
# Using the base class Shape
rectangle = Rectangle(5, 4)
print_area(rectangle) # Output: Area: 20
# Using the derived class Square
square = Square(5)
print_area(square) # Output: Area: 25
In this example, we have a base class Shape
with a method area()
, which calculates the area of a shape. There are two derived classes, Rectangle
and Square
, which inherit from the Shape
class and implement their own versions of the area()
method.
The important thing to note is that the derived classes do not impose stronger preconditions than the base class. Both the Rectangle
and Square
classes require different input parameters in their constructors (width
and height
for Rectangle
, and side_length
for Square
). However, when calculating the area, the methods in both classes work correctly and provide the expected results.
By avoiding stronger preconditions, we ensure that the code expecting the base class (Shape
in this case) can still work correctly with any derived class, providing flexibility and compatibility.
What to Avoid:
To make effective use of the LSP, it’s important to avoid certain pitfalls:
1. Violating the Contract: Derived classes should not violate the contracts defined by the base class. If a derived class modifies or ignores the requirements specified by the base class, it can lead to inconsistencies and unexpected behaviors.
class Bird:
def fly(self):
pass
class Ostrich(Bird):
def fly(self):
raise NotImplementedError("Ostriches cannot fly!")
bird = Bird()
bird.fly() # Output: (no implementation)
ostrich = Ostrich()
ostrich.fly() # Raises NotImplementedError
In this example, the base class Bird
defines a method fly()
. However, the derived class Ostrich
violates the contract by raising a NotImplementedError
when attempting to fly. This violates the expectations set by the base class and can lead to inconsistencies and unexpected behaviors when interacting with objects of the Ostrich
class.
2. Overriding Essential Behavior: Overriding crucial methods in a way that changes the fundamental behavior defined by the base class can break the LSP. Derived classes should extend or specialize the behavior rather than completely altering it.
class Vehicle:
def start_engine(self):
print("Engine started.")
class ElectricVehicle(Vehicle):
def start_engine(self):
print("Engine cannot be started for an electric vehicle.")
vehicle = Vehicle()
vehicle.start_engine() # Output: Engine started.
electric_vehicle = ElectricVehicle()
electric_vehicle.start_engine() # Output: Engine cannot be started for an electric vehicle.
In this example, the base class Vehicle
has a method start_engine()
. The derived class ElectricVehicle
overrides this method and changes the fundamental behavior by indicating that the engine cannot be started for an electric vehicle. This breaks the LSP because the derived class alters the expected behavior defined by the base class.
3. Tight Coupling with Implementation Details: Relying heavily on implementation details of derived classes in client code can lead to tight coupling and hinder the flexibility of the LSP. Aim for loose coupling and focus on interacting with objects through their defined interfaces.
class DatabaseConnector:
def connect(self):
pass
class MySQLConnector(DatabaseConnector):
def connect(self):
print("Connecting to MySQL database...")
class PostgreSQLConnector(DatabaseConnector):
def connect(self):
print("Connecting to PostgreSQL database...")
# Tight coupling with concrete class instantiation
connector = MySQLConnector() # Specific to MySQL
connector.connect() # Output: Connecting to MySQL database...
In this example, the client code tightly couples with the concrete class MySQLConnector
when instantiating the connector
object. This direct dependency on the specific class limits the flexibility to switch to other database connectors, such as PostgreSQLConnector
. To follow the Liskov Substitution Principle, it is better to interact with objects through their common base class interface (DatabaseConnector
in this case) and use polymorphism to instantiate objects based on runtime configuration or user input.
By avoiding tight coupling with implementation details, you allow for more flexibility, extensibility, and maintainability in your codebase, enabling smooth substitutions of derived classes and promoting adherence to the Liskov Substitution Principle.
In conclusion, The Liskov Substitution Principle is a valuable principle in software design that promotes flexibility, extensibility, and maintainability. By correctly applying the LSP, you can create modular systems that allow for the seamless interchangeability of objects and minimize unexpected issues.
Subscribe to my newsletter
Read articles from NonStop io Technologies directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

NonStop io Technologies
NonStop io Technologies
Product Development as an Expertise Since 2015 Founded in August 2015, we are a USA-based Bespoke Engineering Studio providing Product Development as an Expertise. With 80+ satisfied clients worldwide, we serve startups and enterprises across San Francisco, Seattle, New York, London, Pune, Bangalore, Tokyo and other prominent technology hubs.