Le principe de substitution de Liskov (LSP) : la compatibilité des sous-types

Image by Alexander Fox | PlaNet Fox from Pixabay

Le principe de substitution de Liskov, nommé d’après Barbara Liskov, stipule que les objets d’une superclasse doivent pouvoir être remplacés par des objets de ses sous-classes sans altérer la correction du programme. Autrement dit, une classe dérivée doit pouvoir être utilisée en lieu et place de sa classe de base sans que cela n’introduise de bugs ou ne change le fonctionnement du programme.

Pourquoi le LSP est-il souvent mal compris ?

Le LSP est parfois mal compris ou mal appliqué en raison de la confusion entre les concepts de « substitution » et d’« extension ». Beaucoup pensent que l’héritage permet simplement d’ajouter des fonctionnalités à une classe de base, mais le LSP impose des contraintes plus strictes : une classe dérivée ne doit pas seulement étendre la classe de base, elle doit se comporter comme la classe de base dans le contexte où celle-ci est attendue. Si ce n’est pas le cas, le contrat de la classe de base est rompu, et donc un non respect du LSP.

Exemple de LSP : Véhicules hybride et thermique

Prenons un exemple simple avec des véhicules. Supposons que nous ayons les classes suivantes :

  • Vehicule (classe parente)
  • VoitureThermique qui représente un véhicule fonctionnant à l’essence
  • VoitureHybride qui représente un véhicule hybride, donc fonctionnant avec de l’essence et de l’électricité

Exemple de conception respectant le LSP

Imaginons d’abord une conception qui respecte le LSP :

class Vehicule:
    def demarrer(self):
        pass
    
    def accelerer(self):
        pass

class VoitureThermique(Vehicule):
    def demarrer(self):
        print("La voiture thermique démarre en utilisant de l'essence.")
    
    def accelerer(self):
        print("La voiture thermique accélère en utilisant son moteur à essence.")

class VoitureHybride(Vehicule):
    def demarrer(self):
        print("La voiture hybride démarre en mode électrique.")
    
    def accelerer(self):
        print("La voiture hybride accélère en utilisant le moteur électrique ou le moteur à essence.")

Ici, VoitureThermique et VoitureHybride héritent de Vehicule et implémentent les méthodes demarrer et accelerer. Chaque classe dérivée respecte le contrat de la classe de base : elles peuvent être utilisées là où un Vehicule est attendu sans altérer le fonctionnement du programme.

def tester_vehicule(v: Vehicule):
    v.demarrer()
    v.accelerer()

# Utilisation avec une VoitureThermique
vw = VoitureThermique()
tester_vehicule(vw)  # Fonctionne correctement

# Utilisation avec une VoitureHybride
toyota = VoitureHybride()
tester_vehicule(toyota)  # Fonctionne correctement

Dans cet exemple, les deux types de véhicules respectent le LSP. La fonction tester_vehicule fonctionne correctement, que l’on passe un objet VoitureThermique ou VoitureHybride.

Exemple de violation du LSP

Voyons maintenant un exemple qui viole le LSP. Supposons que l’on veuille ajouter une classe Bus qui hérite également de Vehicule, mais avec une méthode demarrer qui impose une vérification spécifique, par exemple pour s’assurer qu’il y a assez de passagers avant de démarrer.

class Bus(Vehicule):
    def __init__(self, passagers):
        self.passagers = passagers
    
    def demarrer(self):
        if self.passagers < 10:
            print("Le bus ne peut pas démarrer, pas assez de passagers.")
        else:
            print("Le bus démarre avec ses passagers.")
    
    def accelerer(self):
        print("Le bus accélère.")

Cette implémentation introduit une condition spécifique dans la méthode demarrer, qui n’existe pas dans la classe Vehicule. Si l’on remplace un objet de type Vehicule par un Bus, cela peut entraîner des comportements inattendus :

def tester_vehicule(v: Vehicule):
    v.demarrer()
    v.accelerer()

# Utilisation avec un Bus
bus = Bus(5)
tester_vehicule(bus)  # Ne fonctionne pas comme prévu, car le bus ne démarre pas si les passagers sont moins de 10

Ici, la classe Bus viole le LSP car elle introduit une restriction qui n’existe pas dans la classe de base Vehicule. Le comportement du programme change selon le type d’objet, ce qui n’était pas le cas avec VoitureThermique et VoitureHybride.

Avantages de cette approche

  1. Cohérence : Les sous-types (VoitureThermique, VoitureHybride) peuvent être utilisés partout où le type de base (Vehicule) est attendu, sans surprises.
  2. Flexibilité : On peut ajouter de nouveaux types de voiture sans modifier le code existant de Vehicule.
  3. Robustesse : Le code client qui utilise ces classes est moins susceptible de contenir des bugs liés à des comportements inattendus.

En respectant le LSP, nous avons créé une hiérarchie de classes plus logique et plus fiable. Chaque forme conserve son comportement spécifique tout en adhérant à une interface commune, ce qui permet une substitution sans heurts dans le code client.

Kevin
Développeur fullstack depuis 16 ans, j'aime résoudre des problèmes complexes et me lancer dans des projets stimulants. J'ai développé de nombreux produits : progiciels, site e-commerce, web services, API ou encore plateforme Saas. J'aime l'accessibilité, le clean code, l'automatisation et améliorer l'Expérience Utilisateur.