SOLID Principles in OOP
Published: 2023-02-20 07:06:05


# Solid principles in OOP

- The SOLID principles are helpful to build scalable, flexible, readable softwares. 
- These principles are intriduced by Bob Martin

### SOLID stands for:

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

## 1.Single Responsibility Principle (SRP)
*This principle states that each class should have only one responsibility or reason to modify class.

Example:
```
class FileHandler:
    def read_file(self, file_path):
        with open(file_path, 'r') as f:
            data = f.read()
        return data

    def write_file(self, file_path, data):
        with open(file_path, 'w') as f:
            f.write(data)
```
- In this example, FileHandler class has a single responsibility, which is to handle file operations such as reading and writing. If we need to add or modify file operations, we only need to change this class.

- Finally this SRP is a valuable principle that helps us write code that is easier to understand, modify, and extend. By focusing each class on a single responsibility, we can make our code more maintainable and reduce the likelihood of bugs and other issues.


## 2. The Open-Closed Principle (OCP)
It is a design principle in object-oriented programming that states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to add new functionality to a program without changing the existing code.

- we can implement the above using inheritance and polymorphism. Here is an example:

Suppose we have a program that calculates the area of different shapes (e.g. rectangle, circle, triangle). We can create a base class called Shape that defines the common properties and methods of all shapes:

```
class Shape:
    def area(self):
        pass
```
Then we can create different classes that inherit from the Shape class and implement their own area method:

```
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 Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height
```
Now, if we want to add a new shape (e.g. a square), we don't have to modify the existing code. We can simply create a new class that inherits from the Shape class and implements its own area method:

```
class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2
This way, we are able to extend the program without modifying the existing code, which is the essence of the Open-Closed Principle.
```

## 3.Liskov principle

The Liskov Substitution Principle is a concept in object-oriented programming that states that if a program is using a base class, it should be able to use any of its derived classes without knowing it. In simpler terms, it means that the derived classes should be able to substitute the base class without affecting the correctness of the program.

* we can demonstrate this principle through an example. Let's say we have a Rectangle class that has a width and a height property, and a calculate_area() method that calculates the area of the rectangle:

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

    def calculate_area(self):
        return self.width * self.height
```
Now, let's say we want to create a Square class that is a subclass of Rectangle. A square is just a special case of a rectangle where the width and height are the same, so we can create a Square class like this:

```
class Square(Rectangle):
    def __init__(self, side_length):
        super().__init__(side_length, side_length)
```
This implementation satisfies the Liskov Substitution Principle because we can substitute a Square object for a Rectangle object without affecting the correctness of the program. For example, if we have a function that takes a Rectangle object and calls its calculate_area() method, we can pass a Square object to it:

```
def print_area(rectangle):
    print(rectangle.calculate_area())

rectangle = Rectangle(3, 4)
square = Square(5)

print_area(rectangle)  # output: 12
print_area(square)  # output: 25
```
The print_area() function doesn't know or care whether it's dealing with a Rectangle or a Square object. It just calls the calculate_area() method, and the correct area is calculated. This demonstrates the Liskov Substitution Principle in action.


## 4.The Interface Segregation Principle (ISP):
It is a concept in object-oriented programming that states that a class should not be forced to implement interfaces that it does not use. In other words, a class should only be required to implement methods that are relevant to its behavior and should not be burdened with unnecessary methods.

* we can demonstrate this principle with an example. Let's say we have an Animal interface that has several methods, including eat(), sleep(), and play():

```
class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

    def play(self):
        pass
```
Now, let's say we want to create two classes that implement the Animal interface: a Dog class and a Cat class. Dogs and cats are both animals, but they have different behaviors. For example, dogs might have a bark() method and cats might have a purr() method. We don't want to force every animal to implement methods that are specific to one type of animal, so we can create separate interfaces for dogs and cats:

```
class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

class Dog(Animal):
    def bark(self):
        pass

class Cat(Animal):
    def purr(self):
        pass
```
In this implementation, the Animal interface only contains methods that are common to all animals, such as eat() and sleep(). The Dog and Cat classes extend the Animal class but also have their own specific methods, such as bark() for dogs and purr() for cats.

This implementation satisfies the Interface Segregation Principle because each class is only required to implement the methods that are relevant to its behavior. The Dog class doesn't need to implement the purr() method, and the Cat class doesn't need to implement the bark() method. By separating the interface into smaller, more specific interfaces, we can keep our code more modular and avoid unnecessary dependencies.


## 5.The Dependency Inversion Principle (DIP):
This principle states that high-level modules should not depend on low-level modules, but should instead depend on abstractions. In simpler terms, it means that instead of depending on specific implementations, classes should depend on abstract interfaces or classes.

### Below FileReader class reads data from a file:
```
class FileReader:
    def read_file(self, filename):
        with open(filename, 'r') as file:
            return file.read()
```
Now, let's say we have a DataProcessor class that processes data from a file:

```
class DataProcessor:
    def __init__(self, reader):
        self.reader = reader

    def process_data(self, filename):
        data = self.reader.read_file(filename)
        # process the data...
```
In this implementation, the DataProcessor class depends on the FileReader class to read data from a file. This violates the Dependency Inversion Principle because the high-level DataProcessor class is depending on the low-level FileReader class.

To fix this, we can create an abstract interface or class for reading files, and have the FileReader class implement that interface or inherit from that class. Then, we can modify the DataProcessor class to depend on the abstract interface or class instead of the FileReader class directly:

```
class FileReaderInterface:
    def read_file(self, filename):
        pass

class FileReader(FileReaderInterface):
    def read_file(self, filename):
        with open(filename, 'r') as file:
            return file.read()

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

    def process_data(self, filename):
        data = self.reader.read_file(filename)
        # process the data...
```

Now, the DataProcessor class depends on the FileReaderInterface abstract interface instead of the FileReader class directly. This allows us to easily swap out the FileReader class with a different file reader implementation that also implements the FileReaderInterface. We can also create other file readers that implement the FileReaderInterface interface or inherit from the FileReaderInterface class, and use them with the DataProcessor class without modifying the DataProcessor class.

This implementation satisfies the Dependency Inversion Principle because the high-level DataProcessor class depends on an abstraction instead of a specific implementation. This makes the code more modular and flexible, and allows us to easily switch out implementations without affecting other parts of the code.

### References

- https://www.youtube.com/playlist?list=PL6n9fhu94yhXjG1w2blMXUzyDrZ_eyOme
- https://www.youtube.com/playlist?list=PL6n9fhu94yhXjG1w2blMXUzyDrZ_eyOme
- https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english/
- https://levelup.gitconnected.com/s-o-l-i-d-principles-explained-in-python-with-examples-83b2b43bdcde