SOLID principles illustrated in simple Python examples

SOLID principles facilitate testing, maintenance and readability of code, as well as more agile software deployment, greater code reusability and scalability, and improved bug debugging.

Introduction

In the field of object-oriented programming and software design, SOLID principles are a set of 5 principles that facilitate code testing, maintenance and readability. The benefits of a team adopting these principles in their code development include more agile software deployment, increased code reusability and scalability, and improved debugging. These principles are a subset of those stated by Robert C. Martin, known as Uncle Bob, in his article Design Principles and Design Patterns.

SOLID is the mnemonic acronym that refers to each of the principles by its acronym in English. These acronyms are:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  •  Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

SOLID principles are among the best known in the world of software design and are a good basis for developing code in collaborative environments, for example, in the field of data engineering. At Damavis we like to apply these good programming practices to offer quality software to our clients. Next, the SOLID principles are illustrated one by one with some simple examples written in Python.

1. Single Responsibility Principle (SRP)

The first principle of SOLID called the Single Responsibility Principle states that a class should be responsible for only one functionality. In other words, the class should only have a single reason to change. In the following simple example a Duck class with 5 different methods is defined.

class Duck:
   
    def __init__(self, name):
        self.name = name
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, duck2: Duck):
        print(f"{self.name}: {self.do_sound()}, hello {duck2.name}")

The main functionality of this class is to define a duck. If this definition needs to be changed, this class will be changed. The problem lies in the greet() method, which is responsible for being able to talk to other ducks. If you wanted to change the functionality of the conversation between ducks, you would be changing the Duck class as well, i.e., there would be an additional reason to change the class. The consequences of not respecting this principle may be several, such as making error debugging more difficult, since several errors point to the same place and the functionalities are more closely coupled.

To solve this problem in the case of the example, a new Communicator class is defined that takes care of all the communication functionality. This new class allows a conversation between two ducks, where they greet each other. In this way, the communication functionality has been changed without affecting the Duck class.

class Duck:
   
    def __init__(self, name):
        self.name = name
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

class Communicator:

    def __init__(self, channel):
        self.channel = channel

    def communicate(self, duck1 : Duck, duck2: Duck):
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = [sentence1, sentence2]
        print(*conversation,
              f"(via {self.channel})",
              sep = '\n')

2. Open-Closed Principle (OCP)

The Open/Closed Principle indicates that classes should be open for extension, but closed for modification. In other words, the code should be written in such a way that, when adding new functionality, previously written code, which may be in use by other users, should not be modified.

In the previous example, it is not possible to extend the Communicator functionality to add different types of conversations without modifying the communicate() method. To accomplish with the second principle, an AbstractConversation class is created that will be responsible for defining different types of conversations in its subclasses with implementations of do_conversation(). In this way, the communicate() method of Communicator will only be used to carry out the communication through a channel and its modification will never be required (it is a final method).

from typing import final

class AbstractConversation:

    def do_conversation(self) -> list:
        pass

class SimpleConversation(AbstractConversation):

    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return [sentence1, sentence2]

class Communicator:  

    def __init__(self, channel):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(),
              f"(via {self.channel})",
              sep = '\n')

3. Liskov Substitution Principle (LSP)

Liskov’s Principle of Substitution states that classes should be substitutable by instances of their subclasses. To illustrate this principle, we consider the possibility that new birds may be added in the future. To this end, a good practice is to add abstract class Bird and have birds such as Duck implement its methods. From here it is also possible to define, from Bird, a subclass of Crow.

from abc import ABC, abstractmethod

class Bird(ABC):
   
    def __init__(self, name):
        self.name = name
   
    @abstractmethod
    def fly(self):
        pass

    @abstractmethod
    def swim(self):
        pass

    @abstractmethod
    def do_sound(self) -> str:
        pass

class Crow(Bird):
   
    def fly(self):
        print(f"{self.name} is flying high and fast!")

    def swim(self):
        raise NotImplementedError("Crows don't swim!")

    def do_sound(self) -> str:
        return "Caw"

