Les principes SOLID de la programmation orientée objet expliqués en anglais simple

Les principes SOLID sont cinq principes de conception de classe orientée objet. Il s'agit d'un ensemble de règles et de bonnes pratiques à suivre lors de la conception d'une structure de classe.

Ces cinq principes nous aident à comprendre la nécessité de certains modèles de conception et d'architecture logicielle en général. Je pense donc que c'est un sujet que chaque développeur devrait apprendre.

Cet article vous apprendra tout ce que vous devez savoir pour appliquer les principes SOLID à vos projets.

Nous commencerons par jeter un regard sur l'histoire de ce terme. Ensuite, nous allons entrer dans les moindres détails - le pourquoi et le comment de chaque principe - en créant une conception de classe et en l'améliorant étape par étape.

Alors prenez une tasse de café ou de thé et sautons dedans!

Contexte

Les principes SOLID ont été introduits pour la première fois par le célèbre informaticien Robert J. Martin (alias Oncle Bob) dans son article en 2000. Mais l'acronyme SOLID a été introduit plus tard par Michael Feathers.

Uncle Bob est également l'auteur des livres à succès Clean Code et Clean Architecture , et est l'un des participants de l '"Agile Alliance".

Par conséquent, il n'est pas surprenant que tous ces concepts de codage propre, d'architecture orientée objet et de modèles de conception soient en quelque sorte connectés et complémentaires les uns des autres.

Ils ont tous le même objectif:

"Créer un code compréhensible, lisible et testable sur lequel de nombreux développeurs peuvent travailler en collaboration."

Examinons chaque principe un par un. Suivant l'acronyme SOLID, ils sont:

  • Le S Ingle principe de responsabilité
  • Le principe O pen-Closed
  • Le principe de substitution de L iskov
  • Le I nterface Principe Ségrégation
  • Le D ependency principe d' inversion

Le principe de responsabilité unique

Le principe de responsabilité unique stipule qu'une classe doit faire une chose et par conséquent, elle ne doit avoir qu'une seule raison de changer .

Pour énoncer ce principe de manière plus technique: Un seul changement potentiel (logique de base de données, logique de journalisation, etc.) dans la spécification du logiciel devrait pouvoir affecter la spécification de la classe.

Cela signifie que si une classe est un conteneur de données, comme une classe Book ou une classe Student, et qu'elle contient des champs concernant cette entité, elle ne doit changer que lorsque nous modifions le modèle de données.

Il est important de suivre le principe de responsabilité unique. Tout d'abord, étant donné que de nombreuses équipes différentes peuvent travailler sur le même projet et éditer la même classe pour des raisons différentes, cela peut conduire à des modules incompatibles.

Deuxièmement, cela facilite le contrôle de version. Par exemple, disons que nous avons une classe de persistance qui gère les opérations de base de données et que nous voyons une modification dans ce fichier dans les commits GitHub. En suivant le SRP, nous saurons qu'il est lié au stockage ou aux bases de données.

Les conflits de fusion sont un autre exemple. Ils apparaissent lorsque différentes équipes modifient le même fichier. Mais si le SRP est suivi, moins de conflits apparaîtront - les fichiers auront une seule raison de changer, et les conflits qui existent seront plus faciles à résoudre.

Pièges et anti-modèles courants

Dans cette section, nous examinerons certaines erreurs courantes qui violent le principe de responsabilité unique. Ensuite, nous parlerons de quelques moyens de les résoudre.

Nous examinerons le code d'un simple programme de facturation de librairie à titre d'exemple. Commençons par définir une classe de livre à utiliser dans notre facture.

class Book { String name; String authorName; int year; int price; String isbn; public Book(String name, String authorName, int year, int price, String isbn) { this.name = name; this.authorName = authorName; this.year = year; this.price = price; this.isbn = isbn; } } 

Ceci est une classe de livre simple avec quelques champs. Rien d'extraordinaire. Je ne rend pas les champs privés afin que nous n'ayons pas besoin de traiter avec les getters et les setters et que nous puissions nous concentrer sur la logique à la place.

Créons maintenant la classe de facture qui contiendra la logique de création de la facture et de calcul du prix total. Pour l'instant, supposons que notre librairie ne vend que des livres et rien d'autre.

public class Invoice { private Book book; private int quantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { double price = ((book.price - book.price * discountRate) * this.quantity); double priceWithTaxes = price * (1 + taxRate); return priceWithTaxes; } public void printInvoice() { System.out.println(quantity + "x " + book.name + " " + book.price + "$"); System.out.println("Discount Rate: " + discountRate); System.out.println("Tax Rate: " + taxRate); System.out.println("Total: " + total); } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Voici notre classe de facture. Il contient également quelques champs sur la facturation et 3 méthodes:

