📜 ⬆️ ⬇️

SOLID principles every developer should be aware of

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 anyone 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:
')

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 the 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.

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

For example, consider this code:

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

The Animal class presented 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. It also solves two, working with the data store in the saveAnimal method and manipulating the properties of the object in the constructor and in the getAnimalName method.

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 save the objects of the Animal class in it:

 class Animal {   constructor(name: string){ }   getAnimalName() { } } class AnimalDB {   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 class Animal .

 class Animal {   constructor(name: string){ }   getAnimalName() { } } 

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

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse') ]; function AnimalSound(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 AnimalSound function AnimalSound not comply with the principle of openness-closeness, since, for example, when new types 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 code of the AnimalSound function:

 //... function AnimalSound(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 if expressions to it.

How to bring the AnimalSound function in accordance with the principle of openness-closeness? For example - so:

 class Animal {       makeSound();       //... } class Lion extends Animal {   makeSound() {       return 'roar';   } } class Squirrel extends Animal {   makeSound() {       return 'squeak';   } } class Snake extends Animal {   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 Animal class now has a virtual method, makeSound . With this approach, it is necessary that classes designed to describe specific animals expand the Animal class and implement this method.

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

If we now add to the array an object that describes a new animal, AnimalSound will not have to change. 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:

 class Discount {   giveDiscount() {       return this.price * 0.2   } } 

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

 class Discount {   giveDiscount() {       if(this.customer == 'fav') {           return this.price * 0.2;       }       if(this.customer == 'vip') {           return this.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, extending the Discount class. In this new class, we are implementing a new mechanism:

 class VIPDiscount: Discount {   getDiscount() {       return super.getDiscount() * 2;   } } 

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

 class SuperVIPDiscount: VIPDiscount {   getDiscount() {       return super.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 with the class Animal . Write a function designed to return information about the quantities of limbs of an animal.

 //... function AnimalLegCount(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:

 //... class Pigeon extends Animal {      } const animals[]: Array<Animal> = [   //...,   new Pigeon(); ] function AnimalLegCount(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]);       if(typeof a[i] == Pigeon)           return PigeonLegCount(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 ( Animal in our case) must also accept and return values ​​whose types are its subclasses ( Pigeon ).

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

 function AnimalLegCount(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 just calls their LegCount methods. All she knows about types is that the objects she processes should belong to the Animal class or its subclasses.

Now the LegCount method should appear in the Animal class:

 class Animal {   //...   LegCount(); } 

And its subclasses need to implement this method:

 //... class Lion extends Animal{   //...   LegCount() {       //...   } } //... 

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

Now, the AnimalLegCount function AnimalLegCount not need to know about the object of which subclass of the Animal class it processes in order to get information about the number of limbs in an animal represented by this object. The function simply calls the LegCount method of the Animal class, 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 interface Shape :

 interface Shape {   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:

 class Circle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Square implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Rectangle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } 

We got a strange code. For example, the Rectangle class, representing a rectangle, implements methods ( drawCircle and drawSquare ), which 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 another method to the Shape interface, drawTriangle , for drawing triangles:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle();   drawTriangle(); } 

This will lead to the fact that classes representing concrete geometric shapes will also have to implement the drawTriangle method. 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 like the Shape from our example. Clients (we have the classes Circle , Square and Rectangle ) 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 Shape interface solves the problems for the solution of 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:

 interface Shape {   draw(); } interface ICircle {   drawCircle(); } interface ISquare {   drawSquare(); } interface IRectangle {   drawRectangle(); } interface ITriangle {   drawTriangle(); } class Circle implements ICircle {   drawCircle() {       //...   } } class Square implements ISquare {   drawSquare() {       //...   } } class Rectangle implements IRectangle {   drawRectangle() {       //...   } } class Triangle implements ITriangle {   drawTriangle() {       //...   } } class CustomShape implements Shape {  draw(){     //...  } } 

Now the ICircle interface ICircle used only for drawing circles, as well as other specialized interfaces for drawing other shapes. The Shape interface can 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.

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

Here, the Http class is a high-level component, and XMLHttpService is a low-level component. 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 Http class is dependent on the XMLHttpService class. If we decide to change the mechanism used by the Http class to 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 Http class by changing the corresponding code. This violates the principle of openness-closeness.

The Http class does not need to know exactly what is used for networking. Therefore, we will create the Connection interface:

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

The Connection interface contains the description of the request method and we pass an argument of type Connection Http class:

 class Http {   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, the Http class can use what it has been passed on without worrying about what is hidden behind the Connection interface.

XMLHttpService class XMLHttpService such a way that it implements this interface:

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

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

 class NodeHttpService implements Connection {   request(url: string, opts:any) {       //...   } } class MockHttpService implements Connection {   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). The XMLHttpService , NodeHttpService and MockHttpService (low-level modules) also depend on the Connection interface.

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 the XMLHttpService , NodeHttpService and MockHttpService can serve as a replacement for the base Connection type.

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?

Source: https://habr.com/ru/post/426413/


All Articles