SOLID concepts with Python code samples
Tags: solidoop, oop, python
Published: 2023-02-20 06:48:06

## 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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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](https://codingwithjohan.com/)
2) [Code Specialist](https://code-specialist.com/code-principles/solid)
3) [Damavis SOLID blog](https://blog.damavis.com/en/solid-principles-illustrated-in-simple-python-examples/)
4) [Levelup gitconnected](https://levelup.gitconnected.com/s-o-l-i-d-principles-explained-in-python-with-examples-83b2b43bdcde)
5) [Github gist on SOLID](https://gist.github.com/dmmeteo/f630fa04c7a79d3c132b9e9e5d037bfd)