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

Par Kamanga6 févr. 202410 mins de lecture

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

Mise en situation: tu travailles sur un projet orienté objet et tout se passe bien jusqu'au moment où tu dérives une classe pour ajouter des fonctionnalités supplémentaires. Tout semble fonctionner au début, mais très vite, tu te rends compte que la nouvelle classe casse le comportement attendu de l'application. Tu te demandes 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 te permet de garantir que ton code reste flexible et maintenable, même lorsqu’il est étendu par des sous-classes.

Dans cet article, je vais te 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, tu auras toutes les clés pour ne plus jamais briser le comportement attendu de tes 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 tu utilises une classe de base dans ton code, tu devrais 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 :

Imagine que tu développes une application qui manipule des formes géométriques. Tu as une classe de base Forme, et deux sous-classes : Rectangle et Carré. Comme tu le sais, un carré est un cas particulier de rectangle où tous les côtés sont égaux. Cependant, si tu crées une sous-classe Carré qui redéfinit le comportement de Rectangle, tu risques 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 ton 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.


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 tu souhaites 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 te permet de rendre ton 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 :

  • Teste régulièrement ton code pour vérifier que les sous-classes respectent bien le comportement des classes parentes.
  • Utilise des interfaces ou la composition lorsque cela est possible, surtout si tu constates que l’héritage ne correspond pas bien à ton modèle d’objet.
  • Applique le LSP avec souplesse, en l’adaptant à tes besoins de conception, mais garde 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 tu utilises 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 ton 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 ton 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.
  • Tu dois modifier le code existant lorsque tu ajoutes 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 tu peux 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 tu as 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 ton code sans violer ce principe.


Rédigé 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é.

Copyright © 2024
 Kamanga
  Powered by bloggrify