Principe de substitution de Liskov (LSP), Comprendre et appliquer avec des exemples Java

Par KamangaFeb 6, 202410 mins de lecture

Principe de Liskov Substitution (LSP) pour les développeurs de logiciels

L'équipe code une hiérarchie d'objets, tout marche. Puis arrive une sous-classe qui ajoute "juste" une fonctionnalité, et le comportement attendu de l'application casse en silence, parfois en production seulement, des semaines plus tard. La question revient toujours : l'héritage a-t-il vraiment été le bon outil ?

Le principe de substitution de Liskov (LSP), formulé par Barbara Liskov en 1987, attaque exactement ce scénario. Quatrième des cinq principes SOLID, il s'assure qu'une sous-classe respecte le contrat implicite de sa classe parente : condition non négociable pour qu'un code orienté objet reste flexible et maintenable.

Cet article explique ce que recouvre vraiment LSP, pourquoi il est central dans la conception de logiciels robustes, et comment l'appliquer concrètement avec des exemples Java.


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 ?

Une violation de LSP rend le code dur à comprendre, dur à maintenir, et surtout dur à tester. Quand une sous-classe redéfinit en douce une méthode de sa classe parente, vous obtenez des bugs cachés et un comportement imprévisible. Sur mes missions, ces violations se manifestent presque toujours par des bugs en production difficiles à reproduire : cycles de correction qui s'allongent, coûts de maintenance qui dérapent. C'est précisément pour cette raison que le Dependency Inversion Principle impose de dépendre d'abstractions : une interface fige un contrat que toutes les implémentations doivent respecter, ce qui supprime à la racine la possibilité d'une violation LSP.


Vous voulez sentir quand un héritage va casser un contrat avant de l'écrire ?

Repérer une sous-classe qui trahit sa classe parente, ça ne s'apprend pas en lisant la définition du LSP : ça se travaille sur du vrai code. En mentoring 1:1, je relis vos hiérarchies d'objets avec vous, je vous montre où le contrat se fissure et comment choisir entre héritage, composition ou interface. Vous repartez avec le réflexe de conception, pas juste la théorie.

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 appelant setHauteur(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 Rectangle fonctionne comme prévu : l'aire est de 50.
  • Le Carre fonctionne é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 qu'une hiérarchie d'objets se comporte comme prévu. Le respecter rend votre code extensible et prévient les erreurs liées aux comportements inattendus des 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.

Le LSP n'est qu'une pratique parmi 100 pour concevoir une hiérarchie qui tient

Cet article décortique une seule règle de conception : qu'une sous-classe respecte le contrat de sa parente. Le Craft Bundle réunit les 100 pratiques que j'applique au quotidien pour modéliser des objets propres, du choix entre héritage et composition jusqu'aux tests qui attrapent une violation de contrat. Ce sont les réflexes que l'IA ne vous apprendra jamais, parce qu'elle ne les a jamais vus tenir en production.


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 l'extensibilité et la maintenabilité de votre code. Sans lui, les sous-classes introduisent des comportements indésirables ou imprévisibles, ce qui complique la détection des bugs et alourdit la maintenance.

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 donne à une classe la capacité de réutiliser le code d'une autre. Le LSP va plus loin : il impose que la sous-classe maintienne le comportement logique attendu de la classe parente, sans en modifier les 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.


Ecris par Kamanga

Expert IT avec 25 ans d'expérience en développement logiciel, diplômé EPITECH et MBA. Spécialisé en software craftsmanship, gestion du changement, stratégie, direction des systèmes d'information, coaching et certifié en agilité.