DEV Community

Anirban Das
Anirban Das

Posted on

SOLID concepts with Python code samples

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...")
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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)