Solid Design Principles

Solid Design Principles

What are SOLID Principles?

They are a set of design principles for object-oriented design which make the different classes of your project loosely coupled (less dependency on each other) so that the code becomes flexible, testable, maintainable and reusable.

The SOLID principle is composed of five different design principles:

  1. S- Single Responsibility Principle

  2. O- Open-Closed Principle

  3. L- Liskov Substitution Principle

  4. I- Interface Segregation Principle

  5. D- Dependency Inversion Principle

Let's explore each of them...

1- SINGLE RESPONSIBILITY PRINCIPLE :

States that a class should have only one reason to change or should have only one responsibility/purpose. More specifically, it can also be described that a module (class, function, package) can be changed by only one actor and it should not change the behaviour. When a class/function has multiple responsibilities then it can be separated into different classes/functions. Each time a class is modified the risk of introducing bugs grows.

SRP violation example -

Consider the below Employee class having two methods to add and delete information of the respective employee.

#include<iostream>
#include<string>
using namespace std;

class Employee{

    string name;
    string mailId;
    int id;

    public:
        // constructor
       Employee(int id, string name, string mailId){
            // initialization
        }

       void addEmployee(){
            //1. Here you will write your code to add employee info to repository/database
            //2. If the employee addition fails then raise an exception
            //3. Once the employee is saved, send mail to the respective employee
        }

       void deleteEmployee(){
            //1. Here you will write your code to delete employee info from repository/database
            //2. If the employee deletion fails then raise an exception
            //3. Once the employee info is deleted, send mail to the respective employee
        }
};

In the above example, the code violates SRP because the methods addEmployee() & deleteEmployee() have more than one responsibility.

Solution-

We can create different methods for different responsibilities. See the below code.

#include<iostream>
#include<string>
using namespace std;

class Employee{

    string name;
    string mailId; 
    int id;

    public:
        // constructor
        Employee(int id, string name, string mailId){
            // initialization
        }

        void addEmployee(){
            // code to add employee info to repository/database
        }

        void deleteEmployee(){
            // code to delete employee info from repository/database
        }

        void sendMail(){
            // code for sending mail to the employee if employee info added or deleted
        }

        void logException(){
            // code for raising exception
        }
};

Still, the above code violates SRP because the class Employee has more than one reason to change. So to solve this problem we will decompose different functionalities into different classes as well. See the below code.

#include<iostream>
#include<string>
using namespace std;

class Employee{

    string name;
    string mailId;
    int id;

    public:
        // constructor
        Employee(int id, string name, string mailId){
            // initialization
        }

        void getEmployeeId(){
            // code to get employee id
        }

        void getEmployeeName(){
           // code to get employee name
        }

        void getEmployeeMailId(){
           // code to get employee mail id
        }    
};

class EmployeeAdder{
    public:
    void addEmployee(Employee obj){
            // code to add employee info to repository/database
        }
};

class EmployeeDeleter{
    public:
    void deleteEmployee(Employee obj){
            // code to delete employee info from repository/database
        }
};

class MailSender{
    public:
    void sendMail(Employee obj){
            // code for sending mail to the employee if employee info added or deleted
        }
};

class ExceptionLogger{
    public:
    void logException(){
            // code for raising exception
        }
};

The above code follows SRP.

2- OPEN-CLOSED PRINCIPLE :

States that a class should be open for extension and closed for modification. With time the code becomes lengthy and may require adding new functionalities. In such cases, the working code must not be changed i.e. it should be closed for modification. Only the extension of new functionality should be done.
To understand this concept you should know the inheritance property of classes.

OCP violation example -

Consider the below code which calculates areas of different shapes.

#include<iostream>
#include<string>
using namespace std;

class Rectangle{
    double width;
    double height;
    public:
    // constructor
    Rectangle(double width, double height){
        // initialization
    }

    double getWidth(){
        // returns width
    }

    double getHeight(){
        // returns height
    }
};

class Circle{
    double radius;
    public:
    // constructor
    Circle(double radius){
        // initialization
    }

    double getRadius(){
        // returns radius
    }
};

