DEV Community

Abraar Reinhardt
Abraar Reinhardt

Posted on

Deeper Dive into S.O.L.I.D Principles with Python.

SOLID is an acronym that stands for five object-oriented design principles that help to make software more maintainable, scalable, and easy to extend. The principles are a subset of many principles promoted by Robert C. Martin (AKA Uncle Bob) which were first introduced in his 2000 paper Design Principles and Design Patterns discussing software rot. The SOLID acronym was introduced later, around 2004, by Michael Feathers.


Here are the high level description of each principle. (Take a look, don’t worry too much. They are explained with examples later) -

         1. Single Responsibility Principle (SRP) : Software artefacts (usually a class, module, or interface) should have only one reason to change.

Spork vs Spoon and Fork

         2. Open-Closed Principle (OCP) : Software entities should be open for extension but closed for modification.

OCP

         3. Liskov Substitution Principle (LSP) : Sub-types must be substitutable for their base types.

Duck LSP

         4. Interface Segregation Principle (ISP): : Clients should not be forced to depend on interfaces they do not use.

ISP

         5. Dependency Inversion Principle (DIP): : High-level modules should not depend on low-level modules, but both should depend on abstractions.

DIP

Rest assured, you don’t have memorize or understand all these now. Let's dive a little deeper into each one.



A. Single Responsibility Principle : SRP

The SRP states that a class should have only one responsibility or reason to change. In other words, a class should have only one job or function, and should not be responsible for doing more than that one job.

SRP

So, How do we achieve SRP?

  1. Separating concerns: Separate the concerns of the class into smaller, more specialised classes. Each class should be responsible for a single concern, and not overlap with other classes. For example, a class that handles both database operations and user interface interactions could be split into two classes, one for database operations and one for user interface interactions.
  2. Creating helper classes: A class can have a single responsibility, but the responsibility may be complex or involve multiple tasks. In such cases, the class can create helper classes that perform specific tasks. For example, a class that generates reports could have a helper class that formats the report, and another helper class that retrieves data for the report.
  3. Encapsulating responsibilities: Another way to achieve SRP is to encapsulate the responsibilities of a class into well-defined interfaces or modules. By encapsulating responsibilities, changes to one module or interface will not affect other modules or interfaces. This allows the code to be more modular and easier to maintain over time.
  4. Delegating responsibilities: A class can also delegate responsibilities to other classes, rather than performing all operations itself.

Overall, achieving SRP in code involves breaking down classes into smaller, more focused units of responsibility, encapsulating responsibilities, delegating responsibilities, and refactoring code. By following these principles, we can create code that is more maintainable, extensible, and easier to understand over time.

No code examples here, use your own judgement for SRP. However, here's a blog that has a nice exaple in it - The Single Responsibility Principle
Now,



B. The Open-Closed Principle : OCP

OCP is achieved in code by designing software entities such as classes, modules, functions, etc. that are open for extension but closed for modification. This means that we should be able to extend the behavior of a software entity without changing its existing implementation.

A Python Example -

Here, we have created a hierarchy of classes that represent shapes, including Shape, Rectangle, and Circle. Each of these classes implements the area() method, which calculates the area of the respective shape.

class Shape:
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

class AreaCalculator:
    def calculate(self, shapes):
        total_area = 0
        for shape in shapes:
            total_area += shape.area()
        return total_area
Enter fullscreen mode Exit fullscreen mode

We have also created an AreaCalculator class, which takes a list of shapes as input and calculates the total area of all the shapes. This class is closed for modification, meaning we can add new shapes to our hierarchy without having to modify the AreaCalculator class.

For example, if we wanted to add a new shape, such as a triangle, we could simply create a new Triangle class that implements the area() method, and the AreaCalculator class would be able to handle it without any modification. This demonstrates the Open-Closed Principle, which states that software entities (classes, modules, etc.) should be open for extension but closed for modification.