  • CalculateTotal , qui calcule le prix total,
  • printInvoice , qui doit imprimer la facture sur la console, et
  • saveToFile , responsable de l'écriture de la facture dans un fichier.

Vous devriez vous donner une seconde pour réfléchir à ce qui ne va pas avec cette conception de classe avant de lire le paragraphe suivant.

Ok alors qu'est-ce qui se passe ici? Notre classe viole le principe de responsabilité unique de plusieurs manières.

La première violation est la méthode printInvoice , qui contient notre logique d'impression. Le SRP stipule que notre classe ne devrait avoir qu'une seule raison de changer, et que cette raison devrait être un changement dans le calcul de la facture pour notre classe.

Mais dans cette architecture, si nous voulions changer le format d'impression, nous aurions besoin de changer la classe. C'est pourquoi nous ne devrions pas mélanger la logique d'impression et la logique métier dans la même classe.

Il existe une autre méthode qui viole le SRP dans notre classe: la méthode saveToFile . Mélanger la logique de persistance et la logique métier est également une erreur extrêmement courante.

Ne pensez pas seulement en termes d'écriture dans un fichier - cela peut être un enregistrement dans une base de données, un appel d'API ou d'autres choses liées à la persistance.

Alors, comment pouvons-nous résoudre cette fonction d'impression, vous pouvez demander.

Nous pouvons créer de nouvelles classes pour notre logique d'impression et de persistance afin que nous n'ayons plus besoin de modifier la classe de facture à ces fins.

Nous créons 2 classes, InvoicePrinter et InvoicePersistence, et déplaçons les méthodes.

public class InvoicePrinter { private Invoice invoice; public InvoicePrinter(Invoice invoice) { this.invoice = invoice; } public void print() { System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $"); System.out.println("Discount Rate: " + invoice.discountRate); System.out.println("Tax Rate: " + invoice.taxRate); System.out.println("Total: " + invoice.total + " $"); } }
public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Maintenant, notre structure de classe obéit au principe de responsabilité unique et chaque classe est responsable d'un aspect de notre application. Génial!

Principe ouvert-fermé

Le principe ouvert-fermé exige que les classes soient ouvertes à l'extension et fermées à la modification.

La modification signifie changer le code d'une classe existante, et l'extension signifie ajouter de nouvelles fonctionnalités.

Donc, ce que ce principe veut dire, c'est: nous devrions être en mesure d'ajouter de nouvelles fonctionnalités sans toucher au code existant pour la classe. En effet, chaque fois que nous modifions le code existant, nous prenons le risque de créer des bogues potentiels. Nous devrions donc éviter de toucher au code de production testé et fiable (principalement) si possible.

Mais comment allons-nous ajouter de nouvelles fonctionnalités sans toucher à la classe, vous pouvez demander. Cela se fait généralement à l'aide d'interfaces et de classes abstraites.

Maintenant que nous avons couvert les bases du principe, appliquons-le à notre application Facture.

Disons que notre patron est venu nous voir et a dit qu'il voulait que les factures soient enregistrées dans une base de données afin que nous puissions les rechercher facilement. Nous pensons que ça va, c'est facile patron, donne-moi juste une seconde

Nous créons la base de données, nous y connectons et nous ajoutons une méthode de sauvegarde à notre classe InvoicePersistence :

public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } public void saveToDatabase() { // Saves the invoice to database } }

Malheureusement, en tant que développeur paresseux de la librairie, nous n'avons pas conçu les classes pour qu'elles soient facilement extensibles à l'avenir. Donc, pour ajouter cette fonctionnalité, nous avons modifié la classe InvoicePersistence .

Si notre conception de classe obéissait au principe Open-Closed, nous n'aurions pas besoin de changer cette classe.

Ainsi, en tant que développeur paresseux mais intelligent de la librairie, nous voyons le problème de conception et décidons de refactoriser le code pour obéir au principe.

interface InvoicePersistence { public void save(Invoice invoice); }

Nous changeons le type de InvoicePersistence en Interface et ajoutons une méthode de sauvegarde. Chaque classe de persistance implémentera cette méthode de sauvegarde.

public class DatabasePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to DB } }
public class FilePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to file } }

Donc, notre structure de classe ressemble maintenant à ceci:

Maintenant, notre logique de persistance est facilement extensible. Si notre patron nous demande d'ajouter une autre base de données et d'avoir 2 types de bases de données différents comme MySQL et MongoDB, nous pouvons facilement le faire.

Vous pouvez penser que nous pourrions simplement créer plusieurs classes sans interface et ajouter une méthode de sauvegarde à toutes.

Mais disons que nous étendons notre application et que nous avons plusieurs classes de persistance comme InvoicePersistence , BookPersistence et que nous créons une classe PersistenceManager qui gère toutes les classes de persistance:

public class PersistenceManager { InvoicePersistence invoicePersistence; BookPersistence bookPersistence; public PersistenceManager(InvoicePersistence invoicePersistence, BookPersistence bookPersistence) { this.invoicePersistence = invoicePersistence; this.bookPersistence = bookPersistence; } }

Nous pouvons maintenant passer n'importe quelle classe qui implémente l' interface InvoicePersistence à cette classe à l'aide du polymorphisme. C'est la flexibilité qu'offrent les interfaces.

Principe de substitution de Liskov

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes.

This means that, given that class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A and the method should not give any weird output in that case.

This is the expected behavior, because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.

Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.

Liskov's principle is easy to understand but hard to detect in code. So let's look at an example.

class Rectangle { protected int width, height; public Rectangle() { } public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } }

We have a simple Rectangle class, and a getArea function which returns the area of the rectangle.

Now we decide to create another class for Squares. As you might know, a square is just a special type of rectangle where the width is equal to the height.

class Square extends Rectangle { public Square() {} public Square(int size) { width = height = size; } @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } @Override public void setHeight(int height) { super.setHeight(height); super.setWidth(height); } }

Our Square class extends the Rectangle class. We set height and width to the same value in the constructor, but we do not want any client (someone who uses our class in their code) to change height or weight in a way that can violate the square property.

Therefore we override the setters to set both properties whenever one of them is changed. But by doing that we have just violated the Liskov substitution principle.

Let's create a main class to perform tests on the getArea function.

class Test { static void getAreaTest(Rectangle r) { int width = r.getWidth(); r.setHeight(10); System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea()); } public static void main(String[] args) { Rectangle rc = new Rectangle(2, 3); getAreaTest(rc); Rectangle sq = new Square(); sq.setWidth(5); getAreaTest(sq); } }

Your team's tester just came up with the testing function getAreaTest and tells you that your getArea function fails to pass the test for square objects.

In the first test, we create a rectangle where the width is 2 and the height is 3 and call getAreaTest. The output is 20 as expected, but things go wrong when we pass in the square. This is because the call to setHeight function in the test is setting the width as well and results in an unexpected output.

Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

The principle states that many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do no need.

This is a simple principle to understand and apply, so let's see an example.

public interface ParkingLot { void parkCar(); // Decrease empty spot count by 1 void unparkCar(); // Increase empty spots by 1 void getCapacity(); // Returns car capacity double calculateFee(Car car); // Returns the price based on number of hours void doPayment(Car car); } class Car { }

We modeled a very simplified parking lot. It is the type of parking lot where you pay an hourly fee. Now consider that we want to implement a parking lot that is free.

public class FreeParking implements ParkingLot { @Override public void parkCar() { } @Override public void unparkCar() { } @Override public void getCapacity() { } @Override public double calculateFee(Car car) { return 0; } @Override public void doPayment(Car car) { throw new Exception("Parking lot is free"); } }

Our parking lot interface was composed of 2 things: Parking related logic (park car, unpark car, get capacity) and payment related logic.

But it is too specific. Because of that, our FreeParking class was forced to implement payment-related methods that are irrelevant. Let's separate or segregate the interfaces.

Nous avons maintenant séparé le parking. Avec ce nouveau modèle, nous pouvons même aller plus loin et fractionner le PaidParkingLot pour prendre en charge différents types de paiement.

Maintenant, notre modèle est beaucoup plus flexible, extensible et les clients n'ont pas besoin de mettre en œuvre une logique non pertinente car nous fournissons uniquement des fonctionnalités liées au stationnement dans l'interface du parking.

Principe d'inversion de dépendance

Le principe d'inversion de dépendance stipule que nos classes doivent dépendre d'interfaces ou de classes abstraites au lieu de classes et de fonctions concrètes.

Dans son article (2000), l'oncle Bob résume ce principe comme suit:

"Si l'OCP énonce le but de l'architecture OO, le DIP indique le mécanisme principal".

These two principles are indeed related and we have applied this pattern before while we were discussing the Open-Closed Principle.

We want our classes to be open to extension, so we have reorganized our dependencies to depend on interfaces instead of concrete classes. Our PersistenceManager class depends on InvoicePersistence instead of the classes that implement that interface.

Conclusion

In this article, we started with the history of SOLID principles, and then we tried to acquire a clear understanding of the why's and how's of each principle. We even refactored a simple Invoice application to obey SOLID principles.

I want to thank you for taking the time to read the whole article and I hope that the above concepts are clear.

I suggest keeping these principles in mind while designing, writing, and refactoring your code so that your code will be much more clean, extendable, and testable.

If you are interested in reading more articles like this, you can subscribe to my blog's mailing list to get notified when I publish a new article.