What Are the SOLID Principles?
March 7, 2023
What Are the SOLID Principles?
The SOLID principles are a set of software design principles used to guide software developers in designing and implementing high-quality, maintainable, and extensible software. It was proposed by Robert C. Martin in his book "Agile Software Development, Principles, Patterns, and Practices" and is widely accepted as a software design concept in the software engineering industry.
Five Principles of SOLID
The SOLID principles include the following five principles:
- Single Responsibility Principle (SRP)
A class should have only one reason to change. In other words, a class should have only one responsibility causing it to change. The following example shows this:
class ShoppingCart:
def __init__(self):
self.items = []
self.total = 0
def add_item(self, item):
self.items.append(item)
self.total += item.price
def remove_item(self, item):
self.items.remove(item)
self.total -= item.price
def print_receipt(self):
print('Items:')
for item in self.items:
print(f'{item.name} - ${item.price}')
print(f'Total: ${self.total}')
This ShoppingCart
class handles both shopping cart-related tasks and output-related tasks. Its print_receipt()
method should be separated into an independent class or method to achieve the Single Responsibility Principle.
- Open-Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means that when adding new functionality, existing entities should be extended rather than modified.
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def remove_item(self, item):
self.items.remove(item)
def get_total_price(self):
total_price = 0
for item in self.items:
total_price += item.price
return total_price
class DiscountedShoppingCart(ShoppingCart):
def get_total_price(self):
total_price = super().get_total_price()
return total_price * 0.9
In this example, when we need to add a shopping cart with a different discount, we must create a new subclass DiscountedShoppingCart
and rewrite the get_total_price()
method, which violates the Open-Closed Principle.
Here is a fixed Python code example that complies with the Open-Closed Principle:
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def remove_item(self, item):
self.items.remove(item)
def get_total_price(self):
total_price = 0
for item in self.items:
total_price += item.price
return total_price
class Discount:
def calculate_discount(self, total_price):
return total_price
class TenPercentDiscount(Discount):
def calculate_discount(self, total_price):
return total_price * 0.9
class ShoppingCartWithDiscount:
def __init__(self, discount: Discount):
self.items = []
self.discount = discount
def add_item(self, item):
self.items.append(item)
def remove_item(self, item):
self.items.remove(item)
def get_total_price(self):
total_price = 0
for item in self.items:
total_price += item.price
return self.discount.calculate_discount(total_price)
In this fixed example, we refactored the get_total_price()
method to use the Discount
strategy class, and the Discount
strategy class defines a common interface that can be used to extend any type of discount. TenPercentDiscount
is a subclass of Discount
that implements a 10% discount calculation. The ShoppingCartWithDiscount
class holds a Discount
instance and calls the calculate_discount()
method when calculating the total price. This way, when we need to add a different type of discount, we only need to create a new strategy class and pass it to ShoppingCartWithDiscount
, without modifying any existing code.
- Liskov Substitution Principle (LSP)
All references to the base class must be able to use objects of its subtypes transparently. In other words, a subclass should be able to replace its parent class and not break the system's correctness.
The following example violates LSP because the inheritance relationship between the Rectangle
and Square
classes is problematic. Square
inherits from Rectangle
, but its set_height()
and set_width()
methods allow it to change the length of both sides, violating the definition of Rectangle
, as Rectangle
can have different lengths on both sides. This means that in code using Rectangle
class objects, Square
objects cannot be used correctly to replace them, violating LSP.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, width):
self.width = width
def set_height(self, height):
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, size):
self.width = size
self.height = size
def set_width(self, width):
self.width = width
self.height = width
def set_height(self, height):
self.width = height
self.height = height
square = Square(5)
square.set_width(10)
square.set_height(5)
print(square.area())
In simple terms, LSP requires that a subclass can replace its parent class and be used in any situation where the parent class is used. If a subclass has additional or modified methods that are not owned by the parent class, this will break the original design. In this example, the special nature of Square
and its difference from Rectangle
means that it cannot fully replace Rectangle
, violating LSP. To fix it we can look at the following code:
class Shape:
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, width):
self.width = width
def set_height(self, height):
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, size):
self.size = size
def set_size(self, size):
self.size = size
def area(self):
return self.size ** 2
def print_area(shape):
print(f"Area: {shape.area()}")
shapes = [Rectangle(5, 10), Square(5)]
for shape in shapes:
print_area(shape)
In this example, both Rectangle
and Square
inherit from the Shape
class and implement the area()
method. In the print_area()
function, it accepts a Shape
object as an argument and calls the area()
method to get its area.
This example does not violate LSP because the Square
object can successfully replace the Shape
object without affecting the normal operation of the program. In other words, the Square
object has the same behavior as the Shape
object and can be replaced by the Shape
object, so it complies with the Liskov Substitution Principle.
- Interface Segregation Principle (ISP)
Client should not be forced to depend on methods it does not use. Interfaces should be split into smaller and more specific parts so that clients only need to know about the methods they use.
See the following code that violates ISP:
class Machine:
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
pass
class MultiFunctionPrinter(Machine):
def print(self, document):
print("Printing")
def fax(self, document):
print("Faxing")
def scan(self, document):
print("Scanning")
The above code, Machine
is an interface for a machine, which includes the print
, fax
and scan
methods. And MultiFunctionPrinter
is a multi-function printer that inherits the Machine
interface and implements all methods. This code violates ISP because not all machines need to implement the fax
and scan
methods, and MultiFunctionPrinter
forces the implementation of these two methods. This design is redundant for other machines that only need to implement the print
method, and it makes the interface unclear.
To fix this problem, we can look at the following code:
class Printer:
def print(self, document):
pass
class Fax:
def fax(self, document):
pass
class Scanner:
def scan(self, document):
pass
class MultiFunctionDevice(Printer, Fax, Scanner):
def print(self, document):
print("Printing")
def fax(self, document):
print("Faxing")
def scan(self, document):
print("Scanning")
In this new design, we split the original Machine
interface into three independent interfaces Printer
, Fax
and Scanner
. MultiFunctionDevice
is a device with multiple functions, which implements the Printer
, Fax
and Scanner
interfaces. This design allows each interface to contain only the necessary methods, and allows devices to implement the interfaces they need. This design is more in line with ISP.
- Dependency Inversion Principle (DIP)
High level modules should not depend on low level modules. Both should depend on abstractions. In other words, high level modules and low level modules should interact through interfaces or abstract classes. This can reduce the direct coupling between classes and improve the flexibility and reusability of the code.
Let's look at an example that violates DIP:
class Logger:
def log(self, message):
print(f"Log: {message}")
class UserService:
def __init__(self):
self.logger = Logger()
def register(self, username, password):
try:
# register user to database
print(f"User {username} registered successfully")
self.logger.log(f"User {username} registered successfully")
except Exception as e:
print(f"Error: {e}")
self.logger.log(f"Error: {e}")
In this example, UserService
directly creates a Logger
object. This design violates the dependency inversion principle because the high-level module UserService
directly depends on the low-level module Logger
.
The following code fixes the problem:
from abc import ABC, abstractmethod
class Logger(ABC):
@abstractmethod
def log(self, message):
pass
class ConsoleLogger(Logger):
def log(self, message):
print(f"Log: {message}")
class UserService:
def __init__(self, logger: Logger):
self.logger = logger
def register(self, username, password):
try:
# register user to database
print(f"User {username} registered successfully")
self.logger.log(f"User {username} registered successfully")
except Exception as e:
print(f"Error: {e}")
self.logger.log(f"Error: {e}")
logger = ConsoleLogger()
service = UserService(logger)
In this example, UserService
now only depends on the abstract interface of Logger
instead of directly depending on the actual Logger
object. This modification complies with the dependency inversion principle.
💡 What is a high-level module and what is a low-level module? In a software system, we often divide the system into different levels, such as data access layer, business logic layer, and interface layer. The data access layer may contain some code that communicates with the database, and the business logic layer uses the methods provided by the data access layer to operate the data. In this case, the business logic layer can be viewed as a high-level module because it uses the services of the low-level module.