Ways of the OCP -

  1. Abstraction: We can use abstraction to define a generic interface for a set of related operations, and then create concrete implementations of that interface for specific use cases. This allows us to add new implementations without modifying the existing ones.
  2. Polymorphism: We can use polymorphism to allow objects of different types to be treated as if they were of the same type. This allows us to write code that can work with different implementations of the same interface without having to know the specifics of each implementation.
  3. Inheritance: We can use inheritance to create a hierarchy of classes, where each class inherits behavior from its parent classes. This allows us to create new classes that inherit existing behavior, and add new behavior on top of it without modifying the existing classes.
  4. Composition: We can use composition to combine smaller, more specialized classes into larger, more complex classes. This allows us to create new behavior by composing existing behavior, without having to modify the existing classes.

Overall, the key idea behind achieving the OCP is to separate behavior that is likely to change from behavior that is unlikely to change, and to create well-defined interfaces between them. By doing this, we can make our software more flexible, maintainable, and extensible. In the end, we just don’t want to face a situation like this :
https://twitter.com/i/status/1064871830358503425
Sorry for the lame meme. Let's move on to-



C. Liskov Substitution Principle : LSP

LSP states that objects of a superclass should be able to be replaced with objects of its subclasses without affecting the correctness of the program. In other words, if we have a program that is designed to work with objects of a particular type, we should be able to use objects of any of its subtypes without breaking the program.

To achieve LSP, we can follow a few guidelines when designing our classes and interfaces:

  1. All subclasses should behave in the same way as their superclass. This means that any methods or properties defined in the superclass should also be present in the subclasses, and should behave in the same way.
  2. Subclasses should not add any additional preconditions or stronger postconditions to the methods defined in the superclass. In other words, they should not impose any additional constraints on the inputs or outputs of the methods.
  3. Subclasses should not override any methods of the superclass in a way that changes their semantics. The overridden methods should behave in the same way as the superclass methods.

LSP explained with Code -

First, take a look at this meme:

LSP with car

This is a good analogy for the LSP. In the same way that you can learn to drive one car and then be able to drive any car afterwards, if we design our classes and interfaces according to LSP, we can write code that works with one object and then be able to work with any object that is a subtype of that object without modifying the code.

For example, if we have a program that works with objects of a class Vehicle, and we follow the LSP when designing our classes, we should be able to use any object that is a subtype of Vehicle, such as a Car, Truck, or Motorcycle, without having to change the code that works with Vehicle objects.

This is because all of the subtypes of Vehicle should have the same behavior as Vehicle, and should be able to respond to the same methods and properties in the same way. This means that we can use them interchangeably without affecting the correctness of the program.

Just like you can use your knowledge of driving one car to drive any other car, we can use our knowledge of working with one type of object to work with any subtype of that object, as long as we follow the LSP.

class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model

    def start_engine(self):
        print("Starting engine...")

    def stop_engine(self):
        print("Stopping engine...")

class Car(Vehicle):
    def __init__(self, make, model, num_doors):
        super().__init__(make, model)
        self.num_doors = num_doors

    def start_engine(self):
        print("Starting car engine...")

class Motorcycle(Vehicle):
    def __init__(self, make, model, num_wheels):
        super().__init__(make, model)
        self.num_wheels = num_wheels

    def start_engine(self):
        print("Starting motorcycle engine...")
Enter fullscreen mode Exit fullscreen mode

In this example, we have a Vehicle class that defines basic properties and methods for a vehicle, such as make, model, start_engine(), and stop_engine(). We also have two subclasses of Vehicle: Car and Motorcycle. Each of these subclasses overrides the start_engine() method to provide their own implementation of starting the engine.

We can create objects of these classes and use them interchangeably, because they all share the same interface and behavior:

def drive(vehicle):
    vehicle.start_engine()
    print("Driving...")
    vehicle.stop_engine()

car = Car("Toyota", "Camry", 4)
motorcycle = Motorcycle("Harley-Davidson", "Sportster", 2)

drive(car)        # Starting car engine... Driving... Stopping engine...
drive(motorcycle) # Starting motorcycle engine... Driving... Stopping engine...
Enter fullscreen mode Exit fullscreen mode

