SOLID principles every developer should be aware of

https://blog.bitsrc.io/solid-principles-every-developer-should-know-b3bfa96bb688
  • Transfer
Object-oriented programming has brought new approaches to application design to software development. In particular, OOP allowed programmers to combine entities, united by some common goal or functionality, in separate classes designed for solving independent tasks and independent of other parts of the application. However, the use of OOP does not mean that the developer is insured against the possibility of creating an incomprehensible, confusing code that is hard to maintain. Robert Martin, in order to help everyone to develop high-quality OOP applications, developed five principles of object-oriented programming and design, referring to which, with the suggestion of Michael Phasers, use the acronym SOLID.



The material, the translation of which we are publishing today, is devoted to the basics of SOLID and is intended for novice developers.

What is SOLID?


This is how the SOLID acronym stands for:

  • S: Single Responsibility Principle.
  • O: Open-Closed Principle (Open-Closed Principle).
  • L: Liskov Substitution Principle (Barbara Liskov substitution principle).
  • I: Interface Segregation Principle.
  • D: Dependency Inversion Principle.

We will now look at these principles with schematic examples. Note that the main purpose of the examples is to help the reader understand the principles of SOLID, learn how to apply them and how to follow them when designing applications. The author of the material did not strive to get to working code that could be used in real projects.

Principle of sole responsibility


“One errand. Only one thing. ”- Loki tells Surcu in the film“ Thor: Ragnarok. ”
Each class must solve only one problem.


The class should be responsible only for one thing. If a class is responsible for solving several problems, its subsystems that implement the solution of these problems are connected with each other. Changes in one such subsystem lead to changes in another.

Note that this principle applies not only to classes, but also to software components in a broader sense.

For example, consider this code:

classAnimal {
    constructor(name: string){ }
    getAnimalName() { }
    saveAnimal(a: Animal) { }
}

The class Animalpresented here describes some kind of animal. This class violates the principle of sole responsibility. How exactly is this principle violated?

In accordance with the principle of sole responsibility, a class must solve only one particular task. He also solves two, working with the data warehouse in the method saveAnimaland manipulating the properties of the object in the constructor and in the method getAnimalName.

How can such a class structure lead to problems?

If the order of work with the data storage used by the application changes, then it is necessary to make changes to all classes working with the storage. Such an architecture is not very flexible; changes in some subsystems affect others, which resembles a domino effect.

In order to bring the above code into line with the principle of sole responsibility, we will create another class whose only task is to work with the repository, in particular, to preserve class objects in it Animal:

classAnimal {
    constructor(name: string){ }
    getAnimalName() { }
}
classAnimalDB {
    getAnimal(a: Animal) { }
    saveAnimal(a: Animal) { }
}

This is what Steve Fenton says about this: “When designing classes, we must strive to integrate related components, that is, those in which changes occur for the same reasons. We should try to separate the components, the changes in which are caused by different reasons. "

The correct application of the principle of sole responsibility leads to a high degree of coherence of elements within the module, that is, to the fact that the tasks solved inside it correspond well to its main goal.

Principle of openness-closeness


Software entities (classes, modules, functions) should be open for expansion, but not for modification.

We continue the work on the classAnimal.

classAnimal{
    constructor(name: string){ }
    getAnimalName() { }
}

We want to sort through the list of animals, each of which is represented by an object of a class Animal, and find out what sounds they make. Imagine that we solve this problem using the function AnimalSounds:

//...const animals: Array<Animal> = [
    new Animal('lion'),
    new Animal('mouse')
];
functionAnimalSound(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(a[i].name == 'lion')
            return'roar';
        if(a[i].name == 'mouse')
            return'squeak';
    }
}
AnimalSound(animals);

The main problem of such an architecture is that the function determines what kind of sound a particular animal emits by analyzing specific objects. The function AnimalSounddoes not correspond to the principle of openness-closeness, since, for example, when new species of animals appear, we will have to change it in order to recognize the sounds made by them.

Add a new element to the array:

//...const animals: Array<Animal> = [
    new Animal('lion'),
    new Animal('mouse'),
    new Animal('snake')
]
//...

After that we will have to change the function code AnimalSound:

//...functionAnimalSound(a: Array<Animal>){
    for(int i = 0; i <= a.length; i++) {
        if(a[i].name == 'lion')
            return'roar';
        if(a[i].name == 'mouse')
            return'squeak';
        if(a[i].name == 'snake')
            return'hiss';
    }
}
AnimalSound(animals);

As you can see, when adding a new animal to the array, you will have to supplement the function code. The example is very simple, but if such an architecture is used in a real project, the function will have to be constantly expanded, adding new expressions to it if.

How to align the function AnimalSoundwith the principle of openness-closeness? For example - so:

classAnimal{
        makeSound();
        //...
}
classLionextendsAnimal{
    makeSound() {
        return'roar';
    }
}
classSquirrelextendsAnimal{
    makeSound() {
        return'squeak';
    }
}
classSnakeextendsAnimal{
    makeSound() {
        return'hiss';
    }
}
//...
function AnimalSound(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        a[i].makeSound();
    }
}
AnimalSound(animals);

You may notice that the class Animalnow has a virtual method makeSound. With this approach, it is necessary that the classes intended to describe specific animals expand the class Animaland implement this method.

As a result, each class describing an animal will have its own method makeSound, and when sorting an array with animals in a function AnimalSound, it will be sufficient to call this method for each element of the array.

If we now add to the array an object describing a new animal, the function AnimalSoundwill not have to be changed. We have aligned it with the principle of openness-closeness.

Consider another example.

Imagine that we have a store. We give customers a 20% discount using this class:

classDiscount{
    giveDiscount() {
        returnthis.price * 0.2
    }
}

Now it is decided to divide clients into two groups. Loved ( fav) customers are given a 20% discount, and VIP customers ( vip) - double the discount, that is - 40%. In order to implement this logic, it was decided to modify the class as follows:

classDiscount{
    giveDiscount() {
        if(this.customer == 'fav') {
            returnthis.price * 0.2;
        }
        if(this.customer == 'vip') {
            returnthis.price * 0.4;
        }
    }
}

Such an approach violates the principle of openness-closeness. As you can see, here, if we need to give a certain group of clients a special discount, we have to add a new code to the class.

In order to rework this code in accordance with the principle of openness-closeness, we will add a new class to the project, which will extend the class Discount. In this new class, we are implementing a new mechanism:

classVIPDiscount: Discount {
    getDiscount() {
        returnsuper.getDiscount() * 2;
    }
}

If you decide to give a discount of 80% "super-VIP" customers, it should look like this:

classSuperVIPDiscount: VIPDiscount {
    getDiscount() {
        returnsuper.getDiscount() * 2;
    }
}

As you can see, the use of classes is used here, not their modification.

Barbara Liskov substitution principle


Subclasses need to serve as a replacement for their superclasses.

The purpose of this principle is that the successor classes could be used instead of the parent classes from which they are derived, without disrupting the work of the program. If it turns out that the class type is checked in the code, then the substitution principle is violated.

Consider the application of this principle, returning to the example of the classAnimal. Write a function designed to return information about the quantities of limbs of an animal.

//...functionAnimalLegCount(a: Array<Animal>){
    for(int i = 0; i <= a.length; i++) {
        if(typeof a[i] == Lion)
            return LionLegCount(a[i]);
        if(typeof a[i] == Mouse)
            return MouseLegCount(a[i]);
        if(typeof a[i] == Snake)
            return SnakeLegCount(a[i]);
    }
}
AnimalLegCount(animals);

The function violates the principle of substitution (and the principle of openness-closeness). This code should be aware of the types of all objects it processes and, depending on the type, refer to the corresponding function for counting the limbs of a particular animal. As a result, when creating a new type of animal, the function will have to be rewritten:

//...classPigeonextendsAnimal{
        
}
const animals[]: Array<Animal> = [
    //...,
    newPigeon();
]
function AnimalLegCount(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(typeof a[i] == Lion)
            returnLionLegCount(a[i]);
        if(typeof a[i] == Mouse)
            returnMouseLegCount(a[i]);
         if(typeof a[i] == Snake)
            returnSnakeLegCount(a[i]);
        if(typeof a[i] == Pigeon)
            returnPigeonLegCount(a[i]);
    }
}
AnimalLegCount(animals);

To ensure that this function does not violate the substitution principle, we will transform it using the requirements formulated by Steve Fenton. They consist in the fact that methods that accept or return values ​​with the type of a certain superclass ( Animalin our case) must also accept and return values ​​whose types are its subclasses ( Pigeon).

Armed with these considerations, we can redo the function AnimalLegCount:

functionAnimalLegCount(a: Array<Animal>) {
    for(let i = 0; i <= a.length; i++) {
        a[i].LegCount();
    }
}
AnimalLegCount(animals);

Now this function is not interested in the types of objects passed to it. She simply calls their methods LegCount. All she knows about types is that the objects she processes should belong to the class Animalor its subclasses.

Now the Animalmethod should appear in the class LegCount:

classAnimal{
    //...
    LegCount();
}

And its subclasses need to implement this method:

//...classLionextendsAnimal{
    //...
    LegCount() {
        //...
    }
}
//...

As a result, for example, when accessing a method LegCountfor an instance of a class Lion, the method implemented in this class is invoked, and exactly what can be expected from calling such a method is returned.

Now, the functions AnimalLegCountdo not need to know about the object of which particular subclass of class Animalit processes in order to find out information about the number of limbs in an animal represented by this object. The function simply calls the LegCountclass method Animal, since subclasses of this class must implement this method so that they can be used instead, without disrupting the correctness of the program.

Interface separation principle


Create highly specialized customer-specific interfaces. Clients should not be dependent on interfaces that they do not use.

This principle aims to eliminate the disadvantages associated with the implementation of large interfaces.

Consider the interfaceShape:

interfaceShape {
    drawCircle();
    drawSquare();
    drawRectangle();
}

It describes methods for drawing circles ( drawCircle), squares ( drawSquare) and rectangles ( drawRectangle). As a result, classes that implement this interface and represent separate geometric shapes, such as a circle (Circle), a square (Square), and a rectangle (Rectangle), must contain an implementation of all these methods. It looks like this:

classCircleimplementsShape{
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}
classSquareimplementsShape{
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}
classRectangleimplementsShape{
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}

We got a strange code. For example, a class Rectanglerepresenting a rectangle implements methods ( drawCircleand drawSquare) that it does not need at all. The same can be noticed when analyzing the code of the two other classes.

Suppose we decide to add Shapeanother method to the interface drawTriangle, designed to draw triangles:

interfaceShape {
    drawCircle();
    drawSquare();
    drawRectangle();
    drawTriangle();
}

This will lead to the fact that classes representing concrete geometric shapes will have to implement the method as well drawTriangle. Otherwise, an error will occur.

As you can see, with this approach it is impossible to create a class that implements a method for drawing a circle, but does not implement methods for displaying a square, a rectangle and a triangle. Such methods can be implemented so that when they are output, an error would be thrown, indicating that such an operation cannot be performed.

The interface separation principle warns us against creating interfaces similar to Shapeour example. Clients (we have classes Circle, SquareandRectangle) should not implement methods that they do not need to use. In addition, this principle indicates that the interface should solve only one task (in this it is similar to the principle of sole responsibility), therefore everything that goes beyond the scope of this task should be transferred to another interface or interfaces.

In our case, the interface Shapesolves the tasks for which it is necessary to create separate interfaces. Following this idea, we will rework the code, creating separate interfaces for solving various highly specialized tasks:

