Liskov Substitution Principle

Liskov Substitution Principle : Gardez vos héritages sous contrôle !

Le principe de substitution de Liskov (Liskov Substitution Principle – LSP) est le troisième des cinq principes SOLID en programmation orientée objet. Formulé par Barbara Liskov en 1987, il stipule que :

“Si S est un sous-type de T, alors les objets de type T doivent pouvoir être remplacés par des objets de type S sans altérer les propriétés du programme.”

En d’autres termes, une sous-classe doit pouvoir être utilisée en lieu et place de sa classe mère sans provoquer de comportement inattendu.

Une façon simple d’expliquer ce principe est d’imaginer des jouets de construction. Si une pièce est censée remplacer une autre mais ne s’adapte pas correctement, la structure entière devient instable. En programmation, c’est la même chose : si une sous-classe ne respecte pas les attentes de la classe mère, le code devient imprévisible et peut causer des erreurs difficiles à corriger.

Ce principe est souvent illustré par l’exemple du carré et du rectangle, une relation d’héritage qui, bien que valide en mathématiques, ne l’est pas nécessairement en programmation orientée objet.

Pourquoi est-ce important ?

Le non-respect du LSP peut entraîner :

  • Une violation du polymorphisme, rendant le code moins flexible. Imaginez une classe Oiseau avec une méthode voler(). Si on crée une sous-classe Pingouin qui hérite de Oiseau mais lève une exception dans voler(), alors un code qui manipule un Oiseau ne pourra pas toujours voler comme prévu, ce qui casse le polymorphisme.
  • Des bugs difficiles à identifier, car les remplacements d’instances ne respectent pas les attentes. Une classe Voiture dispose d’une méthode rouler(). Si une sous-classe BateauVoiture hérite de Voiture mais modifie rouler() pour naviguer au lieu de rouler, un programme s’attendant à un comportement terrestre pourrait produire des erreurs imprévisibles
  • Une maintenance plus compliquée, car le code devient plus fragile et imprévisible. Une classe Animal avec faireDuBruit() est héritée par Chien et Poisson. Si Poisson ne peut pas faire de bruit et lève une exception, les développeurs devront sans cesse adapter le code pour gérer des cas particuliers, rendant la maintenance plus complexe.

Mauvais exemple : Violation du LSP avec Rectangle et Carré

Prenons l’exemple populaire du carré et du rectangle, cité plus haut :

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; // Forcer un carré
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height; // Forcer un carré
    }
}

Pourquoi est-ce une violation du LSP ?

Le problème ici est que Square ne respecte pas les attentes de Rectangle. Si une méthode manipule un Rectangle, elle s’attend à pouvoir modifier indépendamment sa largeur et sa hauteur, ce qui n’est pas le cas pour un Square.

Testons ce comportement :

public static void testRectangle(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    System.out.println("Aire attendue: 50, Aire calculée: " + rect.getArea());
}

public static void main(String[] args) {
    Rectangle rect = new Rectangle();
    testRectangle(rect);

    Rectangle square = new Square();
    testRectangle(square); // Problème ici !
}

Sortie attendue :

Aire attendue: 50, Aire calculée: 50
Aire attendue: 50, Aire calculée: 100  <-- Erreur due à la violation du LSP

Ici, le comportement change en fonction de l’implémentation, ce qui brise l’interchangeabilité des classes.


Bon exemple : Respect du LSP avec une conception améliorée

Plutôt que d’avoir une relation d’héritage inappropriée, on peut utiliser la composition et une interface commune.

Refactorisation correcte :

interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    protected int width;
    protected int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    public int getArea() {
        return side * side;
    }
}

Testons maintenant :

public static void testShape(Shape shape) {
    System.out.println("Aire calculée: " + shape.getArea());
}

public static void main(String[] args) {
    Shape rect = new Rectangle(5, 10);
    testShape(rect);

    Shape square = new Square(5);
    testShape(square);
}

Sortie correcte :

Aire calculée: 50
Aire calculée: 25

Cette refactorisation respecte le LSP, car les objets Rectangle et Square respectent leur propre logique métier tout en partageant une interface commune Shape.

Autre mauvais exemple : Violation du LSP avec Oiseaux

Un autre exemple courant est l’utilisation incorrecte d’une hiérarchie d’héritage entre les oiseaux :

class Oiseau {
    public void voler() {
        System.out.println("Cet oiseau vole");
    }
}

class Pingouin extends Oiseau {
    @Override
    public void voler() {
        throw new UnsupportedOperationException("Un pingouin ne peut pas voler");
    }
}

Pourquoi est-ce une violation du LSP ?

Un code s’attendant à manipuler un Oiseau supposera que l’oiseau peut voler. Cependant, en remplaçant un Oiseau par un Pingouin, nous obtenons une exception non prévue qui casse le comportement attendu.

Solution correcte :

Une meilleure solution consiste à utiliser des interfaces :

interface Oiseau {}

interface OiseauVolant extends Oiseau {
    void voler();
}

class Moineau implements OiseauVolant {
    public void voler() {
        System.out.println("Le moineau vole");
    }
}

class Pingouin implements Oiseau {
    // Aucun comportement de vol ici
}

Ainsi, les classes ne forcent pas un comportement non applicable et restent substituables sans créer d’erreur.

Conclusion

Le principe de substitution de Liskov est essentiel pour écrire un code robuste et évolutif. Pour l’appliquer correctement :

  • Évitez de modifier des comportements attendus dans une sous-classe.
  • Ne forcez pas un héritage s’il n’a pas de sens.
  • Utilisez des interfaces ou la composition lorsque les comportements diffèrent trop.

En respectant ce principe, vous garantissez un code plus flexible, réutilisable et facile à maintenir.


Ressources complémentaires

Qu’en pensez-vous ? Avez-vous rencontré des violations du LSP dans vos projets ? Partagez vos expériences en commentaire !