In this code, the drive()function takes an object of the Vehicleclass, which can be any subtype of Vehicle, and calls its start_engine(), drive(), and stop_engine()methods. We can pass in either a Caror a Motorcycleobject, and the code works the same way for both, without having to modify the drive()function. This is an example of LSP in action.



D. Interface Segregation Principle : ISP

ISP states that "clients should not be forced to depend on interfaces they do not use." This means that we should design our interfaces to be as specific and focused as possible (Look at the cookie meme), so that clients only need to depend on the parts of the interface that they actually use.

dvav
Check this link if you don't understand the meme-
https://stackoverflow.com/questions/56174598/understanding-the-motivational-poster-for-the-interface-segregation-principle

Idea

The idea behind this one is that “no client should be forced to depend on methods it does not use”. What this actually means is that a class should be correctly abstracted so only have the methods it needs (think Single Responsibility). It also allows for larger classes (more common in historical work) where clients can interface with only the methods they need and not be exposed to those they don’t. ISP is basically "Single Responsibility Principle applied to interfaces".

Common ways to achieve ISP

  1. Break down large interfaces into smaller, more focused interfaces: Instead of creating a single large interface that includes many methods, break it down into smaller interfaces that each have a specific purpose. This allows clients to depend only on the interfaces they need.
  2. Use Adapter classes: If you have a class that implements an interface, but not all of the methods in that interface are relevant to that class, you can create an adapter class that implements the interface and provides default implementations for the irrelevant methods. This allows clients to depend on the adapter class instead of the original class.
  3. Default implementations: For interfaces that have many methods, you can provide default implementations that do nothing or raise an exception. This allows clients to only implement the methods they need and ignore the rest.
  4. By Delegation: If a class needs to implement multiple interfaces, but not all of the methods in those interfaces are relevant to that class, you can delegate the implementation of the irrelevant methods to other classes or objects. This allows the main class to only implement the methods it needs.

Here's an example of how we can achieve ISP in Python code

We have a Vehicle class with a start_engine() method, which is implemented by its subclasses Car and Motorcycle. These subclasses only provide the functionality that is relevant to them, and do not implement any unnecessary methods. We also have an Airplane class with a fly() method, which is not related to the Vehicle hierarchy.

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        print("Starting car engine...")

class Motorcycle(Vehicle):
    def start_engine(self):
        print("Starting motorcycle engine...")

class Airplane:
    def fly(self):
        print("Flying airplane...")

class AirplaneAdapter(Vehicle):
    def __init__(self, airplane):
        self.airplane = airplane

    def start_engine(self):
        self.airplane.fly()
Enter fullscreen mode Exit fullscreen mode

However, we can still make use of the Airplane class by creating an AirplaneAdapter class, which implements the Vehicle interface by wrapping an instance of the Airplane class. This allows us to use an Airplane object as if it were a Vehicle object, even though it does not inherit from the Vehicle class.

By using the AirplaneAdapter class, we are following the ISP, because clients of the Vehicle interface only need to depend on the start_engine() method, which is the only method that is relevant to them. They do not need to depend on the fly() method, which is specific to the Airplane class.

def start_and_stop_engine(vehicle):
    vehicle.start_engine()
    print("Stopping engine...")

car = Car()
motorcycle = Motorcycle()
airplane = Airplane()
adapter = AirplaneAdapter(airplane)

start_and_stop_engine(car)         # Starting car engine... Stopping engine...
start_and_stop_engine(motorcycle)  # Starting motorcycle engine... Stopping engine...
start_and_stop_engine(adapter)     # Flying airplane... Stopping engine...
Enter fullscreen mode Exit fullscreen mode

In this code, we have a start_and_stop_engine() function that takes any object that implements the Vehicle interface, and calls its start_engine() method followed by a print statement. We can pass in either a Car, Motorcycle, or AirplaneAdapter object, and the code works the same way for all of them, without having to modify the start_and_stop_engine() function. This is an example of ISP in action.