class AreaCalculator{
    double area;
    public:
    double calculateArea(Rectangle *r = nullptr, Circle *c = nullptr){
        if(r != nullptr){
            area = r->getWidth() * r->getHeight();
        }
        else if(c != nullptr){
            area = 3.14 * c->getRadius() * c->getRadius();
        }
    }

    double getArea(){
        // returns area
    }
};

The above code violates OCP because whenever there is a new requirement calculating the area of new shapes, then we have to change the existing code in the AreaCalculator class based on the new shape object.

Solution-

We can create a 'Shapes' base class and can extend it to create different child classes.

#include<iostream>
#include<string>
using namespace std;

class Shapes{
    public:
    virtual void calculateArea() = 0;
    virtual double getArea() = 0;
};

class Rectangle : public Shapes{
    double width;
    double height;
    double rectangleArea;

    public:
    // constructor
    Rectangle(double width, double height){
        // initialization
    }

    double getWidth(){
        // returns width
    }

    double getHeight(){
        // returns height
    }

    // override
    void calculateArea(){
        rectangleArea = width * height;
    }

    // override
    double getArea(){
        return rectangleArea;
    }
};

class Circle : public Shapes{
    double radius;
    double circleArea;

    public:
    // constructor
    Circle(double radius){
        // initialization
    }

    double getRadius(){
        // returns radius
    }

     // override
     void calculateArea(){
        circleArea = 3.14 * radius * radius;
    }

    // override
    double getArea(){
        return circleArea;
    }
};

The above code follows OCP because now whenever there is a new requirement to calculate the area of a new shape then we can just extend it from the 'Shapes' class and can override the calculateArea() & getArea() functions.

3- LISKOV SUBSTITUTION PRINCIPLE :

States that any method that takes class 'X' as a parameter must be able to work with any subclasses of 'X' i.e. the objects of a superclass shall be replaceable with objects of its subclasses without breaking the application. It is an extension of OCP.
To understand this concept you should know the inheritance & polymorphism properties of classes.

LSP violation example -

Consider the below code. By definition a Square is a kind of Rectangle with a width equal to height. Therefore we can say that a square is a special kind of rectangle.

#include<iostream>
#include<string>
using namespace std;

class Rectangle {
    double width;
    double height;

    public:
    virtual void setWidth(double width){
        this->width = width;
    }

    virtual void setHeight(double height){
        this->height = height;
    }

    double getWidth(){
        return width;
    }

    double getHeight(){
        return height;
    }

    virtual double getArea(){
        return (width* height);
    }
};

class Square : public Rectangle{
    double width;
    double height;
    public:
    // override
    void setWidth(double width){ 
        this->width = width;
        height = width;
    }

    // override
    void setHeight(double height){
        this->height = height;
        width = height;
    }

    // override
    double getArea(){
        return (width * height);
    }
};

class AreaCalculator{
    public:
    // method that takes superclass and subclass object as parameter
    // run-time polymorphism occurs
    void calculateArea(Rectangle &r){
         cout <<"Area = " << r.getArea() << endl;
    }
};

int main(){
    AreaCalculator ac;

    //calculate area of rectangle
    Rectangle *r = new Rectangle();
    r->setWidth(2);
    r->setHeight(3);
    ac.calculateArea(*r);
    r = nullptr;

    //calculate area of rectangle with position of setWidth & setHeight interchanged
    r = new Rectangle();
    r->setHeight(3);
    r->setWidth(2);
    ac.calculateArea(*r);
    r = nullptr;

    //calculate area of square
    Rectangle *s = new Square();
    s->setWidth(2);
    s->setHeight(3);
    ac.calculateArea(*s);
    s = nullptr;

    //calculate area of square with position of setWidth & setHeight interchanged
    s = new Square();
    s->setHeight(3);
    s->setWidth(2);
    ac.calculateArea(*s);
    s = nullptr;

    return 0;
}

OUTPUT-

