Published on

The Ultimate Guide to SOLID Principles with Python

15 min read

Authors
banner

SOLID Principles

In this blog post, I will show you how to apply the SOLID principles in Python, a popular and versatile programming language that supports multiple paradigms, including object-oriented programming (OOP).

OOP is a powerful way of organizing your code by defining classes that encapsulate data and behavior. However, writing good OOP code is not easy. You need to follow some best practices and design principles that can help you avoid common pitfalls and create code that is easy to understand, reuse, and extend.

One of the most widely accepted sets of design principles for OOP is the SOLID principles, which were defined by Robert C. Martin (Uncle Bob) in the early 2000s. SOLID stands for:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

These principles can help you write code that is more maintainable, flexible, and scalable. In this post, I will explain each principle and give you some examples of how to apply them in Python.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that a class should have only one responsibility and only one reason to change. This means that a class should not perform multiple tasks or handle multiple concerns. Instead, it should focus on one thing and do it well.

Why is this important? Because having classes with multiple responsibilities can lead to several problems, such as:

  • Increased complexity and reduced readability
  • Increased coupling and reduced cohesion
  • Difficulty in testing and debugging
  • Difficulty in reusing and modifying

To apply the SRP in Python, you should identify the core responsibility of each class and separate the unrelated or secondary concerns into different classes. For example, suppose you have a class that represents a bank account and performs the following tasks:

  • Stores the account number and balance
  • Validates the account number format
  • Connects to a database and saves the account data
  • Sends email notifications to the account holder

This class clearly violates the SRP, as it has more than one responsibility and more than one reason to change. A better design would be to split this class into four classes, each with a single responsibility:

  • Account: Stores the account number and balance
  • AccountValidator: Validates the account number format
  • AccountRepository: Connects to a database and saves the account data
  • AccountNotifier: Sends email notifications to the account holder

Here is how the code might look like in Python:

class Account:
    """A class that represents a bank account"""

    def __init__(self, account_number, balance):
        self.account_number = account_number
        self.balance = balance

    def deposit(self, amount):
        """Deposits money to the account"""
        self.balance += amount

    def withdraw(self, amount):
        """Withdraws money from the account"""
        self.balance -= amount

    def get_balance(self):
        """Returns the account balance"""
        return self.balance



class AccountValidator:
    """A class that validates the account number format"""

    def __init__(self, account_number):
        self.account_number = account_number

    def is_valid(self):
        """Returns True if the account number is valid, False otherwise"""
        # Assume some logic to check the account number format
        return True



class AccountRepository:
    """A class that connects to a database and saves the account data"""

    def __init__(self, db_connection):
        self.db_connection = db_connection

    def save(self, account):
        """Saves the account data to the database"""
        # Assume some logic to insert or update the account data
        pass



class AccountNotifier:
    """A class that sends email notifications to the account holder"""

    def __init__(self, email_service):
        self.email_service = email_service

    def notify(self, account, message):
        """Sends an email notification to the account holder"""
        # Assume some logic to send an email using the email service
        pass

By applying the SRP, you have reduced the complexity and increased the readability of your code. You have also decoupled the classes and increased their cohesion, making them easier to test, debug, reuse, and modify.

Open-Closed Principle (OCP)

The Open-Closed Principle (OCP) states that software entities (classes, functions, modules) should be open for extension, but closed for modification. This means that you should be able to add new features or behaviors to your code without changing the existing code.

Why is this important? Because modifying existing code can introduce bugs, break existing functionality, and violate the expectations of the clients who use your code. By following the OCP, you can avoid these problems and create code that is more stable and robust.

To apply the OCP in Python, you should use abstraction and polymorphism to define interfaces that specify the expected behavior of your classes, and then implement those interfaces with concrete classes that provide the specific behavior. For example, suppose you have a class that calculates the area of different shapes:

class AreaCalculator:
    """A class that calculates the area of different shapes"""

    def calculate_area(self, shape):
        """Calculates the area of a shape"""
        if isinstance(shape, Square):
            return shape.side * shape.side
        elif isinstance(shape, Circle):
            return 3.14 * shape.radius * shape.radius
        else:
            raise ValueError("Invalid shape")

This class violates the OCP, as it has to be modified every time you want to add a new shape. A better design would be to define an abstract class that specifies the interface for a shape, and then implement that interface with concrete classes that provide the area calculation for each shape:

from abc import ABC, abstractmethod

class Shape(ABC):
    """An abstract class that represents a shape"""

    @abstractmethod
    def area(self):
        """Returns the area of the shape"""
        pass


class Square(Shape):
    """A class that represents a square"""

    def __init__(self, side):
        self.side = side

    def area(self):
        """Returns the area of the square"""
        return self.side * self.side


class Circle(Shape):
    """A class that represents a circle"""

    def __init__(self, radius):
        self.radius = radius

    def area(self):
        """Returns the area of the circle"""
        return 3.14 * self.radius * self.radius

Now, you can rewrite the AreaCalculator class to use the Shape interface, without depending on the specific implementation of each shape:

class AreaCalculator:
    """A class that calculates the area of different shapes"""

    def calculate_area(self, shape):
        """Calculates the area of a shape"""
        if isinstance(shape, Shape):
            return shape.area()
        else:
            raise ValueError("Invalid shape")

By applying the OCP, you have made your code more flexible and extensible. You can now add new shapes without changing the AreaCalculator class, as long as they implement the Shape interface.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the five SOLID principles that guide object-oriented design. It emphasizes that objects of a subclass should be replaceable with objects of their superclass without affecting the correctness of the program. In other words, a subclass should behave like a subtype of its superclass, and the program’s behavior should remain consistent whether we use the superclass or a subclass.

