S.O.L.I.D Principles Explained With Code
S.O.L.I.D

S.O.L.I.D Principles Explained With Code

Writing clean, maintainable code is just as important as writing code that works.

The SOLID principles provide a blueprint for writing code that’s easy to adjust, extend, and maintain over time.

5 Principles:

  • S — Single-responsiblity Principle
  • O — Open-closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

S: Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change.

This means that a class must have only one responsibility.

How does this principle help us to build better software?

  1. Testing — A class with one responsibility will have far fewer test cases.
  2. Lower coupling — Less functionality in a single class will have fewer dependencies.
  3. Organization — Smaller, well-organized classes are easier to search than monolithic ones.

"""
Single Responsibility Principle

A class should have only one job. 
If a class has more than one responsibility, it becomes coupled. 
A change to one responsibility results to modification of the other responsibility.
"""

class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

    def save(self, animal: Animal):
        pass

"""
The Animal class violates the SRP.

How does it violate SRP?
SRP states that classes should have one responsibility, here, we can draw out two responsibilities: animal database management and animal properties management. 

The constructor and get_name manage the Animal properties while the save manages the Animal storage on a database.

How will this design cause issues in the future?
If the application changes in a way that it affects database management functions. The classes that make use of Animal properties will have to be touched and recompiled to compensate for the new changes.

To make this conform to SRP, we create another class that will handle the sole responsibility of storing an animal to a database:
"""

class Animal:
    def __init__(self, name: str):
            self.name = name
    
    def get_name(self):
        pass


class AnimalDB:
    def get_animal(self) -> Animal:
        pass

    def save(self, animal: Animal):
        pass

"""
When designing our classes, we should aim to put related features together, 
so whenever they tend to change they change for the same reason. 
And we should try to separate features if they will change for different reasons.
"""        

O: Open/Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

This means the design of a software entity should be such that you can introduce new functionality or behavior without modifying the existing code since changing the existing code might introduce bugs. i.e. the classes, modules, function should be open for extension but closed for modification. In doing so, we stop ourselves from modifying existing code and causing potential new bugs in an otherwise happy application.

"""
Open-Closed Principle

Software entities(Classes, modules, functions) should be open for extension, not modification.
"""
class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

animals = [
    Animal('lion'),
    Animal('mouse')
]

def animal_sound(animals: list):
    for animal in animals:
        if animal.name == 'lion':
            print('roar')

        elif animal.name == 'mouse':
            print('squeak')

animal_sound(animals)

"""
The function animal_sound does not conform to the open-closed principle because it cannot be closed against new kinds of animals.

If we add a new animal, Snake, We have to modify the animal_sound function.
You see, for every new animal, a new logic is added to the animal_sound function. 

This is quite a simple example. When your application grows and becomes complex, you will see that the if statement would be repeated over and over again in the animal_sound function each time a new animal is added, all over the application.
"""

animals = [
    Animal('lion'),
    Animal('mouse'),
    Animal('snake')
]

def animal_sound(animals: list):
    for animal in animals:
        if animal.name == 'lion':
            print('roar')
        elif animal.name == 'mouse':
            print('squeak')
        elif animal.name == 'snake':
            print('hiss')

animal_sound(animals)


"""
How do we make it (the animal_sound) conform to OCP?
"""

class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

    def make_sound(self):
        pass


class Lion(Animal):
    def make_sound(self):
        return 'roar'


class Mouse(Animal):
    def make_sound(self):
        return 'squeak'


class Snake(Animal):
    def make_sound(self):
        return 'hiss'


def animal_sound(animals: list):
    for animal in animals:
        print(animal.make_sound())

animals = [
    Lion('lion'),
    Mouse('mouse'),
    Snake('snake')]

animal_sound(animals)

"""
Animal now has a virtual method make_sound. We have each animal extend the Animal class and implement the virtual make_sound method.

Every animal adds its own implementation on how it makes a sound in the make_sound. 
The animal_sound iterates through the array of animal and just calls its make_sound method.
Now, if we add a new animal, animal_sound doesn’t need to change. 
All we need to do is add the new animal to the animal array.
animal_sound now conforms to the OCP principle.
"""

"""
Another example:
Let’s imagine you have a store, and you give a discount of 20% to your favorite customers using this class:
When you decide to offer double the 20% discount to VIP customers. You may modify the class like this:
"""

class Discount:
    def __init__(self, customer, price):
        self.customer = customer
        self.price = price

    def give_discount(self):
            if self.customer == 'fav':
                return self.price * 0.2
            if self.customer == 'vip':
                return self.price * 0.4