In the above example, we have created a Rectangle base class and a Square child class. When the Rectangle class object is passed as a parameter to function calculateArea(), the output remains the same even after interchanging the position of setWidth() & setHeight() but when the Square class object is passed as a parameter to function calculateArea(), the output changes and thus the behaviour changes. This happened because we have changed the behaviour of the setWidth() & setHeight() methods in the Square class.

Solution-

We can create an interface 'Shape' and implement the getArea() function for different shapes classes.

#include<iostream>
#include<string>
using namespace std;

class Shape{
    public:
    virtual double getArea() = 0;
};

class Rectangle : public Shape{
    double width;
    double height;

    public:
    void setWidth(double width){
        this->width = width;
    }

    void setHeight(double height){
        this->height = height;
    }

    double getWidth(){
        return width;
    }

    double getHeight(){
        return height;
    }

    // override
    double getArea(){
        return (width* height);
    }
};

class Square : public Shape{
    double length;
    public:
    void setLength(double length){ 
        this->length = length;
    }

    // override
    double getArea(){
        return (length * length);
    }
};

class AreaCalculator{
    public:
    // method that takes superclass and subclass object as parameter
    // run-time polymorphism occurs
    void calculateArea(Shape &sh){
         cout <<"Area = " << sh.getArea() << endl;
    }
};

int main(){
    AreaCalculator ac;

    //calculate area of rectangle
    Rectangle *r = new Rectangle();
    r->setWidth(2);
    r->setHeight(3);
    ac.calculateArea(*r);
    r = nullptr;

    //calculate area of rectangle with position of setHeight & setWidth interchanged
    r = new Rectangle();
    r->setHeight(3);
    r->setWidth(2);
    ac.calculateArea(*r);
    r = nullptr;

    //calculate area of square
    Square *s = new Square();
    s->setLength(2);
    ac.calculateArea(*s);
    s = nullptr;

    return 0;
}

OUTPUT-

The above code follows LSP because we have implemented the getArea() method separately for different subclasses.

4- INTERFACE SEGREGATION PRINCIPLE :

States that the clients should not be forced to depend upon interfaces that they do not use i.e. the interfaces that have become fat should be split into several interfaces to minimize the responsibilities.

ISP violation example -

Consider the below code where we have an ADWM (AutomatedDeposit cum Withdrawal Machine) interface having the functionality of withdrawing cash, depositing cash and depositing cheques. Two subclasses WithdrawMachine & DepositMachine are inherited from ADWM class. To understand this concept you should know the inheritance & abstraction properties of classes. In C++, the interface is defined through pure virtual functions.
WithdrawMachine class is solely used for withdrawing cash.
DepositMachine class is solely used for depositing cash and cheques.

#include<iostream>
#include<string>
using namespace std;

// interface
class ADWM{
    public:
    virtual void depositCash() = 0;
    virtual void depositCheque() = 0;
    virtual void withdrawCash() = 0;
};

class WithdrawMachine : public ADWM{
    public:
    // override
    void depositCash(){
        // not used in this class
    }

    // override
    void depositCheque(){
        // not used in this class
    }

    // override
    void withdrawCash(){
        // implement code to withdraw cash
    }
};

class DepositMachine : public ADWM{
    public:
    // override
    void depositCash(){
        // implement code to deposit cash
    }

    // override
    void depositCheque(){
        // implement code to deposit cheque
    }

    // override
    void withdrawCash(){
        // not used in this class
    }
};

The above code violates ISP because the class WithdrawMachine has to implement all the functionalities although only one function is used (withdrawCash).
Similarly, the class DepositMachine does not need withdrawCash() function.

Solution-

We can solve this problem by segregating the ADWM interface functionalities.

#include<iostream>
#include<string>
using namespace std;

// interface 1
class ADWM_Withdraw{
    public:
    virtual void withdrawCash() = 0;
};

// interface 2
class ADWM_Deposit{
    public:
    virtual void depositCash() = 0;
    virtual void depositCheque() = 0;
};

class WithdrawMachine : public ADWM_Withdraw{
    public:
    // override
    void withdrawCash(){
        // implement code to withdraw cash
    }
};

class DepositMachine : public ADWM_Deposit{
    public:
    // override
    void depositCash(){
        // implement code to deposit cash
    }

