- Published on
The Ultimate Guide to SOLID Principles with Python
15 min read
- Authors
- Name
- Kiarash Soleimanzadeh
- https://go.kiarashs.ir/twitter
Table of Contents
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
, andSMS
. - Both
Email
andSMS
inherit from theNotification
class. - The
Notification
abstract class defines anotify()
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! :)