Introduction
SOLID is a set of five principles for writing clean and maintainable code, initially introduced by Robert C. Martin (also known as Uncle Bob). These principles can help you develop code that is easy to understand, test, and modify.
The SOLID principles are:
- S - Single-Responsibility Principle (SRP)
- O - Open-Closed Principle (OCP)
- L - Liskov Substitution Principle (LSP)
- I - Interface Segregation Principle (ISP)
- D - Dependency inversion Principle (DIP)
In this paper, we will discuss each of the SOLID principles and provide Python code samples to illustrate their usage.
S - Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In other words, a class should have only one responsibility.
Example
Here's an example of violating the SRP in Python:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
def print_area(self):
area = self.width * self.height
print(f"The area of the rectangle is {area}")
In this example, we have a Rectangle class that represents a rectangle and has two responsibilities: calculating the area of the rectangle and printing the area to the console. This violates the SRP because a class should have only one responsibility.
To correct this violation, we need to separate the responsibilities into two different classes. Here's an example of how we can correct this violation:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
class RectanglePrinter:
def __init__(self, rectangle):
self.rectangle = rectangle
def print_area(self):
area = self.rectangle.get_area()
print(f"The area of the rectangle is {area}")
rectangle = Rectangle(10, 20)
rectangle_printer = RectanglePrinter(rectangle)
rectangle_printer.print_area() # The area of the rectangle is 200
In this corrected example, we have two classes: Rectangle and RectanglePrinter. The Rectangle class has the responsibility of representing a rectangle and calculating its area, while the RectanglePrinter class has the responsibility of printing the area of a rectangle to the console.
By separating the responsibilities of the two classes, we have adhered to the SRP and improved the maintainability and extensibility of our code. If we need to change the way we calculate the area of a rectangle, we can do so without affecting the code that prints the area. Similarly, if we need to change the way we print the area, we can do so without affecting the code that calculates the area.
O - Open-Closed Principle
The Open-Closed Principle (OCP) states that a class should be open for extension but closed for modification. In other words, we should be able to extend the behavior of a class without modifying its source code.
Example
Here's an example of a class violating the OCP:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
class Circle:
def __init__(self, radius):
self.radius = radius
def get_area(self):
return 3.14 * self.radius * self.radius
def print_area(shape):
if isinstance(shape, Rectangle):
area = shape.get_area()
print(f"The area of the rectangle is {area}")
elif isinstance(shape, Circle):
area = shape.get_area()
print(f"The area of the circle is {area}")
In this example, we have a Rectangle class and a Circle class that both have a get_area() method to calculate their respective areas. We also have a print_area() function that takes a shape as a parameter and prints its area to the console. The problem with this code is that if we want to add a new shape (e.g., triangle) in the future, we will have to modify the print_area() function to include a new condition for the new shape.
To correct this violation, we need to make our code open for extension but closed for modification. Here's an example of how we can correct this violation:
class Shape:
def get_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def get_area(self):
return 3.14 * self.radius * self.radius
def print_area(shape):
area = shape.get_area()
print(f"The area of the shape is {area}")
rectangle = Rectangle(10, 20)
print_area(rectangle) # The area of the shape is 200
circle = Circle(5)
print_area(circle) # The area of the shape is 78.5
In this corrected example, we have created an abstract Shape class that has a get_area() method that is overridden by the concrete classes Rectangle and Circle. We also modified the print_area() function to take any object that implements the Shape interface and call its get_area() method to print its area to the console.
By making our code open for extension but closed for modification, we have adhered to the OCP and improved the maintainability and extensibility of our code. If we need to add a new shape (e.g., triangle) in the future, we can create a new class that implements the Shape interface and pass it to the print_area() function without modifying the function's source code.
L - Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. If the sub class changes due to any modification the super class should not get affected.
Example
Here's an example of a violation of the 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 get_width(self):
return self.width
def get_height(self):
return self.height
def get_area(self):
return self.width * self.height
class Square(Rectangle):
def set_width(self, width):
self.width = width
self.height = width
def set_height(self, height):
self.height = height
self.width = height
def get_width(self):
return self.width
def get_height(self):
return self.height
In this example, we have a Rectangle class and a Square class that inherits from the Rectangle class. The problem with this code is that a Square object can be used wherever a Rectangle object is used, but the behavior of a Square object is not the same as that of a Rectangle object. Specifically, a Square object has the same width and height, whereas a Rectangle object can have different width and height.
To correct this violation, we need to ensure that a Square object can be used wherever a Rectangle object is used without causing any unexpected behavior. Here's an example of how we can correct this violation:
class Shape:
def get_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 get_width(self):
return self.width
def get_height(self):
return self.height
def get_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def set_side(self, side):
self.side = side
def get_side(self):
return self.side
def get_area(self):
return self.side * self.side
rectangle = Rectangle(10, 20)
print(f"Rectangle area: {rectangle.get_area()}") # Rectangle area: 200
rectangle.set_width(5)
rectangle.set_height(10)
print(f"New rectangle area: {rectangle.get_area()}") # New rectangle area: 50
square = Square(5)
print(f"Square area: {square.get_area()}") # Square area: 25
square.set_side(7)
print(f"New square area: {square.get_area()}") # New square area: 49
In this corrected example, we have created an abstract Shape class that is inherited by the Rectangle and Square classes. We also modified the Rectangle and Square classes to ensure that the behavior of a Square object is the same as that of a Rectangle object. Specifically, we added a set_side() method and a get_side() method to the Square class and overrode the get_area() method to calculate the area of a square.
By ensuring that a Square object can be used wherever a Rectangle object is used without causing any unexpected behavior, we have adhered to the LSP and improved the flexibility and reusability of our code.
I - Interface Segregation Principle
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In other words, we should separate large interfaces into smaller and more specific ones.
Example
Here's an example of a violation of the ISP:
class Printer:
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print("Printing document: ", document)
def fax(self, document):
pass
def scan(self, document):
pass
class ModernPrinter(Printer):
def print(self, document):
print("Printing document: ", document)
def fax(self, document):
print("Faxing document: ", document)
def scan(self, document):
print("Scanning document: ", document)
In this example, we have a Printer interface with three methods: print(), fax(), and scan(). We also have two classes that implement this interface: OldPrinter and ModernPrinter. The problem with this code is that the OldPrinter class does not support the fax() and scan() methods, but it still has to implement these methods because they are part of the Printer interface. This violates the ISP because the client of the OldPrinter class is forced to depend on methods it does not use.
To correct this violation, we need to separate the Printer interface into smaller interfaces that are more focused on specific functionalities. Here's an example of how we can correct this violation:
class Printer:
def print(self, document):
pass
class Fax:
def fax(self, document):
pass
class Scanner:
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print("Printing document: ", document)
class ModernPrinter(Printer, Fax, Scanner):
def print(self, document):
print("Printing document: ", document)
def fax(self, document):
print("Faxing document: ", document)
def scan(self, document):
print("Scanning document: ", document)
old_printer = OldPrinter()
old_printer.print("This is an old printer") # Printing document: This is an old printer
modern_printer = ModernPrinter()
modern_printer.print("This is a modern printer") # Printing document: This is a modern printer
modern_printer.fax("This is a fax") # Faxing document: This is a fax
modern_printer.scan("This is a scan") # Scanning document: This is a scan
In this corrected example, we have separated the Printer interface into three smaller interfaces: Printer, Fax, and Scanner. We also modified the OldPrinter and ModernPrinter classes to implement only the interfaces they need. The OldPrinter class now only implements the Printer interface, and the ModernPrinter class implements all three interfaces. This ensures that the client of the OldPrinter class is not forced to depend on methods it does not use, and that the ModernPrinter class supports all three methods.
By separating the Printer interface into smaller, more focused interfaces, we have adhered to the ISP and improved the flexibility and reusability of our code.
D - 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 on abstractions.
Example
Here's an example of a violation of the DIP:
class PaymentProcessor:
def process_payment(self, payment):
if payment.type == "credit_card":
credit_card = CreditCard()
credit_card.process_payment(payment)
elif payment.type == "paypal":
paypal = PayPal()
paypal.process_payment(payment)
class CreditCard:
def process_payment(self, payment):
print("Processing credit card payment...")
class PayPal:
def process_payment(self, payment):
print("Processing PayPal payment...")
In this example, the PaymentProcessor class depends on the CreditCard and PayPal classes. The PaymentProcessor class creates instances of these classes and calls their process_payment() methods directly. This violates the DIP because the PaymentProcessor class depends on low-level modules (CreditCard and PayPal) instead of abstractions.
To correct this violation, we need to invert the dependencies by introducing an abstraction (interface) between the PaymentProcessor and the low-level modules. Here's an example of how we can correct this violation:
class PaymentProcessor:
def __init__(self, payment_gateway):
self.payment_gateway = payment_gateway
def process_payment(self, payment):
self.payment_gateway.process_payment(payment)
class PaymentGateway:
def process_payment(self, payment):
pass
class CreditCardGateway(PaymentGateway):
def process_payment(self, payment):
print("Processing credit card payment...")
class PayPalGateway(PaymentGateway):
def process_payment(self, payment):
print("Processing PayPal payment...")
credit_card_gateway = CreditCardGateway()
payment_processor = PaymentProcessor(credit_card_gateway)
payment_processor.process_payment(4) # Processing credit card payment...
paypal_gateway = PayPalGateway()
payment_processor = PaymentProcessor(paypal_gateway)
payment_processor.process_payment(7) # Processing PayPal payment...
In this corrected example, we have introduced an PaymentGateway interface between the PaymentProcessor and the low-level modules. The PaymentProcessor class now depends on the PaymentGateway interface instead of the CreditCard and PayPal classes. The PaymentGateway interface has a single method process_payment() which is implemented by the CreditCardGateway and PayPalGateway classes. The PaymentProcessor class now receives an instance of the PaymentGateway interface through its constructor and calls the process_payment() method on this instance to process the payment.
By introducing an abstraction (interface) between the PaymentProcessor and the low-level modules, we have inverted the dependencies and adhered to the DIP. This makes the PaymentProcessor class more flexible and reusable, and allows us to easily add new payment gateways without modifying the PaymentProcessor class.
Conclusion
In conclusion, the SOLID principles are a set of guidelines that help us write code that is easier to maintain, extend, and test. By following these principles, we can create code that is more modular, more decoupled, and more reusable, which can lead to better software design and higher productivity.
References
1) Coding with Johan Blogs
2) Code Specialist
3) Damavis SOLID blog
4) Levelup gitconnected
5) Github gist on SOLID
Top comments (0)