Les principes de conception SOLID sont les principes de conception les plus importants que vous devez connaître pour écrire du code propre. Avoir une solide maîtrise des principes SOLID est une compétence indispensable pour tout programmeur. Ils constituent la base sur laquelle d’autres modèles de conception sont développés. Dans cet article, nous aborderons les principes de conception SOLID à l'aide de quelques exemples réels et comprendrons leur importance. Avec le polymorphisme, l'abstraction et l'héritage, les principes SOLID sont vraiment importants pour être bon en programmation orientée objectifs. Pourquoi les principes SOLID sont-ils importants ? Les principes SOLID sont importants pour plusieurs raisons : Les principes SOLID nous permettent d'écrire du code propre et maintenable : lorsque nous démarrons un nouveau projet, la qualité du code est initialement bonne car nous disposons d'un ensemble limité de fonctionnalités que nous implémentons. Cependant, à mesure que nous intégrons davantage de fonctionnalités, le code commence à devenir encombré. Les principes SOLID s'appuient sur les fondements de l'abstraction, du polymorphisme et de l'héritage et conduisent à des modèles de conception pour des cas d'utilisation courants. Comprendre ces modèles de conception aide à mettre en œuvre des cas d'utilisation courants en programmation. Les principes SOLID nous aident à écrire du code propre qui améliore la testabilité du code. En effet, le code est modulaire et faiblement couplé. Chaque module peut être développé indépendamment et testé indépendamment. Explorons maintenant chacun des principes SOLID en détail avec des exemples concrets. 1. Principe de responsabilité unique S dans les principes SOLID signifie principe de responsabilité unique. Le principe de responsabilité unique stipule qu’une classe ne devrait avoir qu’une seule raison de changer. Cela limite le nombre d'endroits où nous devons apporter des modifications lors de l'intégration d'exigences supplémentaires dans notre projet. Chaque classe devrait avoir exactement une raison de changer. Par exemple, disons que nous concevons une application bancaire en Java dans laquelle nous avons une classe qui permet des opérations de base telles que le débit, le crédit et . La méthode prend une énumération appelée (comme Email, SMS, etc.) et envoie la mise à jour avec le support approprié. Nous allons écrire le code pour cela comme indiqué ci-dessous. SavingsAccount sendUpdates sendUpdate NotificationMedium public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } } public enum NotificationMedium { SMS, EMAIL } Maintenant, si vous regardez la classe ci-dessus, elle peut changer pour plusieurs raisons : SavingsAccount S'il y a un changement dans la logique de base de la classe (comme , , etc.). SavingsAccount debit credit Si la banque décide d'introduire un nouveau support de notification (disons WhatsApp). Il s’agit d’une violation du principe de responsabilité unique des principes SOLID. Pour résoudre ce problème, nous créerons une classe distincte qui envoie la notification. Refactorisons le code ci-dessus selon les principes SOLID public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } } public enum NotificationMedium { SMS, EMAIL } public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } } Maintenant que nous avons refactorisé le code, s'il y a un changement dans le ou le format, nous modifierons la classe . Cependant, s'il y a un changement dans la logique de base de , des changements seront apportés à la classe . NotificationMedium Sender SavingsAccount SavingsAccount Cela corrige la violation que nous avons observée dans le premier exemple. 2. Principe d'ouverture/fermeture Le principe Open Close stipule que nous devons concevoir les classes de telle manière qu'elles soient ouvertes à l'extension (en cas d'ajout de fonctionnalités supplémentaires) mais fermées à la modification. Être fermé pour modification nous aide de deux manières : Il arrive souvent que la source originale de la classe ne soit même pas disponible. Il pourrait s'agir d'une dépendance consommée par votre projet. Garder la classe d'origine inchangée réduit les risques de bugs. Puisqu’il peut y avoir d’autres classes dépendantes de la classe que nous souhaitons modifier. Pour voir un exemple du principe Open Close, prenons l'exemple d'un panier d'achat (comme celui mis en œuvre sur les sites de commerce électronique). Je vais créer une classe appelée qui contiendra une liste d' que vous pourrez y ajouter. En fonction du type d'article et de sa fiscalité, nous souhaitons créer une méthode qui calcule la valeur totale du panier dans la classe . Cart Item Cart Les classes doivent être ouvertes pour extension et fermées pour modification import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } } @Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } } public enum ItemType { ELECTRONIC, GIFT } Dans l'exemple ci-dessus, la méthode calcule la valeur du panier en itérant sur tous les articles à l'intérieur du panier et en appelant une logique basée sur le type d'article. calculateCartValue Bien que ce code semble correct, il viole les principes SOLID. Disons que nous devons ajouter une nouvelle règle pour un type d'article différent (par exemple l'épicerie) lors du calcul de la valeur du panier. Dans ce cas, nous devrons modifier la classe d'origine et écrire une autre à l'intérieur qui vérifie les articles de type . Cart else if Grocery Cependant, avec peu de refactorisation, nous pouvons faire en sorte que le code adhère au principe d'ouverture/fermeture. Voyons comment. Tout d’abord, nous allons rendre la classe abstraite et créer des classes concrètes pour différents types d’ , comme indiqué ci-dessous. Item Item public abstract class Item { protected double price; public double getValue() { return price; } } public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } } public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } } public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } } À l'intérieur de chaque classe Item concrète comme , et implémentent la méthode qui contient la logique métier pour la taxation et le calcul de la valeur. GroceryItem GiftItem ElectronicItem getValue() Maintenant, nous allons faire dépendre la classe de la classe abstraite et invoquer la méthode pour chaque élément, comme indiqué ci-dessous. Cart Item getValue() import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } } Désormais, dans ce code refactorisé, même si de nouveaux types d' (s) sont introduits, la classe reste inchangée. En raison du polymorphisme, quel que soit le type réel de l' à l'intérieur des , la méthode de cette classe serait invoquée. Item Cart Item items ArrayList getValue() 3. Le principe de substitution de Liskov Le principe de substitution de Liskov stipule que dans un code donné, même si l'on remplace l'objet d'une superclasse par un objet de la classe enfant, le code ne doit pas se casser. En d’autres termes, lorsqu’une sous-classe hérite d’une superclasse et remplace ses méthodes, elle doit maintenir la cohérence avec le comportement de la méthode dans la superclasse. Par exemple, si nous créons les classes suivantes et deux classes et . Maintenant, disons que nous créons une méthode appelée dans la classe Vehicle, elle peut être remplacée dans la classe , mais elle ne sera pas prise en charge dans la classe car n'a pas de moteur (voir l'exemple de code ci-dessous) Vehicle Car Bicycle startEngine() Car Bicycle Bicycle La sous-classe doit maintenir la cohérence avec le comportement de la superclasse lors du remplacement des méthodes. class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } } Maintenant, disons qu'il y a du code qui attend un objet de type véhicule et s'appuie sur la méthode . Si, en appelant ce morceau de code au lieu de passer un objet de type , nous passons un objet , cela entraînerait des problèmes dans le code. Puisque la méthode de la classe (s) lèvera une exception lorsque la méthode est appelée. Ce serait une violation des principes SOLID (principe de substitution de Liskov) startEngine() Vehicle Bicycle Bicycle startEngine() Pour résoudre ce problème, nous pouvons créer deux classes et et faire en sorte que hérite de la classe et que hérite de MotorizedVehicle NonMotorizedVehicle Car MotorizedVehicle Bicycle NonMotorizedVehicle class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } } 4. Principe de ségrégation des interfaces Le « I » dans SOLID Principes signifie le principe de ségrégation d'interface. Le principe de ségrégation des interfaces stipule qu'au lieu d'avoir des interfaces plus grandes qui obligent les classes d'implémentation à implémenter des méthodes inutilisées, nous devrions avoir des interfaces plus petites et implémenter des classes. De cette façon, les classes implémentent uniquement les méthodes pertinentes et restent propres. Divisez vos interfaces en plusieurs interfaces plus petites plutôt qu'en une seule grande interface. Par exemple, regardons le framework Collections intégré en Java. Entre autres structures de données, Java fournit également la structure de données et , LinkedList ArrayList La classe implémente les interfaces suivantes : , , , , et . ArrayList Serializable Cloneable Iterable Collection List RandomAccess La classe implémente , , , , , et . LinkedList Serializable Cloneable Iterable Collection Deque List Queue Cela fait beaucoup d'interfaces ! Plutôt que d'avoir autant d'interfaces, les développeurs Java auraient pu combiner , , , , et en une seule interface, disons l'interface . Désormais, les classes et auraient pu implémenter cette nouvelle interface . Serializable Cloneable Iterable Collecton List RandomAccess IList ArrayList LinkedList IList Cependant, comme ne prend pas en charge l'accès aléatoire, il aurait pu implémenter les méthodes dans l'interface et aurait pu lancer lorsque quelqu'un essaie de l'appeler. LinkedList RandomAccess UnsupportedOperationException Cependant, cela constituerait une violation du principe de ségrégation d'interface dans les principes SOLID car cela « forcerait » la classe LinkedList à implémenter les méthodes à l'intérieur de l'interface même si cela n'est pas nécessaire. RandomAccess Par conséquent, il est préférable de diviser l'interface en fonction du comportement commun et de laisser chaque classe implémenter plusieurs interfaces plutôt qu'une grande interface. Principe d'inversion de dépendance Le principe d'inversion des dépendances stipule que les classes du niveau supérieur ne doivent pas dépendre directement des classes du niveau inférieur. Cela provoque un couplage étroit entre les deux niveaux. Au lieu de cela, les classes inférieures devraient fournir une interface dont dépendraient les classes de niveau supérieur. Dépendre des interfaces plutôt que des classes Par exemple, continuons avec l'exemple que nous avons vu ci-dessus et améliorons-le pour ajouter des options de paiement. Supposons que nous ayons deux types d'options de paiement avec nous, et . Maintenant, dans la classe , nous souhaitons ajouter une méthode à qui calculerait la valeur du panier et lancerait le paiement en fonction du paiement fourni. méthode. Cart DebitCard Paypal Cart placeOrder Pour ce faire, nous aurions pu ajouter une dépendance dans l'exemple ci-dessus en ajoutant les deux options de paiement en tant que champs à l'intérieur de la classe . Cependant, cela couplerait étroitement la classe avec les classes et . Cart Cart Cart DebitCard Paypal Au lieu de cela, nous créerions une interface et demanderions aux classes et d'implémenter les interfaces . Désormais, la classe dépendra de l’interface , et non des types de paiement individuels. Cela maintient les classes faiblement couplées. Payment DebitCard Paypal Payment Cart Payment Voir le code ci-dessous. public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } } Si vous souhaitez en savoir plus sur la programmation orientée objet, les modèles de conception GoF et les entretiens de conception de bas niveau, consultez mon cours très apprécié Programmation orientée objet + modèles de conception Java.