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.

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