"""
No, this fails the OCP principle. OCP forbids it. If we want to give a new percent discount maybe, to a diff. 
type of customers, you will see that a new logic will be added.
To make it follow the OCP principle, we will add a new class that will extend the Abstract Class Discount. 
In this new class, we would implement its new behavior:
"""

from abc import ABC, abstractmethod
 
class Discount(ABC):
    @abstractmethod
    def get_discount(self):
        pass

class FavDiscount(Discount):
    def __init__(self, customer, price):
        self.customer = customer
        self.price = price

    def get_discount(self):
        return self.price * 0.2


class VIPDiscount(Discount):
    def __init__(self, customer, price):
        self.customer = customer
        self.price = price

    def get_discount(self):
        return self.price * 0.4        

L: Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

This means if you have a base class and a derived class, you should be able to use instances of the derived class wherever instances of the base class are expected, without breaking the application.

"""
Liskov Substitution Principle

A sub-class must be substitutable for its super-class.
The aim of this principle is to ascertain that a sub-class can assume the place of its super-class without errors. 
If the code finds itself checking the type of class then, it must have violated this principle.
Let’s use our Animal example.
"""

def animal_leg_count(animals: list):
    for animal in animals:
        if isinstance(animal, Lion):
            print(lion_leg_count(animal))
        elif isinstance(animal, Mouse):
            print(mouse_leg_count(animal))
        elif isinstance(animal, Pigeon):
            print(pigeon_leg_count(animal))
        
animal_leg_count(animals)

"""
To make this function follow the LSP principle, we will follow the LSP requirements:
If the super-class (Animal) has a method that accepts a super-class type (Animal) parameter. 
Its sub-class(Pigeon) should accept as argument a super-class type (Animal type) or sub-class type(Pigeon type).
If the super-class returns a super-class type (Animal). 
Its sub-class should return a super-class type (Animal type) or sub-class type(Pigeon).
Now, we can re-implement animal_leg_count function:
"""

def animal_leg_count(animals: list):
    for animal in animals:
        print(animal.leg_count())
        
animal_leg_count(animals)

"""
The animal_leg_count function cares less the type of Animal passed, it just calls the leg_count method. 
All it knows is that the parameter must be of an Animal type, either the Animal class or its sub-class.
The Animal class now have to implement/define a leg_count method.
And its sub-classes have to implement the leg_count method:
"""

class Animal:
    def leg_count(self):
        pass

class Lion(Animal):
    def leg_count(self):
        return 4

class Mouse(Animal):
    def leg_count(self):
        return 4

class Pigeon(Animal):
    def leg_count(self):
        return 2


"""
When it’s passed to the animal_leg_count function, it returns the number of legs a lion has.
You see, the animal_leg_count doesn’t need to know the type of Animal to return its leg count, 
it just calls the leg_count method of the Animal type because by contract a sub-class of Animal class must implement the leg_count function.
"""        

I: Interface Segregation Principle (ISP)

No client should be forced to depend on interfaces they don’t use.

The main idea behind ISP is to prevent the creation of “fat” or “bloated” interfaces that include methods that are not required by all clients.

By segregating interfaces into smaller, more specific ones, clients only depend on the methods they actually need, promoting loose coupling and better code organization.

nd on interfaces they don’t use.

The main idea behind ISP is to prevent the creation of “fat” or “bloated” interfaces that include methods that are not required by all clients.

By segregating interfaces into smaller, more specific ones, clients only depend on the methods they actually need, promoting loose coupling and better code organization.

"""
Interface Segregation Principle

Make fine grained interfaces that are client specific.
Clients should not be forced to depend upon interfaces that they do not use.

This principle deals with the disadvantages of implementing big interfaces.
Let’s look at the below IShape interface:
"""

class IShape:
    def draw_square(self):
        raise NotImplementedError
    
    def draw_rectangle(self):
        raise NotImplementedError
    
    def draw_circle(self):
        raise NotImplementedError

"""
This interface draws squares, circles, rectangles. 
Class Circle, Square or Rectangle implementing the IShape interface must define the methods draw_square(), draw_rectangle(), draw_circle().
"""

class Circle(IShape):
    def draw_square(self):
        pass

    def draw_rectangle(self):
        pass
    
    def draw_circle(self):
        pass

class Square(IShape):
    def draw_square(self):
        pass

    def draw_rectangle(self):
        pass
    
    def draw_circle(self):
        pass

class Rectangle(IShape):
    def draw_square(self):
        pass

    def draw_rectangle(self):
        pass
    
    def draw_circle(self):
        pass