This will sound a bit confusing the first time you read it. If you couldn’t comprehend all that mumbo-jumbo, just remember that the main goal of this principle is to write code in a way so that devs using our code doesn’t feel like this-

Image description



E. Dependency Inversion Principle : DIP

DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.

In other words, DIP suggests that classes or modules should depend on abstractions or interfaces, rather than on concrete implementations. This allows for greater flexibility and modularity in the design, as changes to the low-level modules do not affect the high-level modules.

fbfb f

Here's an example of how to apply the Dependency Inversion Principle (DIP) in Python:

Let's say we have two classes, FileReader and FileProcessor, where FileReader reads data from a file and FileProcessor processes the data. The FileProcessor class depends on the FileReader class to get the data. However, if we want to change the way the data is read (for example, from a different file format or from a database instead of a file), we would have to change the FileProcessor class as well.

# Without DIP:

class FileReader:
    def read(self, filename):
        # read data from file
        pass

class FileProcessor:
    def __init__(self):
        self.reader = FileReader()

    def process(self, filename):
        data = self.reader.read(filename)
        # process data
        pass**
Enter fullscreen mode Exit fullscreen mode
# With DIP:

class IReader:
    def read(self, filename):
        pass

class FileReader(IReader):
    def read(self, filename):
        # read data from file
        pass

class FileProcessor:
    def __init__(self, reader):
        self.reader = reader

    def process(self, filename):
        data = self.reader.read(filename)
        # process data
        pass
Enter fullscreen mode Exit fullscreen mode

In this example, we define an interface IReader that defines the read method, and the FileReader class implements the interface. The FileProcessor class no longer depends on the FileReader class directly, but on the IReader interface, which can be implemented by any class that reads data.

By using an interface, we can easily swap out the implementation of the IReader interface (for example, by creating a new class that reads data from a database), without having to modify the FileProcessor class. This makes our code more flexible and easier to maintain.

How to achieve DIP

  1. Use interfaces or abstract classes to define dependencies: Instead of depending on concrete classes directly, use interfaces or abstract classes to define the required behavior. This allows you to swap out the implementation without changing the code that depends on it.
  2. Use Dependency injection: Instead of creating and managing dependencies within a class or module, use a dependency injection framework to inject the required dependencies at runtime. This allows you to easily swap out dependencies without changing the code that uses them.
  3. Use Inversion of control containers: An inversion of control (IoC) container is a framework that manages the creation and configuration of objects and their dependencies. By using an IoC container, you can achieve automatic dependency injection and reduce the amount of boilerplate code required for managing dependencies.

Dependency Inversion is a design principle to limit the effect of changes to low-level classes in your projects.

Think of a base class like ‘make call’. This class worked great 50 years ago when calls were only made in one way, but now we can make wi-fi calls, skype, whatsapp and more. To add these into our bad example would require modifying the base class to enable support for other methods and means that every time we add or edit one of these we would need to test all other versions because they derive from the same class.

Irr

Dependency inversion states that classes should depend on abstractions. So in this case, we could define an interface for the call and them implement methods for ‘Landline’, ‘Skype’ etc.

Dependency Inversion isn't really a separate principle, but is rather a combination of Single Responsibility Principle and Liskov Substitution Principle.



Wrapping it all up

So, In conclusion, the SOLID principles are a cool set of guidelines for writing clean, maintainable, and extensible code.

While the SOLID principles are not always easy to apply, especially for beginners, it's important to keep in mind that they are not strict rules, but rather guidelines that can be adapted to suit different situations. However, by understanding and applying these principles when appropriate, we can produce higher quality code with flexibility, scalability that will save us time and effort in the long run and ultimately deliver better software products.

Top comments (2)

Collapse
 
mmascioni profile image
Matt Mascioni

Nice introduction! Loved the meme selection too 😊

Collapse
 
kainatraisa21 profile image
kainat Raisa

Insightful and super helpful for the beginner programmers!