Dernière mise à jour:

Le principe de substitution de Liskov

Cédric Gérard
Cédric Gérard Code

Troisième principe de l’ensemble SOLID, est le Principe de Substitution de Liskov, définit les conditions que doivent respecter les sous-classes lorsqu’elles sont utilisées à la place de leurs classes parentes.

Ce principe a été formulé par Barbara Liskov en 1987. Il énonce que “Si S est une sous-classe de T, alors les objets de type T peuvent être remplacés par des objets de type S sans altérer la cohérence du programme”. En d’autres termes, cela signifie que les sous-classes doivent pouvoir être utilisées de manière interchangeable avec leurs classes parentes, sans introduire d’erreurs ni de comportements inattendus.

Prenons un exemple concret d’un code qui ne respect pas ce principe pour illustrer les problèmes que cela peut engendrer.

Code qui utilise une implementation qui ne respect pas le principe de Liskov

Dans cet exemple, nous avons une fonction qui va prendre un dispositif capable de jouer de la musique. La fonction va lancer la musique, réaliser des opérations et enfin couper la musique. Cette même fonction utilise le retour du dispositif pour déterminer s’il y a une erreur et la gérer.

Voyons maintenant le code des dispositifs.

Interface MusicDevice et code d'un dispositif cassé

Ici, nous avons, tout d’abord, la définition de l’interface MusicDevice. Cette dernière permet de définir le contrat que doit respecter un dispositif musical. Ensuite, on le code d’un appareil cassé et qui n’est plus en mesure de lire de la musique.

Dans cette implémentation, on a choisi de lever une exception lorsqu’il n’est pas possible de lire la musique. Le problème, c’est qu’ici notre erreur ne va pas être gérée par le gestionnaire d’erreur de la fonction qui lance la lecture de la musique. Pour qu’une erreur soit gérée, il faudrait que notre code retourne la valeur “false”. Il y a donc une différence de comportement entre ce que fait ce dispositif et d’autre qui vont respecter le contrat d’interface.

Cette classe peut littéralement planter l’application avec une levée d’exception là où les appelants vont attendre un booléen pour gérer les cas d’erreurs. Notre dispositif cassé ne respectant pas le contrat qu’il étend alors il ne respect pas le principe de Liskov non plus. Dans ce cas, notre BrokenBluetoothSpeaker, n’est pas interchangeable avec n’importe quel MusicDevice sans altérer le comportement de l’application.

Remanions maintenant ce code et ajoutons d’autre dispositif dans un contexte ou le principe de substitution de Liskov est respecté :

Voici notre dispositif Bluetooth défectueux respectant le contrat en s’assurant de toujours retourner un booléen.

Code d'un dispositif bluetooth cassé

Voici deux autres dispositifs qui sont aussi des MusicDevice :

Code d'un casque audio
Code d'un dispositif bluetooth fonctionnel

Dans cet exemple, l’ensemble des trois classes ci-dessus sont bien interchangeables ainsi qu’avec n’importe quel autre MusicDevice. Ces échanges peuvent se faire sans que cela ne pose de problème de fonctionnement au niveau de l’application. Cela ne générera pas de comportement imprévisible, car le contrat de la classe parente (ici notre interface MusicDevice) est respecté.

Nous avons ici pris l’exemple d’un langage typé statiquement où le typage (interface) force quand même notre code à respecter un contrat définit à l’avance. Pour le casser nous avons du passer par la levée d’une exception. En revanche, si on regarde un langage où le typage ne nous aidera pas, il est beaucoup plus facile d’introduire des comportements imprévisibles.

Prenons un exemple similaire, mais cette fois-ci en Ruby :

Code d'une application de musique en Ruby

Ici, nous avons un code simple qui va lire de la musique et la stopper à la fin du programme. Ce code va quand même vérifier que la musique est bien en train de jouer. Dans le cas contraire, elle va remonter une erreur.

Prenons maintenant les implémentations des dispositifs suivantes :

Code d'une classe casque en Ruby
Code d'une classe enceinte bluetooth en Ruby
Code d'une classe enceinte bluetooth cassée en Ruby

Ici, nous n’avons aucun typage pour nous imposer un type de retour bien que ces trois classes étendent la même classe parente. On voit que chaque implémentation est faite d’une façon différente. La première décide de retourner un booléen, la deuxième retourne l’instance en cas de succès et nil s’il y a eu un problème. Enfin, la dernière lève une exception.

Ces trois classes ne sont pas du tout interchangeables sans causer des incohérences dans l’exécution d’une application bien qu’elles soient du même type (MusicDevice). Il y a cependant des cas qui “tombent en marche”, comme la classe Headphone qui retourne false en cas d’erreur et la classe BluetoothSpeaker qui elle retourne nil. Notre application utilisant un “falsey check” supportera cette différence, mais c’est plus un coup de chance qu’un design réussi. Il est impossible de proposer une application qui manipule ces trois classes facilement et sans risque en s’appuyant sur la classe parente. Pour la gestion des erreurs, par exemple, il est indispensable de connaître les spécificités de chacune. Imaginer ce que cela peut donner avec des dizaines de dispositifs différents.

Il n’y a pas de miracle ici, il faut de la rigueur du côté des développeurs pour éviter ce genre de problèmes et garantir un code robuste. Il y a aussi un moyen de s’appuyer sur les tests unitaires afin de valider les comportements. Je proposerai un article prochainement avec un exemple complet sur RSpec.

Conclusion

Le principe de substitution de Liskov est, au final, uniquement du bon sens. En effet, si j’ai un programme qui affiche des formes géométriques, il doit être capable d’afficher des ronds, des carrés et des triangles de la même façon du moment que ces trois formes soient toutes des formes géométriques du même type. Sans entendu, que les classes Circle, Square et Triangle héritent de Shape. De la même manière, je dois être capable d’ajouter la classe Rectangle et permettre à mon application de l’afficher sans avoir à modifier quoi que ce soit d’autre.

Le non-respect de ce principe est à l’origine de beaucoup de souffrance dans la programmation orientée objet. C’est un problème bien plus difficile à suivre dans les langages qui ne bénéficient pas d’un typage statique.

De manière générale, il faut toujours être vigilant lorsqu’on s’appuie sur l’héritage. Cela implique un couplage fort entre une classe et sa classe parente dont elle doit respecter scrupuleusement le comportement.

Cédric Gérard

Cédric Gérard

Je suis dans l'informatique depuis tout jeune. D'abord intéressé par le hardward (montage, overcloking), j'ai mis du temps à trouver ma voie. Je suis tombé dans le développement en 2007, je n'ai jamais arrêté depuis..

Aujourd'hui, je suis développeur web avec une plus grande appétence pour le backend. J’accorde beaucoup d’attention à la valeur apportée aux utilisateurs finaux. On ne réalise pas d'application que pour se faire plaisir, après tout.

Je mets aussi un point d'honneur à livrer du code de qualité en m'appuyant sur les bonnes pratiques du développement logiciel et je défends les valeurs du software craftmanship.

L'agilité est également un élément essentiel pour un travail fiable et efficace. Je ne parle pas de méthode, mais de l'état d'esprit prôné par l'agilité.

J'aime partager mes compétences et j'ai une appétence particulière pour l'encadrement des développeurs juniors.

Je suis également en quête de sens, aucune technologie étant une fin en elle-même, j'ai besoin de savoir pourquoi je travaille et qu'elle est la valeur produite.

Articles en relation

Le principe d'injection de dépendance

Le principe de ségrégation des interfaces

Le principe Ouvert/Fermé