interfaceShape{
    draw();
}
interfaceICircle{
    drawCircle();
}
interfaceISquare{
    drawSquare();
}
interfaceIRectangle{
    drawRectangle();
}
interfaceITriangle{
    drawTriangle();
}
classCircleimplementsICircle{
    drawCircle() {
        //...
    }
}
classSquareimplementsISquare{
    drawSquare() {
        //...
    }
}
classRectangleimplementsIRectangle{
    drawRectangle() {
        //...
    }    
}
classTriangleimplementsITriangle{
    drawTriangle() {
        //...
    }
}
classCustomShapeimplementsShape{
   draw(){
      //...
   }
}

Now the interface is ICircleused only for drawing circles, as well as other specialized interfaces for drawing other shapes. The interface Shapecan be used as a universal interface.

Dependency Inversion Principle


The object of the dependency should be an abstraction, not something concrete.

  1. The modules of the upper levels should not depend on the modules of the lower levels. Both types of modules must depend on abstractions.
  2. Abstractions should not depend on the details. Details must depend on abstractions.

In the process of software development, there is a moment when the functionality of the application ceases to fit within a single module. When this happens, we have to solve the problem of module dependencies. As a result, for example, it may turn out that high-level components depend on low-level components.

classXMLHttpServiceextendsXMLHttpRequestService{}
classHttp{
    constructor(private xmlhttpService: XMLHttpService) { }
    get(url: string , options: any) {
        this.xmlhttpService.request(url,'GET');
    }
    post() {
        this.xmlhttpService.request(url,'POST');
    }
    //...
}

Here the class Httpis a high-level component, and XMLHttpService- low-level. This architecture violates clause A of the principle of dependency inversion: “The modules of the upper levels should not depend on the modules of the lower levels. Both types of modules should depend on abstractions. ”

The class is Httpdependent on the class XMLHttpService. If we decide to change the mechanism used by the class Httpto interact with the network - say, it will be a Node.js service or, for example, a service stub used for testing purposes, we will have to edit all instances of the class Httpby changing the corresponding code. This violates the principle of openness-closeness.

ClassHttpdoes not need to know exactly what is used for networking. Therefore, we will create an interface Connection:

interfaceConnection {
    request(url: string, opts:any);
}

The interface Connectioncontains a description of the method requestand we pass a Httptype argument to the class Connection:

classHttp{
    constructor(private httpConnection: Connection) { }
    get(url: string , options: any) {
        this.httpConnection.request(url,'GET');
    }
    post() {
        this.httpConnection.request(url,'POST');
    }
    //...
}

Now, regardless of what exactly is used to organize interaction with the network, a class Httpcan use what it has been passed on without worrying about what lies behind the interface Connection.

Rewrite the class XMLHttpServiceso that it implements this interface:

classXMLHttpServiceimplementsConnection{
    const xhr = new XMLHttpRequest();
    //...
    request(url: string, opts:any) {
        xhr.open();
        xhr.send();
    }
}

As a result, we can create many classes that implement the interface Connectionand are suitable for use in the class Httpfor organizing data exchange over the network:

classNodeHttpServiceimplementsConnection{
    request(url: string, opts:any) {
        //...
    }
}
classMockHttpServiceimplementsConnection{
    request(url: string, opts:any) {
        //...
    }    
}

As you can see, here high-level and low-level modules depend on abstractions. The class Http(high-level module) depends on the interface Connection(abstraction). Classes XMLHttpService, NodeHttpServiceand MockHttpService(low-level modules) also depend on the interface Connection.

In addition, it is worth noting that, following the principle of inversion of dependencies, we follow the principle of Barbara Liskov's substitution. Namely, it turns out that types XMLHttpService, NodeHttpServiceand MockHttpServicecan serve as a substitute for the base type Connection.

Results


Here we looked at five SOLID principles that every OOP developer should adhere to. At first, this may not be easy, but if you strive towards this, reinforcing desires with practice, these principles become a natural part of the workflow, which has a huge positive impact on the quality of applications and greatly facilitates their support.

Dear readers! Do you use SOLID principles in your projects?


Also popular now: