Principe de substitution de Liskov (LSP), Comprendre et appliquer avec des exemples Java
Principe de Liskov Substitution (LSP) pour les développeurs de logiciels
Mise en situation : vous travaillez sur un projet orienté objet et tout se passe bien jusqu’au moment où vous dérivez une classe pour ajouter des fonctionnalités supplémentaires. Tout semble fonctionner au début, mais très vite, vous vous rendez compte que la nouvelle classe casse le comportement attendu de l’application. Vous vous demandez alors : Qu’est-ce qui ne va pas ? Est-ce que j’ai mal utilisé l’héritage ?
C’est ici que le principe de substitution de Liskov (LSP) entre en jeu.
Le LSP est l’un des cinq principes SOLID et a pour but d’éviter ces scénarios où une sous-classe viole le contrat implicite d’une classe parente. En tant que développeur, comprendre ce principe vous permet de garantir que votre code reste flexible et maintenable, même lorsqu’il est étendu par des sous-classes.
Dans cet article, je vais vous montrer ce qu’est exactement le LSP, pourquoi il est si crucial dans la conception de logiciels robustes, et surtout comment l’appliquer efficacement à travers des exemples concrets. À la fin, vous aurez toutes les clés pour ne plus jamais briser le comportement attendu de vos classes.
Qu’est-ce que le principe de substitution de Liskov (LSP) ?
Le principe de substitution de Liskov (LSP), introduit par Barbara Liskov en 1987, est l’un des cinq principes SOLID utilisés en programmation orientée objet. Ce principe stipule qu’une sous-classe doit pouvoir être substituée à sa classe parente sans que cela ne modifie le comportement attendu du programme. En d’autres termes, si vous utilisez une classe de base dans votre code, vous devriez pouvoir la remplacer par n’importe quelle sous-classe sans avoir à ajuster le code existant.
En termes simples, voici ce que cela signifie :
Si une classe B hérite de la classe A, alors B doit pouvoir être utilisée partout où A est acceptée. Les objets de B doivent se comporter de manière cohérente avec les attentes établies par la classe A.
Exemple classique pour mieux comprendre :
Imaginez que vous développez une application qui manipule des formes géométriques. Vous avez une classe de base Forme, et deux sous-classes : Rectangle et Carré. Comme vous le savez, un carré est un cas particulier de rectangle où tous les côtés sont égaux. Cependant, si vous créez une sous-classe Carré qui redéfinit le comportement de Rectangle, vous risquez de casser la logique qui fonctionne parfaitement pour la classe Rectangle. Cela brise le LSP.
Pourquoi ce principe est-il important ?
Le non-respect du LSP peut rendre votre code plus difficile à comprendre, à maintenir et surtout à tester. Si une sous-classe introduit des comportements inattendus ou redéfinit de manière inattendue des méthodes de la classe parente, cela peut entraîner des bugs cachés et un comportement imprévisible du logiciel. Dans mes missions, j’ai observé que les violations de LSP se manifestent souvent à travers des bugs en production difficiles à reproduire, ce qui allonge les cycles de correction et alourdit les coûts de maintenance. C’est pour cette raison que le Dependency Inversion Principle recommande de dépendre d’abstractions : une interface garantit un contrat que toutes les implémentations respectent, ce qui évite précisément les violations LSP.
L’héritage dans votre code crée des comportements imprévisibles et des bugs en cascade ?
Les sous-classes cassent les contrats de leurs parents, les tests passent mais le comportement en production est incorrect, et personne ne comprend vraiment pourquoi. Réservons 30 minutes pour diagnostiquer les violations de design dans votre architecture et construire un plan de remédiation.
Exemple pratique - Briser le LSP : Ce qu’il ne faut pas faire
Prenons l'exemple classique du Rectangle et du Carré en Java pour illustrer une violation du principe de substitution de Liskov (LSP).
Classe Rectangle :
public class Rectangle {
protected int largeur;
protected int hauteur;
public Rectangle(int largeur, int hauteur) {
this.largeur = largeur;
this.hauteur = hauteur;
}
public void setLargeur(int largeur) {
this.largeur = largeur;
}
public void setHauteur(int hauteur) {
this.hauteur = hauteur;
}
public int calculerAire() {
return this.largeur * this.hauteur;
}
}
Ici, la classe Rectangle fonctionne normalement. Elle a des méthodes pour définir la largeur et la hauteur, et pour calculer l'aire.
Sous-classe Carre :
public class Carre extends Rectangle {
public Carre(int taille) {
super(taille, taille);
}
@Override
public void setLargeur(int taille) {
this.largeur = this.hauteur = taille;
}
@Override
public void setHauteur(int taille) {
this.largeur = this.hauteur = taille;
}
}
Dans cet exemple, nous avons une classe Carre qui hérite de Rectangle. Un carré étant un cas particulier de rectangle où tous les côtés sont égaux, nous avons redéfini les méthodes setLargeur et setHauteur pour toujours appliquer la même valeur à la largeur et à la hauteur.
Problème avec le LSP
Maintenant, utilisons ces classes dans un programme. Supposons que nous ayons une méthode qui teste les objets Rectangle :
public class Main {
public static void testerRectangle(Rectangle rect) {
rect.setLargeur(5);
rect.setHauteur(10);
int aire = rect.calculerAire();
System.out.println("Aire attendue : 50, Aire calculée : " + aire);
}
public static void main(String[] args) {
// Test avec un Rectangle
Rectangle rect = new Rectangle(2, 3);
testerRectangle(rect);
// Test avec un Carre
Carre carre = new Carre(5);
testerRectangle(carre);
}
}
Résultat :
- Avec l'objet
Rectangle, tout fonctionne comme prévu. L'aire calculée sera de 50 (5 * 10). - Avec l'objet
Carre, cependant, le calcul ne donnera pas le résultat attendu. En appelantsetHauteur(10), nous changeons aussi la largeur à 10, et l'aire calculée sera 100 au lieu de 50.
Ce comportement brise le LSP, car la classe Carre ne respecte pas les attentes fixées par Rectangle. Cela peut causer des bugs ou des comportements inattendus dans les programmes qui s’attendent à ce qu’un Rectangle fonctionne d’une certaine manière.
Exemple correct - Respecter le LSP : Ce qu’il faut faire
Pour respecter le principe de substitution de Liskov, nous devons nous assurer que les sous-classes n'altèrent pas le comportement attendu de la classe de base. Dans le cas du Rectangle et du Carre, le problème vient du fait que le carré redéfinit la manière dont les dimensions sont définies, brisant ainsi le comportement du rectangle.
Solution : Séparer les concepts
Une solution consiste à ne pas faire de Carre une sous-classe de Rectangle. Bien que mathématiquement, un carré soit un type particulier de rectangle, en termes de conception orientée objet, il est souvent préférable de modéliser ces concepts de manière distincte pour éviter de briser le LSP.
Nouvelle conception : Classes indépendantes Rectangle et Carre
Voici comment nous pourrions concevoir cela correctement :
Classe Rectangle :
public class Rectangle {
protected int largeur;
protected int hauteur;
public Rectangle(int largeur, int hauteur) {
this.largeur = largeur;
this.hauteur = hauteur;
}
public void setLargeur(int largeur) {
this.largeur = largeur;
}
public void setHauteur(int hauteur) {
this.hauteur = hauteur;
}
public int calculerAire() {
return this.largeur * this.hauteur;
}
}
La classe Rectangle reste inchangée.
Classe Carre (indépendante) :
public class Carre {
private int taille;
public Carre(int taille) {
this.taille = taille;
}
public void setTaille(int taille) {
this.taille = taille;
}
public int calculerAire() {
return this.taille * this.taille;
}
}
Dans cette version, Carre n’hérite plus de Rectangle. Nous avons ainsi une classe Carre totalement indépendante qui suit ses propres règles et n'interfère pas avec les attentes définies pour un Rectangle. Le carré a une seule dimension (taille), et son comportement est cohérent avec son concept sans violer les principes de la classe Rectangle.
Modification du programme de test :
Puisque Carre n’est plus une sous-classe de Rectangle, nous devons légèrement modifier notre méthode de test pour respecter cette nouvelle structure.
public class Main {
public static void testerRectangle(Rectangle rect) {
rect.setLargeur(5);
rect.setHauteur(10);
int aire = rect.calculerAire();
System.out.println("Aire attendue (Rectangle) : 50, Aire calculée : " + aire);
}
public static void testerCarre(Carre carre) {
carre.setTaille(5);
int aire = carre.calculerAire();
System.out.println("Aire attendue (Carre) : 25, Aire calculée : " + aire);
}
public static void main(String[] args) {
// Test avec un Rectangle
Rectangle rect = new Rectangle(2, 3);
testerRectangle(rect);
// Test avec un Carre
Carre carre = new Carre(5);
testerCarre(carre);
}
}
Résultat :
- Le
Rectanglefonctionne comme prévu : l'aire est de 50. - Le
Carrefonctionne également correctement : l'aire est de 25 (5 * 5), et le comportement est bien conforme aux attentes.
Pourquoi cela respecte-t-il le LSP ?
Dans cette solution, nous avons séparé les deux concepts (Rectangle et Carre) afin que chaque classe respecte ses propres contraintes. Le LSP est respecté car les objets Rectangle et Carre ne sont plus liés par une relation d’héritage qui pourrait potentiellement briser les attentes du programme.
Autre approche : utiliser une interface commune
Si vous souhaitez toujours utiliser l’héritage ou l’interchangeabilité, une meilleure approche serait d’introduire une interface commune Forme que les deux classes pourraient implémenter. Ainsi, elles partageraient des comportements communs tout en ayant leurs propres implémentations spécifiques.
Exemple avec une interface commune
public interface Forme {
int calculerAire();
}
public class Rectangle implements Forme {
private int largeur;
private int hauteur;
public Rectangle(int largeur, int hauteur) {
this.largeur = largeur;
this.hauteur = hauteur;
}
@Override
public int calculerAire() {
return largeur * hauteur;
}
}
public class Carre implements Forme {
private int taille;
public Carre(int taille) {
this.taille = taille;
}
@Override
public int calculerAire() {
return taille * taille;
}
}
Exemple d'utilisation :
public class Main {
public static void afficherAire(Forme forme) {
System.out.println("Aire calculée : " + forme.calculerAire());
}
public static void main(String[] args) {
Forme rectangle = new Rectangle(5, 10);
Forme carre = new Carre(5);
afficherAire(rectangle); // Aire calculée : 50
afficherAire(carre); // Aire calculée : 25
}
}
Conclusion et conseils pratiques
Le principe de substitution de Liskov est fondamental pour garantir que les objets dérivés fonctionnent comme prévu dans des systèmes orientés objet. Respecter ce principe vous permet de rendre votre code plus extensible et de prévenir les erreurs liées à des comportements inattendus dans les sous-classes.
Récapitulatif des points clés :
- Le LSP exige que les sous-classes puissent être utilisées de manière interchangeable avec les classes parentes sans modifier le comportement attendu.
- Une sous-classe qui modifie les règles d'une classe parente brise le LSP.
- Il est parfois préférable d’utiliser des interfaces ou la composition pour éviter les problèmes d’héritage tout en respectant le LSP.
Conseils pratiques :
- Testez régulièrement votre code pour vérifier que les sous-classes respectent bien le comportement des classes parentes.
- Utilisez des interfaces ou la composition lorsque cela est possible, surtout si vous constatez que l’héritage ne correspond pas bien à votre modèle d’objet.
- Appliquez le LSP avec souplesse, en l’adaptant à vos besoins de conception, mais gardez en tête son importance pour éviter des bugs difficiles à identifier.
FAQ sur le principe de substitution de Liskov (LSP)
1. Qu’est-ce que le LSP exactement ?
Le principe de substitution de Liskov (LSP) stipule qu’une sous-classe doit pouvoir remplacer sa classe parente sans altérer le comportement du programme. Si vous utilisez une instance d’une sous-classe à la place d’une classe de base, le programme ne doit pas avoir de comportements inattendus ou incorrects.
2. Pourquoi est-ce que je dois respecter le LSP ?
Le LSP garantit que votre code est extensible et maintenable. Sans ce principe, les sous-classes pourraient introduire des comportements indésirables ou imprévisibles, compliquant la détection des bugs et rendant votre code plus difficile à maintenir.
3. Quels sont les signes indiquant que mon code ne respecte pas le LSP ?
Voici quelques indices de violation du LSP :
- Ta sous-classe modifie ou redéfinit des méthodes de la classe parente de manière inattendue.
- Vous devez modifier le code existant lorsque vous ajoutez une nouvelle sous-classe.
- La sous-classe ne respecte pas les propriétés définies par la classe parente.
4. Quelle est la différence entre l’héritage classique et l’application du LSP ?
L’héritage permet à une classe de réutiliser du code d’une autre. Cependant, respecter le LSP va plus loin : il garantit que la sous-classe maintient le comportement logique attendu de la classe parente, sans modifier ses règles.
5. Est-ce que le LSP est toujours applicable ?
Non, le LSP est un principe de conception qui doit être appliqué lorsqu’il est pertinent. Dans certains cas, éviter des hiérarchies complexes en utilisant la composition ou des interfaces peut être une meilleure approche.
6. Comment tester si mon code respecte le LSP ?
Une manière de tester est de vérifier que vous pouvez utiliser une instance de la sous-classe à la place de la classe parente sans modifier le comportement du programme. Si des ajustements sont nécessaires, il est probable que le LSP soit violé.
7. Quelles sont les alternatives si je n’arrive pas à respecter le LSP dans mon code ?
Si vous avez des difficultés à respecter le LSP, il peut être préférable de repenser la conception. Utiliser des interfaces, la composition plutôt que l’héritage, ou des classes abstraites peut aider à structurer votre code sans violer ce principe.
Ressource gratuite : 10 signaux que votre équipe tech est en danger
10 signaux d'alarme pour identifier les problèmes systémiques cachés dans votre équipe avant qu'ils deviennent critiques. Auto-diagnostic inclus : 5 minutes pour savoir où vous en êtes.