"""
It’s quite funny looking at the code above. Class Rectangle implements methods (draw_circle and draw_square) it has no use of, likewise Square implementing draw_circle, and draw_rectangle, and class Circle (draw_square, raw_rectangle).
If we add another method to the IShape interface, like draw_triangle(),
"""

class IShape:
    def draw_square(self):
        raise NotImplementedError
    
    def draw_rectangle(self):
        raise NotImplementedError
    
    def draw_circle(self):
        raise NotImplementedError
    
    def draw_triangle(self):
        raise NotImplementedError


"""
the classes must implement the new method or error will be thrown.

We see that it is impossible to implement a shape that can draw a circle but not a rectangle or a square or a triangle. 
We can just implement the methods to throw an error that shows the operation cannot be performed.

ISP frowns against the design of this IShape interface. 
To make our IShape interface conform to the ISP principle, we segregate the actions to different interfaces.
Classes (Circle, Rectangle, Square, Triangle, etc) can just inherit from the IShape interface and implement their own draw behavior.
"""

class IShape:
    def draw(self):
        raise NotImplementedError

class Circle(IShape):
    def draw(self):
        pass

class Square(IShape):
    def draw(self):
        pass

class Rectangle(IShape):
    def draw(self):
        pass        


D: Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules; both should depend on abstractions.

This means that a particular class should not depend directly on another class, but on an abstraction (interface) of this class.

Applying this principle reduces dependency on specific implementations and makes our code more reusable.

"""
Dependency Inversion Principle

Dependency should be on abstractions not concretions
A. High-level modules should not depend upon low-level modules. Both should depend upon abstractions.
B. Abstractions should not depend on details. Details should depend upon abstractions.
"""

class GmailClient:
    def send_email(self, recipient, subject, body):
        # Logic to send email using Gmail API

class EmailService:
    def __init__(self):
        self.gmail_client = GmailClient()
    
    def send_email(self, recipient, subject, body):
        self.gmail_client.send_email(recipient, subject, body)

"""
In this example, the EmailService class directly depends on the GmailClient class, a low-level module that implements the details of sending emails using the Gmail API.

This violates the DIP because the high-level EmailService module is tightly coupled to the low-level GmailClient module.

To adhere to the DIP, we can introduce an abstraction (interface) for email clients.
"""

class EmailClient:
    def send_email(self, recipient, subject, body):
         raise NotImplementedError

class GmailClient(EmailClient):
    def send_email(self, recipient, subject, body):
        # Logic to send email using Gmail API

class OutlookClient(EmailClient):
    def send_email(self, recipient, subject, body):
        # Logic to send email using Outlook API

class EmailService:
    def __init__(self, email_client):
        self.email_client = email_client
    
    def send_email(self, recipient, subject, body):
        self.email_client.send_email(recipient, subject, body)


# Usage
gmail_client = GmailClient()
email_service = EmailService(gmail_client)
email_service.send_email("[email protected]", "Subject", "Email Body")

"""
Now, we can see that EmailService class depends on the EmailClient abstraction, and the low-level email client implementations (GmailClient and OutlookClient) depend of the abstraction.
"""        

SOLID is fear of most junior developers. But as you can see it is really easy to learn and implement. Whatever you work on, try to implement SOLID principles, you will see the result immediately. You will like your codes and other developers also will like your codes because it will be much easier to understand.


BONUS

DRY Principle Dont Repeat Yourself — it tells you what to do with its name. Just do not repeat yourself when you are coding. Repeating yourself looks very funny. Take coding as talking or writing an essay. Would you mention in your essay “Bus” or “Plane” twice right after? Of course, you wouldn’t. Then why should you do it in your code? Let me explain what I mean with an example.

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system
# Without DRY principle
def calculate_area_rectangle(width, height):
    return width * height

def calculate_area_circle(radius):
    return 3.14 * radius ** 2

def calculate_area_triangle(base, height):
    return 0.5 * base * height

# With DRY principle
def calculate_area(shape, *args):
    if shape == "rectangle":
        return args[0] * args[1]
    elif shape == "circle":
        return 3.14 * args[0] ** 2
    elif shape == "triangle":
        return 0.5 * args[0] * args[1]

print(calculate_area("rectangle", 4, 5)) 
print(calculate_area("circle", 2)) 
print(calculate_area("triangle", 3, 6))         

Like reading it, you can follow this newsletter , I share a post every

Friday here .


Thank you so much for reading!


If you want to read my learnings on any specific topic, let me know in

the comment box. I will tag you too if I write on that.


xx

Abhijit Paul


要查看或添加评论,请登录

社区洞察

其他会员也浏览了