SOLID Principles in Python

SOLID Principles

The SOLID principles are:

  • SRP - Single-Responsibility Principle
  • OCP - The Open-Closed Principle
  • LSP - Liskov Substitution Principle
  • ISP - Interface Segregation Principle
  • DIP - Dependency inversion Principle

SOLID principles are the building blocks of the Class design

Single-responsibility principle(SRP)

Every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class. All its services should be narrowly aligned with that responsibility.

Let's see a simple example

class User:
    def create_user(self, username, password, email):
        print("registered user in db")
    def send_email(self, subject, to_email):
        print("email sent")
    def log_error(self, error):
        print("log:", error)
    def register_user(self, username, password, email):
        try:
            self.create_user(username, password, email)
            self.send_email("user registered", email)
        except Exception:
            self.log_error("error in user register")

if __name__ == '__main__':
    user = User()
    user.register_user("anji", "secret", "anji@example.com")

In above class, we can see the register_user, login_user, send_email and log_error total of 4 responsibilities. The class should only have the single responsibility i.e user realted. But, it also have sending email and logging an error functionalities. so, it should be separated and moved to classes like Email and ErrorLog. Let's re-write the code with single responsibility.

class UserDB:
    def create_user(self, username, password, email):
        print("registered user in db")

class Email:
    def send_email(self, subject, to_email):
        print("email sent")

class ErrorLog:
    def log_error(self, error):
        print("log:", error)

class User:
    def register_user(self, username, password, email):
        try:
            user_db = UserDB()
            user_db.create_user(username, password, email)
            email_server = Email()
            email_server.send_email("user registered", email)
        except Exception:
            logger = ErrorLog()
            logger.log_error("error in user register")

if __name__ == '__main__':
    user = User()
    user.register_user("anji", "secret", "anji@example.com")

After, implementing the Single Responsibility Principle we can see that the code more readable and maintainable now. Let's say if want to add a new feature to update_user which updates the user info to the database. We can simply add the method to UserDB class. Because, it's related to database. The client class User will have a new method called update_user_info to use it.

Open–closed principle(OCP)

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification

Let's check out the below example.

class Calculator:
    def add(self, a, b):
        return a + b
    def sub(self, a, b):
        return a - b
    def mul(self, a, b):
        return a * b
    def div(self, a, b):
        return a / b

In the above code, we have a class Calculator which performs the basic math operations like addition, subtraction, multiplication and division. If we want to add scientific math operations like sin, cos, tan then we do not add these methods in the same class. As per open-closed principle, we do not want to modify the existsing class but we can inherit the class and add the scientific math operations to the inherited class i.e ScientificCalculator

import math

class ScientificCalculator(Calculator):
    def sin(self, value):
        return math.sin(value)
    def cos(self, value):
        return math.cos(value)
    def tan(self, value):
        return math.tan(value)

If we want to implement the business calculator then we can use the simpilar open-closed principle and solve it using class inheritance. Let's see the code.

class BusinessCalculator(Calculator):
    def future_value(self, pv, r, n):
        return pv * (1+r)**n
    def monthly_payment(self, p, r, n):
        return p * (r * (1 + r)**n) / (((1 + r)**n) - 1)

For ScientificCalculator and BusinessCalculator we did not modify the existing code but we have used it's functionality and implemented the new feature requirements. so, the code satisfies the open-closed principle.

In the future, If we get any requirement related to business then we can only modify the BusinessCalculator. so, it won't have an effect on the other two classes.

Liskov substitution principle

Derived classes must be usable through the base class interface, without the need for the user to know the difference. —Barbara Liskov

If S is a subtype of T, then objects of type T may be replaced with objects of the S —Barbara Liskov

In simple words, Let's say we have a abstract class with 4 methods and 3 different classes are inheriting the abstract class. Then we should be able to access the methods abstract class without knowing the actual class that implemented the method.

Let's use the classic example of shapes.

import math
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def calculate_area(self):
        pass

    @abstractmethod
    def calculate_perimeter(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

    def calculate_perimeter(self):
        return 2 * (self.width + self.height)

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def calculate_perimeter(self):
        return 4 * self.width

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def calculate_area(self):
        return math.pi * (self.radius ** 2)

    def calculate_perimeter(self):
        return 2 * math.pi * self.radius

def print_shape_info(shape: Shape):
    data = {
        "name": shape.__class__.__name__,
        "area": shape.calculate_area(),
        "perimeter": shape.calculate_perimeter(),
    }
    print(data)

if __name__ == '__main__':
    shapes = [
        Rectangle(10, 15),
        Square(8),
        Circle(12),
    ]

    for shape in shapes:
        print_shape_info(shape)

Output:

{'name': 'Rectangle', 'area': 150, 'perimeter': 50}
{'name': 'Square', 'area': 64, 'perimeter': 32}
{'name': 'Circle', 'area': 452.3893421169302, 'perimeter': 75.39822368615503}

The function print_shape_info does not know the infomation about the implemeted class. It just know the abstract class. As per the Liskov substitution principle we can able to access the methods of Shape in all of it's chil classes.

Interface Segregation Principle(ISP)

Interface segregation principle (ISP) states that no code should be forced to depend on methods it does not use.ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.

Interface Segregation Principle(ISP)

Let's consider the code below.

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def eat(self):
        pass
    @abstractmethod
    def walk(self):
        pass
    @abstractmethod
    def swim(self):
        pass
    @abstractmethod
    def fly(self):
        pass

class Cat(Animal):
    def eat(self):
        return True
    def walk(self):
        return True
    def swim(self):
        raise NotImplemented
    def fly(self):
        raise NotImplemented

class Duck(Animal):
    def eat(self):
        return True
    def walk(self):
        return True
    def swim(self):
        return True
    def fly(self):
        raise NotImplemented

class Pigeon(Animal):
    def eat(self):
        return True
    def walk(self):
        return True
    def swim(self):
        raise NotImplemented
    def fly(self):
        return True

In the above code the class Animal is inherited by classes Cat, Duck, Pigeon. But, these classes do not require all methods from the Animal class. but, It's forcing these classes to implement the methods which are not required. It's violating the Interface Segregation Principle(ISP).

To implement fix this issue we seperate out the non-common methods to new classses SwimAbility, FlyAbility like below code.

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def eat(self):
        pass
    @abstractmethod
    def walk(self):
        pass

class SwimAbility(ABC):
    @abstractmethod
    def swim(self):
        pass

class FlyAbility(ABC):
    @abstractmethod
    def fly(self):
        pass

class Cat(Animal):
    def eat(self):
        return True
    def walk(self):
        return True

class Duck(Animal, SwimAbility):
    def eat(self):
        return True
    def walk(self):
        return True
    def swim(self):
        return True

class Pigeon(Animal, FlyAbility):
    def eat(self):
        return True
    def walk(self):
        return True
    def fly(self):
        return True

The above code satisfies the principle Interface Segregation Principle(ISP). We can see that there is no forced implementation of the inherited class methods.

Dependency inversion Principle

The Dependency Inversion Principle (DIP) states that high level modules should not depend on low level modules; both should depend on abstractions. Abstractions should not depend on details. Details should depend upon abstractions.

Let's consider the scenario of electric switch board and electric devices. i.e Switches, Fan, Light Bulb, etc.

class LightBulb:
    def turn_on(self):
        print("LightBulb: on")

    def turn_off(self):
        print("LightBulb: off")

class PowerSwitch:
    def __init__(self, light_bulb: LightBulb):
        self.light_bulb = light_bulb
        self.on = False

    def press(self):
        if self.on:
            self.light_bulb.turn_off()
        else:
            self.light_bulb.turn_on()
            self.on = True

if __name__ == '__main__':
    light_bulb = LightBulb()
    switch = PowerSwitch(light_bulb)
    switch.press()
    switch.press()

The above code works perfectly, but it violates the Dependency Inversion Principle(DIP). The class PowerSwitch is tightly coupled with class LightBulb. The power switch object takes in a light bulb and then directly calls the turn off and turn on method on that instance.

Let's implement the pattern Dependency Inversion Principle(DIP) for above case.

from abc import ABC, abstractmethod

class Switchable(ABC):
    @abstractmethod
    def turn_on(self):
        pass
    @abstractmethod
    def turn_off(self):
        pass

class LightBulb(Switchable):
    def turn_on(self):
        print("LightBulb: on")

    def turn_off(self):
        print("LightBulb: off")

class PowerSwitch:
    def __init__(self, device: Switchable):
        self.device = device
        self.on = False

    def press(self):
        if self.on:
            self.device.turn_off()
        else:
            self.device.turn_on()
            self.on = True

if __name__ == '__main__':
    light_bulb = LightBulb()
    switch = PowerSwitch(light_bulb)
    switch.press()
    switch.press()

Now, the class PowerSwitch does not depend on the class LightBulb. It depends on the abstract class Switchable. with the above code we succesfully removed the dependency between PowerSwitch and LightBulb.

If we want to implement the functionality for another device we can do easily by inheriting the class Switchable. Let's implement it for device Fan

class Fan(Switchable):
    def turn_on(self):
        print("Fan: on")

    def turn_off(self):
        print("Fan: off")

We can use the Fan object with the class PowerSwitch just like LightBulb.

By Dependency Inversion Principle(DIP), we have decoupled two classes through an interface, which in our case is Switchable. It is now clear to see the usefulness that dependency inversion provides by reducing coupling between classes.

References