    // override
    void depositCheque(){
        // implement code to deposit cheque
    }
};

The above code follows ISP because now the ADWM interface is segregated into two different interfaces based on their appropriate functionalities.

5- DEPENDENCY INVERSION PRINCIPLE :

States that high-level modules should not depend upon low-level modules. Both should depend upon abstractions. Secondly, abstractions should not depend upon details. Details should depend upon abstractions. This concept is a bit tricky and many other design patterns form their base on this principle. Let us understand through a code example.

DIP violation example -

#include<iostream>
#include<string>
using namespace std;

class Manager {
    public:
    void addDeveloper(Developer obj){
        // code to add developer to repository
    }

    void addTester(Tester obj){
        // code to add tester to repository
    }
};

class Developer {
    string name;
    string type;
    int id;
    public:
    // constructor
    Developer(int id, string name, string type){
        // initialization
    }

    int getId(){
        // returns id
    }

    string getName(){
        // returns name
    }

    string getType(){
        // returns type of employee
    }

};

class Tester {
    string name;
    string type;
    int id;
    public:
    // constructor
    Tester(int id, string name, string type){
        // initialization
    }

    int getId(){
        // returns id
    }

    string getName(){
        // returns name
    }

    string getType(){
        // returns type of employee
    }
};

int main(){
    Developer developer(1, "Aayush", "developer");
    Tester tester(1, "Ashish", "tester");

    Manager manager;
    manager.addDeveloper(developer);
    manager.addTester(tester);

    return 0;
}

In the above example, the Manager class is the higher-level module and the Developer/Tester is the lower-level module. Here, we have exposed everything about the lower layer to the upper layer, thus abstraction is not mentioned. That means the Manager must already know about the type of workers that he can supervise. Now if another type of worker comes under the manager let's say, Designer, then the whole Manager class needs to be rejigged. This is where the dependency inversion principle pitch in.

Solution-

We can solve this problem by creating an interface between the Manager and different employee classes.

#include<iostream>
#include<string>
using namespace std;

class Manager {
    public:
    void addEmployee(Employee &obj){
        // code to add employee to the respective repository
    }
};

// Employee interface
class Employee{
    public:
    virtual int getId() = 0;
    virtual string getName() = 0;
    virtual string getType() = 0;
};

class Developer : public Employee {
    string name;
    string type;
    int id;
    public:
    // constructor
    Developer(int id, string name, string type){
        // initialization
    }

    // override
    int getId(){
        // returns id
    }

    // override
    string getName(){
        // returns name
    }

    // override
    string getType(){
        // returns type of employee
    }

};

class Tester : public Employee {
    string name;
    string type;
    int id;
    public:
    // constructor
    Tester(int id, string name, string type){
        // initialization
    }

    // override
    int getId(){
        // returns id
    }

    // override
    string getName(){
        // returns name
    }

    // override
    string getType(){
        // returns type of employee
    }
};

class Designer : public Employee {
    string name;
    string type;
    int id;
    public:
    // constructor
    Designer(int id, string name, string type){
        // initialization
    }

    // override
    int getId(){
        // returns id
    }

    // override
    string getName(){
        // returns name
    }

     // override
    string getType(){
        // returns type of employee
    }
};

int main(){
    Employee *d = new Developer(1, "Aayush", "developer");
    Employee *t = new Tester(1, "Ashish", "tester");
    Employee *des = new Designer(1, "Aman", "designer");

    Manager manager;
    manager.addEmployee(*d);
    manager.addEmployee(*t);
    manager.addEmployee(*des);

    return 0;
}

The above code follows DIP because now if any other kind of employee is added, it can simply be added to the Manager class without making the manager explicitly aware of it. The design now looks like the figure below-

If you look thoroughly then DIP is closely related to OCP.
**************************************************************************

THIS IS ALL ABOUT SOLID PRINCIPLES. WE HAVE COVERED ALL THE PRINCIPLES THROUGH EASY AND UNDERSTANDABLE CODE EXAMPLES. HOPE IT HELPED YOU TO UNDERSTAND THESE CONCEPTS.