class Duck(Bird):
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

This small change has generated a problem: the methods of SimpleConversation depend on Duck. This means that this class could not accept, for example, instances of the Crow class. Therefore, the dependency on Duck must be changed to Bird. After making this change, Bird can be replaced with any instance of its subclasses without causing any problem, that is, the third principle is being respected.

class SimpleConversation(AbstractConversation):
   
    def __init__(self, bird1: Bird, bird2: Bird):
        self.bird1 = bird1
        self.bird2 = bird2

    def do_conversation(self) -> list:
        sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
        sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
        return [sentence1, sentence2]

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to rely on methods they do not use and therefore suggests the creation of specific interfaces or classes for such clients. In the previous section, a new class Crow has been added that describes a crow. But there is a problem in that definition: the crow does not know how to swim and the abstract class Bird forces us to define swim().

To solve this problem, the Bird class must be segregated. To do so, two new abstract classes are defined for those birds that can swim (SwimmingBird) and those that can fly (FlyingBird), which will extend the functionality of Bird. Thus, Crow implements the functionality of FlyingBird and Duck implements the functionality of SwimmingBird and FlyingBird. Now, if the penguin, which can swim but does not fly, were to be implemented, only SwimmingBird should be extended.

class Bird(ABC):

    def __init__(self, name):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass

class FlyingBird(Bird):

    @abstractmethod
    def fly(self):
        pass

class SwimmingBird(Bird):

    @abstractmethod
    def swim(self):
        pass

class Crow(FlyingBird):
   
    def fly(self):
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"

class Duck(SwimmingBird, FlyingBird):
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

5. Dependency Inversion Principle (DIP)

The last principle called Principle of Dependency Inversion can be separated into two statements. On the one hand, it indicates that abstractions should not depend on details, since details should depend on abstractions.  On the other hand, it indicates that high-level classes should not depend on low-level classes, since both should depend on abstractions. In summary, abstractions should depend on abstractions.

To illustrate this principle, it is decided that the channel now defined in the communicator class will have more functionalities in the future. Following the first principle (SRP), this new responsibility is extracted from the Communicator class and assigned to a new abstract class AbstractChannel. Additionally, the communicator class is now made abstract (AbstractCommunicator) so that it defines a concrete channel. This new abstraction and the final method get_channel_message() in AbstractChannel help us to fulfill the second principle (OCP) since communicate() will not need to be modified to use different channels.

class AbstractChannel(ABC):

    def get_channel_message(self) -> str:
        pass

class AbstractCommunicator(ABC):

    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep = '\n')

From here, a channel and a communicator can be defined so that our intelligent birds can send SMS messages to each other:

class SMSChannel(AbstractChannel):

    def get_channel_message(self) -> str:
        return "(via SMS)"

class SMSCommunicator(AbstractCommunicator):  

    def __init__(self):
        self._channel = SMSChannel()

    def get_channel(self) -> AbstractChannel:
        return self._channel

Unfortunately, this implementation does not respect the Dependency Inversion Principle. This is because inside SMSCommunicator you are calling SMSChannel, that is, you are depending on details and not on abstract classes. To solve this problem, a communicator is defined that depends directly on the abstraction.

class SimpleCommunicator(AbstractCommunicator):  

    def __init__(self, channel : AbstractChannel):
        self._channel = channel
   
    def get_channel(self) -> str:
        return self._channel

Conclusions

We hope that these examples have been useful to understand the basics of SOLID principles. Thanks to these good practices, you get cleaner and reusable code that can be very useful when different colleagues develop different parts of the software or will work on it in the future.

If you found this article interesting, we encourage you to visit the Software category of our blog to see posts similar to this one and to share it on social networks with all your contacts. Don’t forget to mention us to let us know your opinion @Damavisstudio, see you soon!

Guillermo Camps
Guillermo Camps
Articles: 16