Let’s explore an example of how to apply the Liskov Substitution Principle in Python. Consider the following initial code snippet:

from abc import ABC, abstractmethod

class Notification(ABC):
    @abstractmethod
    def notify(self, message, email):
        pass

class Email(Notification):
    def notify(self, message, email):
        print(f'Send {message} to {email}')

class SMS(Notification):
    def notify(self, message, phone):
        print(f'Send {message} to {phone}')

if __name__ == '__main__':
    notification = SMS()
    notification.notify('Hello', '[email protected]')

In this example:

  • We have three classes: Notification, Email, and SMS.
  • Both Email and SMS inherit from the Notification class.
  • The Notification abstract class defines a notify() method that sends a message to an email address.
  • However, the SMS class uses a phone number instead of an email for sending messages.

This violates the Liskov Substitution Principle because the SMS class does not fully substitute for the Notification class. To conform with the principle, we need to adjust the notify() method of the SMS class to accept a phone number instead of an email.

Here’s an improved version that adheres to the Liskov Substitution Principle:

So we must keep in mind that when we want to develop a class by derivation, the parts of the program where the parent class is used must be able to work with child classes without problems. That is, the child class should not change the characteristics and behavior of the parent class. For example, if the parent class has a method whose output is a number, the child class should not copy this method so that the output is an array.

class Contact:
    def __init__(self, name, email, phone):
        self.name = name
        self.email = email
        self.phone = phone

class NotificationManager:
    def __init__(self, notification, contact):
        self.contact = contact
        self.notification = notification

    def send(self, message):
        if isinstance(self.notification, Email):
            self.notification.notify(message, self.contact.email)
        elif isinstance(self.notification, SMS):
            self.notification.notify(message, self.contact.phone)
        else:
            raise Exception('The notification is not supported')

if __name__ == '__main__':
    contact = Contact('Kiarash Sz', '[email protected]', '(408)-888-9999')
    notification_manager = NotificationManager(SMS(), contact)
    notification_manager.send('Hello Kiarash')

Now, the NotificationManager class accepts a notification object and correctly handles both email and SMS notifications. This adheres to the Liskov Substitution Principle, ensuring that subclasses can seamlessly replace their parent class without causing errors.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is one of the five SOLID principles of object-oriented programming. It states that no client should be forced to depend on methods it does not use. In other words, an interface should be designed to be as small and cohesive as possible, and a class should not have to implement methods that it does not need or use.

Here’s an example of how to implement ISP in Python. Consider a scenario where we have a Vehicle abstract class with two abstract methods, go() and fly(). We also have two subclasses, Aircraft and Car, which inherit from the Vehicle class. The Aircraft class implements both go() and fly() methods, while the Car class only implements the go() method. If we create a Car object and pass it to a function that expects a Vehicle object, the function should work correctly without any issues.

from abc import ABC, abstractmethod

class Vehicle(ABC):
    @abstractmethod
    def go(self):
        pass

    @abstractmethod
    def fly(self):
        pass

class Aircraft(Vehicle):
    def go(self):
        print("Taxiing")

    def fly(self):
        print("Flying")

class Car(Vehicle):
    def go(self):
        print("Going")

def operate_vehicle(vehicle):
    vehicle.go()

if __name__ == '__main__':
    car = Car()
    operate_vehicle(car)

In the above example, the Car class is a client-specific interface that only implements the go() method. We can pass a Car object to the operate_vehicle() function without any issues. This is because the Car class does not have to implement the fly() method, which it does not need or use.

Dependency Inversion Principle (DIP)

It states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This principle aims to reduce the coupling between classes by creating an abstraction layer between them.

Here’s an example of how to implement DIP in Python. Consider a scenario where we have a Logger class that logs messages to a file and a Calculator class that performs arithmetic operations. The Calculator class depends on the Logger class to log messages. If we want to change the logging mechanism in the future, we would need to modify the Calculator class, which violates the DIP.

class Logger:
    def log(self, message):
        with open('log.txt', 'a') as f:
            f.write(message + '\n')

class Calculator:
    def __init__(self):
        self.logger = Logger()

    def add(self, x, y):
        self.logger.log(f'Adding {x} and {y}')
        return x + y

if __name__ == '__main__':
    calculator = Calculator()
    print(calculator.add(2, 3))

In the above example, the Calculator class depends on the Logger class, violating the DIP. To invert the dependency, we can define an interface LoggerInterface and make the Calculator class dependent on it instead of the Logger class. Then, we can create a new class FileLogger that implements the LoggerInterface and use it in the Calculator class.

from abc import ABC, abstractmethod

class LoggerInterface(ABC):
    @abstractmethod
    def log(self, message):
        pass

class FileLogger(LoggerInterface):
    def log(self, message):
        with open('log.txt', 'a') as f:
            f.write(message + '\n')

class Calculator:
    def __init__(self, logger):
        self.logger = logger

    def add(self, x, y):
        self.logger.log(f'Adding {x} and {y}')
        return x + y

if __name__ == '__main__':
    logger = FileLogger()
    calculator = Calculator(logger)
    print(calculator.add(2, 3))

In the above example, the Calculator class depends on the LoggerInterface, which is an abstraction. We can create a new class FileLogger that implements the LoggerInterface and use it in the Calculator class. This way, we can change the logging mechanism in the future without modifying the Calculator class.

By following the SOLID principles, we can write more flexible and maintainable code that is easier to extend and modify. It also helps us to avoid common pitfalls such as tight coupling and brittle code.

That's All Folks.

Best of luck, you got this! :)

© 2024 Kiarash Soleimanzadeh