Principes SOLID
Les principes SOLID sont un ensemble de cinq principes de conception en programmation orientée objet, destinés à rendre les conceptions logicielles plus compréhensibles, flexibles et maintenables. Ils ont été promus par Robert C. Martin (Uncle Bob) et sont largement considérés comme des meilleures pratiques pour écrire du code propre, robuste et évolutif.
Chaque lettre de SOLID représente un principe :
- S - Principe de Responsabilité Unique (Single Responsibility Principle)
- O - Principe Ouvert/Fermé (Open/Closed Principle)
- L - Principe de Substitution de Liskov (Liskov Substitution Principle)
- I - Principe de Ségrégation des Interfaces (Interface Segregation Principle)
- D - Principe d'Inversion de Dépendances (Dependency Inversion Principle)
1. Principe de Responsabilité Unique (SRP)
Définition : Une classe ne devrait avoir qu'une seule raison de changer. Cela signifie qu'une classe devrait avoir une, et une seule, responsabilité principale.
Explication : Ce principe vise à empêcher une classe de devenir un "objet Dieu" qui gère trop de préoccupations non liées. Lorsqu'une classe a plusieurs responsabilités, les modifications apportées à l'une d'elles peuvent par inadvertance affecter les autres, entraînant des bugs et rendant la classe plus difficile à comprendre et à maintenir. Séparer les préoccupations signifie que chaque classe se concentre sur une tâche spécifique.
Avantages :
- Couplage réduit : Les classes sont moins dépendantes les unes des autres.
- Maintenabilité améliorée : Les changements liés à une responsabilité sont isolés dans une seule classe.
- Lisibilité accrue : L'objectif de chaque classe est clair.
- Tests plus faciles : Les classes plus petites et ciblées sont plus faciles à tester.
Exemple (Non-respect vs. Respect) :
Non-respect (Une seule classe gère plusieurs responsabilités) :
// Anti-patron : UserProfile gère les données utilisateur ET les opérations de base de données
class UserProfile {
private String username;
private String email;
public UserProfile(String username, String email) {
this.username = username;
this.email = email;
}
// Responsabilité 1 : Gestion des données utilisateur
public String getUsername() { return username; }
public String getEmail() { return email; }
public void setUsername(String username) { this.username = username; }
public void setEmail(String email) { this.email = email; }
// Responsabilité 2 : Persistance en base de données
public void saveToDatabase() {
System.out.println("Saving " + username + " to database...");
// Logique pour sauvegarder l'utilisateur dans la BD
}
public void loadFromDatabase(String username) {
System.out.println("Loading " + username + " from database...");
// Logique pour charger l'utilisateur depuis la BD et remplir les champs
}
}
Respect (Responsabilités séparées) :
// Responsabilité 1 : Gestion des données utilisateur
class User {
private String username;
private String email;
public User(String username, String email) {
this.username = username;
this.email = email;
}
public String getUsername() { return username; }
public String getgetEmail() { return email; }
public void setUsername(String username) { this.username = username; }
public void setEmail(String email) { this.email = email; }
}
// Responsabilité 2 : Persistance utilisateur
class UserRepository {
public void save(User user) {
System.out.println("Saving " + user.getUsername() + " to database...");
// Logique pour sauvegarder l'utilisateur dans la BD
}
public User load(String username) {
System.out.println("Loading " + username + " from database...");
// Logique pour charger l'utilisateur depuis la BD et retourner l'objet User
return new User(username, "loaded@example.com"); // Retour fictif
}
}
2. Principe Ouvert/Fermé (OCP)
Définition : Les entités logicielles (classes, modules, fonctions, etc.) devraient être ouvertes à l'extension, mais fermées à la modification.
Explication : Cela signifie que vous devriez être en mesure d'ajouter de nouvelles fonctionnalités à un système sans modifier le code existant qui fonctionne. Lorsque vous modifiez du code existant, vous risquez d'introduire de nouveaux bugs dans des parties du système auparavant stables. L'OCP est souvent réalisé par l'utilisation d'interfaces, de classes abstraites et de polymorphisme.
Avantages :
- Stabilité : Le code existant reste inchangé, réduisant le risque de régressions.
- Évolutivité : Plus facile d'ajouter de nouvelles fonctionnalités sans casser les anciennes.
- Maintenabilité : Moins sujet aux changements par "effet domino".
Exemple (Non-respect vs. Respect) :
Non-respect (Modification du code existant pour une nouvelle fonctionnalité) :
// Anti-patron : Nécessité de modifier Calculator à chaque ajout d'une nouvelle opération
class Calculator {
public double calculate(char operation, double a, double b) {
switch (operation) {
case '+': return a + b;
case '-': return a - b;
// Si nous avons besoin d'une opération 'multiplier', nous modifions cette classe !
// case '*': return a * b; // Ceci modifie le code existant
default: throw new IllegalArgumentException("Invalid operation");
}
}
}
Respect (Extension avec de nouvelles fonctionnalités) :
// Ouvert à l'extension : De nouvelles opérations peuvent être ajoutées en implémentant cette interface
interface Operation {
double apply(double a, double b);
}
// Implémentations concrètes des opérations
class AddOperation implements Operation {
@Override
public double apply(double a, double b) {
return a + b;
}
}
class SubtractOperation implements Operation {
@Override
public double apply(double a, double b) {
return a - b;
}
}
// Nouvelle fonctionnalité ajoutée sans modifier le Calculator existant ou les interfaces Operation
class MultiplyOperation implements Operation {
@Override
public double apply(double a, double b) {
return a * b;
}
}
// Fermé à la modification : La classe Calculator n'a pas besoin de changer
class BetterCalculator {
public double calculate(Operation operation, double a, double b) {
return operation.apply(a, b);
}
}
// Utilisation :
// BetterCalculator calc = new BetterCalculator();
// System.out.println(calc.calculate(new AddOperation(), 5, 3)); // 8.0
// System.out.println(calc.calculate(new MultiplyOperation(), 5, 3)); // 15.0
3. Principe de Substitution de Liskov (LSP)
Définition : Les sous-types doivent être substituables à leurs types de base sans altérer la correction du programme.
Explication : Ce principe signifie que si un programme utilise une classe de base, il devrait pouvoir utiliser n'importe laquelle de ses sous-classes de manière interchangeable sans provoquer d'erreurs ou de comportements inattendus. Il traite principalement du sous-typage comportemental, garantissant que les classes dérivées étendent la fonctionnalité de la classe de base sans enfreindre son contrat inhérent. Une violation courante se produit lorsqu'une sous-classe lève une exception inattendue ou fournit une implémentation sans opération pour une méthode définie dans la super-classe, brisant ainsi le comportement attendu.
Avantages :
- Robustesse : Garantit que le polymorphisme fonctionne de manière fiable.
- Maintenabilité : Prévient les effets secondaires inattendus lors de l'utilisation de sous-classes.
- Correction : Garantit que le code utilisant les types de base fonctionnera correctement avec les types dérivés.
Exemple (Non-respect vs. Respect) :
Non-respect (Un carré n'est pas un substitut parfait pour un rectangle) :
// Anti-patron : Square enfreint le contrat de Rectangle
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Changer la largeur change aussi la hauteur
}
@Override
public void setHeight(int height) {
this.width = height; // Changer la hauteur change aussi la largeur
this.height = height;
}
}
// Méthode de test qui attend le comportement de Rectangle
class TestLSP {
public static void printArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4); // Nous nous attendons à ce que la surface soit 20
System.out.println("Expected Area: " + 5 * 4 + ", Actual Area: " + r.getArea());
}
public static void main(String[] args) {
Rectangle rect = new Rectangle();
printArea(rect); // Attendu : 20, Actuel : 20 (Correct)
Rectangle square = new Square();
printArea(square); // Attendu : 20, Actuel : 16 (Violation ! Square a changé la hauteur à 5 après setWidth)
}
}
Respect (Meilleure conception pour les formes ou utilisation d'abstractions séparées) :
Au lieu de forcer Square
dans une relation est-un
Rectangle
alors que cela enfreint son contrat, considérez :
- Avoir une classe de base abstraite
Shape
avec une méthodegetArea()
.Rectangle
etSquare
sont tous deux desShape
s, mais n'héritent pas directement l'un de l'autre si leurs comportements entrent en conflit. - Si l'héritage est souhaité, assurez-vous que
Square
ne surcharge pas les méthodes d'une manière qui enfreint le contrat deRectangle
. (Souvent, cela signifie queSquare
ne devrait pas hériter directement deRectangle
siRectangle
a des setters de largeur/hauteur mutables).
Un exemple d'adhérence plus simple :
interface Flyable {
void fly();
}
class Bird implements Flyable {
@Override
public void fly() {
System.out.println("Bird is flying.");
}
}
class Eagle extends Bird { // Eagle est un Bird, et peut voler
// Surcharge le comportement fly() mais respecte toujours le contrat Flyable
@Override
public void fly() {
System.out.println("Eagle is soaring.");
}
}
// Exemple de violation (si Penguin implémente Flyable mais ne peut pas voler) :
// class Penguin implements Flyable {
// @Override
// public void fly() {
// throw new UnsupportedOperationException("Penguins cannot fly!"); // Enfreint le LSP
// }
// }
// Manière correcte pour Penguin (ne pas le forcer à implémenter Flyable)
class Penguin {
public void swim() {
System.out.println("Penguin is swimming.");
}
}
Le LSP suggère que Penguin
ne devrait pas implémenter Flyable
s'il ne peut pas réellement voler, car cela briserait l'attente de tout code s'attendant à ce qu'un objet Flyable
soit capable de voler.
4. Principe de Ségrégation des Interfaces (ISP)
Définition : Les clients ne devraient pas être forcés de dépendre d'interfaces qu'ils n'utilisent pas. Plutôt qu'une seule grande interface, de nombreuses interfaces plus petites et spécifiques au client sont préférables.
Explication : Ce principe vise à décomposer les interfaces "grasses" en interfaces plus petites et plus spécifiques. Si une interface a trop de méthodes, certaines classes l'implémentant pourraient n'avoir besoin que d'un sous-ensemble de ces méthodes, étant forcées d'en implémenter d'autres qu'elles n'utilisent pas (souvent sous forme de méthodes vides ou levant des exceptions). La ségrégation des interfaces garantit qu'une classe ne dépend que des méthodes dont elle a réellement besoin.
Avantages :
- Couplage réduit : Les classes ne sont couplées qu'aux interfaces qu'elles utilisent réellement.
- Maintenabilité améliorée : Plus facile de modifier ou de refactoriser les interfaces.
- Flexibilité accrue : Plus facile d'implémenter uniquement les comportements nécessaires.
Exemple (Non-respect vs. Respect) :
Non-respect (Une seule interface "grasse") :
// Anti-patron : Interface Worker polyvalente
interface Worker {
void work();
void eat();
void sleep(); // Tous les travailleurs n'ont pas forcément besoin de "dormir" dans un contexte computationnel
void manage(); // Tous les travailleurs ne sont pas des managers
}
class HumanWorker implements Worker {
@Override public void work() { /* ... */ }
@Override public void eat() { /* ... */ }
@Override public void sleep() { /* ... */ }
@Override public void manage() { /* ... */ } // Les travailleurs humains peuvent gérer
}
class RobotWorker implements Worker {
@Override public void work() { /* ... */ }
@Override public void eat() { /* Le robot ne mange pas */ } // Forcé d'implémenter
@Override public void sleep() { /* Le robot ne dort pas */ } // Forcé d'implémenter
@Override public void manage() { /* Le robot ne gère pas */ } // Forcé d'implémenter
}
Respect (Interfaces ségréguées) :
// Interfaces plus petites et spécifiques
interface Workable {
void work();
}
interface Eatable {
void eat();
}
interface Sleepable {
void sleep();
}
interface Manageable {
void manage();
}
class HumanWorker implements Workable, Eatable, Sleepable, Manageable {
@Override public void work() { System.out.println("Human working."); }
@Override public void eat() { System.out.println("Human eating."); }
@Override public void sleep() { System.out.println("Human sleeping."); }
@Override public void manage() { System.out.println("Human managing."); }
}
class RobotWorker implements Workable { // N'implémente que ce dont il a besoin
@Override public void work() { System.out.println("Robot working."); }
}
// Si un robot pouvait aussi être gérable :
// class ManagerRobot implements Workable, Manageable {
// @Override public void work() { System.out.println("Manager robot working."); }
// @Override public void manage() { System.out.println("Manager robot managing."); }
// }
5. Principe d'Inversion de Dépendances (DIP)
Définition :
- Les modules de haut niveau ne devraient pas dépendre des modules de bas niveau. Tous deux devraient dépendre d'abstractions.
- Les abstractions ne devraient pas dépendre des détails. Les détails devraient dépendre des abstractions.
Explication : Ce principe promeut le découplage des modules logiciels. Au lieu qu'un module de haut niveau (qui contient une logique complexe) dépende directement d'un module de bas niveau (qui gère des tâches spécifiques comme l'accès à la base de données ou le contrôle matériel), les deux devraient dépendre d'une abstraction (une interface ou une classe abstraite). Cela permet un échange plus facile des implémentations et rend le système plus testable et flexible.
Avantages :
- Couplage faible : Les modules de haut niveau et de bas niveau ne sont pas directement connectés.
- Flexibilité accrue : Facile de changer les implémentations de bas niveau sans affecter la logique de haut niveau.
- Tests plus faciles : Les dépendances de bas niveau peuvent être mockées ou simulées pour les tests unitaires.
Exemple (Non-respect vs. Respect) :
Non-respect (Le module de haut niveau dépend d'un module concret de bas niveau) :
// Anti-patron : LightSwitch dépend directement d'une implémentation spécifique de LightBulb
class LightBulb { // Module de bas niveau (détail)
public void turnOn() { System.out.println("LightBulb: On"); }
public void turnOff() { System.out.println("LightBulb: Off"); }
}
class LightSwitch { // Module de haut niveau (logique)
private LightBulb bulb; // Dépendance directe à une classe concrète
public LightSwitch() {
this.bulb = new LightBulb(); // Fortement couplé : LightSwitch crée sa propre LightBulb
}
public void press() {
// Logique pour décider d'allumer ou d'éteindre
// Pour la simplicité, faisons simplement basculer
if (bulb.isOn()) { // En supposant une méthode isOn()
bulb.turnOff();
} else {
bulb.turnOn();
}
}
}
Respect (Les deux dépendent d'une abstraction) :
// Abstraction (interface)
interface Switchable {
void turnOn();
void turnOff();
boolean isOn(); // Supposons une méthode isOn pour l'état
}
// Module de bas niveau (détails) dépendent de l'abstraction
class LightBulb implements Switchable {
private boolean on = false;
@Override public void turnOn() { System.out.println("LightBulb: On"); on = true; }
@Override public void turnOff() { System.out.println("LightBulb: Off"); on = false; }
@Override public boolean isOn() { return on; }
}
class Fan implements Switchable { // Un autre détail de bas niveau
private boolean on = false;
@Override public void turnOn() { System.out.println("Fan: On"); on = true; }
@Override public void turnOff() { System.out.println("Fan: Off"); on = false; }
@Override public boolean isOn() { return on; }
}
// Module de haut niveau (logique) dépend de l'abstraction
class Button {
private Switchable device; // Dépend de l'abstraction
// Injection de Dépendances : Le périphérique Switchable est injecté (fourni de l'extérieur)
public Button(Switchable device) {
this.device = device;
}
public void press() {
if (device.isOn()) {
device.turnOff();
} else {
device.turnOn();
}
}
}
// Utilisation :
// Button lightButton = new Button(new LightBulb()); // Injecter LightBulb
// lightButton.press(); // LightBulb: On
// lightButton.press(); // LightBulb: Off
// Button fanButton = new Button(new Fan()); // Injecter Fan
// fanButton.press(); // Fan: On
Dans l'exemple de respect, le Button
(haut niveau) ne connaît pas et ne se soucie pas du type concret du périphérique Switchable
qu'il contrôle. Il opère purement sur l'interface Switchable
, ce qui le rend très flexible. Vous pouvez connecter un Button
à une LightBulb
, un Fan
, ou tout autre périphérique Switchable
sans modifier la classe Button
. C'est également la base des frameworks d'injection de dépendances.