Le principe de substitution de Liskov
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.
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.
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.
Voici deux autres dispositifs qui sont aussi des MusicDevice :
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 :
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 :
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.