Substitution de Liskov : le polymorphisme aux petits oignons.
Cet article fait partie d'une série sur SOLID : les 5 principes fondamentaux de la programmation. Vous pouvez retrouver tous les autres ici.
Abstraire en programmation est extrêmement important.
On l’a bien vu avec le principe ouvert/fermé, ça permet d’écrire du code qui ne se répète pas, que l’on peut étendre super facilement et qui reste simple à maintenir, même des années après.
Mais au final, n’a-t-on pas remplacé le problème par un autre ?
À force de monter dans les abstractions, on finit par écrire des modules avec de plus en plus de dépendances. Et le module qui se trouve tout en haut de la pyramide ne connaît même pas la règle métier super importante tout en bas, même quand celle-ci dépend de lui.
C’est comme le patron de chez McDonald, il est trop haut dans la hiérarchie pour connaître le stagiaire qui fait des burgers au centre-ville de Poitier. Pourtant, c’est en partie lui qui fait tourner le business.
Donc oui, il est vrai que plus un module est abstrait, plus il a de dépendances, et donc beaucoup plus de risque de causer des instabilités, en particulier lorsqu’il faut faire du polymorphisme.
Mais vous savez quoi ? On s’en fout royalement.
Car il y a un principe fondamental qui permet de faire des modules robustes et maintenables quoi qu’il arrive, qu’ils aient 10, 100 ou un milliard de dépendances. Et c’est ce que l’on va voir dans cet article.
Besoin d'un résumé des principes SOLID et leur application concrète ? Allez lire le bonus de cet article.
Problème
Imaginez que vous êtes à la charge d’un entrepôt type Amazon totalement automatisé, dans lequel travaillent jour et nuit 20 000 robots.
Vous êtes tous seul et ce sont robots qui se chargent de tout : préparer les commandes, déplacer les colis, charger les conteneurs, récurer les toilettes, faire la comptabilité… Tout ce que vous avez à faire, c’est vérifier que tout fonctionne bien et donner les instructions qui sortent de l’ordinaire.
Bien sûr, à 20 000, il est impossible de faire du cas par cas, même en leur donnant un petit nom sympa.
Le meilleur moyen de s’en sortir est donc de catégoriser les robots par rapport à ce qu’ils peuvent faire, et de hiérarchiser ces catégories.
Par exemple :
- Tous les robots peuvent se déplacer.
- Seuls les robots liés à l’administratif peuvent imprimer des documents.
- Seuls les robots liés à la logistique peuvent déplacer des charges de plus de 6kg.
class Robot
{
public string ID { get; set; }
public Robot(string id)
{
ID = id;
}
public virtual void Move(string location) { }
}
class AdministrationRobot : Robot
{
public AdministrationRobot(string id) : base(id) { }
public void PrintDocumentOnPaper() { }
public override void Move(string location)
{
// Marche
}
}
class LogisticRobot : Robot
{
public LogisticRobot(string id) : base(id) { }
public override void Move(string location)
{
// Roule
}
public void MoveHeavyCharges() { }
}
Si ont traduit cette situation dans des termes informatiques :
- Les robots sont les instances (objets en POO).
- Les catégories sont les types (classes en POO).
- La hiérarchie est traduite sous forme d’abstractions (héritages/interfaces).
Grâce à ce système, vous pouvez ordonner à un robot de se déplacer jusqu’au secteur B sans même avoir besoin de savoir duquel il s’agit, car c’est une action qu’ils peuvent tous faire.
Et l’outil que vous allez utiliser pour passer vos ordres sans même savoir à quel robot vous vous adressez, c’est le polymorphisme.
static void Main(string[] args)
{
List<Robot> robots = new List<Robot>()
{
new Robot("DAVID#9874"),
new AdministrationRobot("GEOFREY#5157"),
new LogisticRobot("LUCAS#1795")
};
foreach (Robot robot in robots)
{
robot.Move("Secteur B");
}
}
Présenté comme ça, ce système semble vraiment bien foutu.
En prenant le temps de bien définir les catégories, on peut arriver à une hiérarchie avec une belle division des tâches (responsabilité unique), nécessitant peu de maintenance et extensible à l’infini (ouvert/fermé). De quoi vous la couler douce pour le restant de votre contrat !
Et pourtant, je ne donne pas une semaine à ce système avant qu’il se casse complètement la gueule.
Mais pourquoi ? Mon organisation était si parfaite 😭
Oui, mais uniquement en apparence.
Imaginez que vous ordonnez à un robot au hasard de vous amener votre café fraîchement coulé à la salle de pause, directement à votre bureau.
À priori, aucun souci. Tous les robots peuvent se déplacer et soulever des petits objets, donc n’importe lequel fera l’affaire.
static void Main(string[] args)
{
var lucas = new LogisticRobot("LUCAS#1795");
BringMeCoffee(lucas);
}
public static void BringMeCoffee(Robot robot)
{
robot.Move("Salle de pause");
robot.TakeItem("Coffee");
robot.Move("Bureau du chef");
return robot.GiveItem();
}
Mais sont-ils tous capables de monter les escaliers ? De passer dans le couloir de 3 mètres de large ? D’ouvrir votre porte ?
Bien sûr que non, à chacun sa méthode !
En effet, ce n’est pas parce que tous les robots peuvent se déplacer qu’ils le font de la même manière, on peut très bien imaginer un comportement de la sorte.
class LogisticRobot : Robot
{
public LogisticRobot(string id) : base(id) { }
public new void Move(string location)
{
// Roule
}
public void MoveHeavyCharges() { }
}
public static void BringMeCoffee(Robot robot)
{
robot.Move("Salle de pause");
robot.TakeItem("Coffee");
robot.Move("Bureau du chef"); // => RIP :(
return robot.GiveItem();
}
Mais le polymorphisme s’en tape de ça. Il ne regarde que la signature de la fonction sans s’occuper de l’implémentation. Si le robot peut bouger, alors il bougera, même s’il s’agit d’une grue de 40m de haut.
Détruire un entrepôt pour se faire livrer un café, je suis à peu près sûr que ça compte comme une faute grave.
Appelle-moi con ! J’ai déjà prévu le coup. Si le robot ne peut pas accomplir sa tâche, il ne va pas insister.
public static void BringMeCoffee(Robot robot)
{
robot.Move("Salle de pause");
robot.TakeItem("Coffee");
robot.Move("Bureau du chef"); // FallInStairsException
return robot.GiveItem();
}
D’accord, d’accord, excusez-moi. Bon, vous ordonnez à un robot de vous apporter votre café. Manque de bol, l’ordre arrive chez un manitou qui n’arrive pas à passer la porte de la salle de pause.
Peut-être qu’il ne défoncera pas la porte, mais dans tous les cas, vous allez boire votre café froid.
D’autant plus que vous êtes confronté à un autre problème. Vous ne savez pas quel robot a reçu l’ordre, car vous avez laissé la magie du polymorphisme le faire à votre place.
Donc où est votre robot ? Devant la porte ? Écroulé en bas des escaliers ? Enfermé dans les toilettes ? Au fond de l’Atlantique ? Vous n’en savez rien.
C’est le gros problème du polymorphisme. À chaque fois qu’il y a une erreur, vous devez mener l’enquête pour retracer l’objet qui a causé le plantage. Perso, je connais de meilleures manière d’investir son temps.
static void Main(string[] args)
{
Robot[] robots = new Robot[20000];
// Initialisation...
// On prend un robot aléatoire, mais s'il ne vient jamais, comment savoir duquel il s'agit ?
string coffee = BringMeCoffee(robots[new Random().Next(0, robots.Length)]);
}
Et ça, c’est si tenté que le programme plante. Parce que si votre robot accomplit la mission jusqu’au bout, mais qu’il vous a amené une tasse de goudron… Bon, c’est mieux que rien ¯\_(ツ)_/¯
static void Main(string[] args)
{
var lucas = new LogisticRobot("LUCAS#1795");
string coffee = BringMeCoffee(lucas);
Console.WriteLine(coffee); // Goudron
}
public static string BringMeCoffee(Robot robot)
{
robot.Move("Salle de pause");
robot.TakeItem("Coffee");
robot.Move("Bureau du chef");
return robot.GiveItem();
}
Donc rien que pour identifier le problème, c’est déjà la croix et la bannière, mais ça ne s’arrête pas là. Maintenant, il faut le corriger, ce problème.
Plusieurs possibilités s’offrent à vous, mais aucune n’est vraiment viable :
- Vous pouvez modifier le comportement des robots pour qu’ils ne reçoivent pas cet ordre, mais dans ce cas, vous violez le principe ouvert/fermé (OCP), car vous apportez une modification à un module existant.
- Vous pouvez transformer vos manitous en manitous/livreurs de café, mais dans ce cas, déjà vous vous faites vraiment chier pour rien, et en plus, vous violez le principe de responsabilité unique (SRP) et créant des robots multi-fonction (et l’OCP au passage car vous les modifiez).
- Vous pouvez “faire attention” lorsque vous envoyez votre ordre. Éventuellement, ça peut marcher si vous vivez dans un monde de Bisounours où les développeurs sont tous compétents, travailleurs et pètent la forme 24h/24.
T’exagères ! Il n’y a pas besoin de faire tout ça, il suffit de filtrer la requête pour ne garder que les robots qui peuvent effectuer cette tâche. Ainsi, on est sûr que, quoi qu’il advienne, le robot choisi sera en mesure de m’apporter mon café.
static void Main(string[] args)
{
Robot[] robots = new Robot[20000];
Robot myGuy = null;
// Initialisation...
foreach(Robot robot in robots)
{
if(
robot is InternRobot
|| robot is JanitorRobot
|| robot is GardenerRobot
//...On pourrait en mettre d'autres
)
{
myGuy = robot;
break;
}
}
string coffee = BringMeCoffee(myGuy);
}
Bonne idée ! Mais j’ai encore un tour dans mon sac.
Imaginez que vous installez un nouveau robot chargé de changer les ampoules grillées dans l’entrepôt.
La question que l’on se pose tous, c’est bien sûr : est-il capable de vous apporter votre café ?
Si oui, alors ça pose un problème, car vous allez devoir modifier votre filtre pour prendre en compte ce nouveau robot, et donc à violer l’OCP.
foreach(Robot robot in robots)
{
if(
robot is InternRobot
|| robot is JanitorRobot
|| robot is GardenerRobot
|| robot is LightSpecialistRobot // Nouvelle ligne
)
{
myGuy = robot;
break;
}
}
Pas grave, c’est juste une ligne à modifier. Au pire, vous supprimez les journaux du commit, ni vu, ni connu.
Soit. Maintenant, imaginez que vous avez une seconde méthode pour ordonner à un robot de vous apporter votre journal (décidément). Et bien sûr, pour éviter que la même situation se produise, elle aussi soumise à un filtre…
Est-ce que vous commencez à voir où je veux en venir ?
Il n’y pas d’exception possible, si vous êtes amené à violer l’OCP, alors il y a une erreur dans le design de votre programme. C’est aussi simple que ça.
Alors ? Quelque chose à ajouter ? 😜
…Mouais, ça parait quand même tordu comme histoire. Il faut vraiment être con pour se foutre dans une galère pareille.
J’aimerais sincèrement être d’accord avec vous. Cependant, le problème est bien réel, et même très fréquent.
Dès que vous êtes amené à faire du polymorphisme, ça peut vous tomber sur le coin du nez sans que vous vous en rendiez compte. Et je suis bien placé pour parler, car ça m’a coûté des JOURS de travail au total.
Le cas d’école, c’est la généricité. Je ne compte plus le nombre de fois où j’ai planté mon programme en envoyant un type invalide à un module générique.
Les premières fois, je me suis dit que j’avais simplement manqué de vigilance, et que je devais faire plus attention. Mais la vigilance ne suffit pas.
Si mes applications n’étaient pas robustes, c’est qu’il devait me manquer une pièce au puzzle.
Solution
La clef pour résoudre cette énigme, c’est le principe de substitution de Liskov (LSP).
Ce principe dit qu’une fonction utilisant la référence d’un type doit pouvoir utiliser les références de ses sous-types sans les connaître, et sans même savoir qu’ils existent.
Autrement dit, qu’une fonction utilise une instance du type indiqué ou d’un de ses sous-types, le résultat doit être le même.
Notez que je parle de type, et pas forcement de classes. Le principe de substitution de Liskov s’applique sur n’importe quelle technique de polymorphisme (héritage, interface, généricité, fonction d’ordre supérieur…).
C’est là où se trouve l’erreur fondamentale dans notre histoire de robots. Quand on a mis en place la hiérarchie, on a fait que regarder les définitions des fonctions sans même s’occuper de leur implémentation.
Tous les robots peuvent bouger, ça, c’est vrai, mais la façon dont ils bougent est tout aussi importante. S’ils ne bougent pas tous de la même manière, alors ils n’accompliront pas leur mission de la même manière. CQFD.
Il s’agit donc d’une violation du principe de substitution de Liskov. On la reconnaît facilement, car la solution finale était de filtrer les sous-types.
// Preuve ultime d'une violation du principe de substitution de Liskov
if(robot is InternRobot || robot is JanitorRobot || robot is GardenerRobot)
{
//...
}
En revanche, si vous respectez ce principe, vos modules parents n’auront jamais, et je dis bien JAMAIS, à connaître leurs enfants pour fonctionner correctement.
Cela signifie que vous pouvez ajouter des centaines de couches d’abstraction et des milliards de dépendances, ça ne changera strictement rien.
Peu importe la manière dont vous allez torturer vos instances avec du polymorphisme, votre code ne vous lâchera jamais, et votre café arrivera toujours chaud à votre bureau, sans une seule goutte à côté.
Explication
Pour comprendre le principe de substitution de Liskov, il faut d’abord se graver quelque chose dans le crâne : ce n’est pas parce qu’une abstraction est bonne par définition qu’elle l’est à l’implémentation.
Ainsi, il faut hiérarchiser les types par rapport à leurs comportements, et non pas leurs propriétés. Autrement dit, aller plus loin que la simple signature de la fonction.
Le principe de substitution de Liskov est réussi lorsqu’une méthode et toutes ses abstractions produisent les mêmes effets secondaires et agissent sur les mêmes propriétés.
Dans l’exemple des robots, ce n’est clairement pas le cas, car certains des robots peuvent passer la porte, alors que d’autres non. Le résultat n’est pas le même en fonction du type de robot sélectionné.
Cela signifie que la méthode Move() dans la classe de base n’est pas légitime. Il faut faire des méthodes qui représentent exactement le comportement qu’elles décrivent, quitte à n’avoir aucune base commune, et donc à être inutilisables avec du polymorphisme.
Mais du coup, je ne peux pas donner mon ordre ? 😟
Si, mais pas à tous les robots. Ça peut vous sembler moins flexible, mais la stabilité de votre application passe avant tout.
Voici une image qui résume tout le concept :
Application
Le problème avec le principe de substitution de Liskov, c’est que les compilateurs sont peu capables de prédire les erreurs à notre place, car ils se contentent uniquement de regarder la signature des méthodes, sans même s’occuper de leur implémentation.
Donc c’est à vous et votre cerveau aguerri de jouer.
Mais oh, je ne prédis pas l’avenir moi !
Il est vrai que ce principe peut sembler un peu mystique au premier abord. C’est compliqué de prévoir un bug qui va venir nous casser la tête que dans 3 ans.
Mais en réalité, ce n’est pas si compliqué que ça, car il est bien encadré dans une série 7 règles d’or qui, si elles sont respectées, devraient éliminer toute sources d’instabilité liées au polymorphisme.
Contravariance des paramètres des sous-types
Ils ne doivent jamais être d’un type plus spécifique que ceux de leurs parents (mais ils peuvent être du même type).
Si c’est le cas, on prend le risque de passer un objet du mauvais type à la méthode en faisant du polymorphisme.
interface IRobot
{
void GiveItem(IRobot receiver);
}
class LogisticRobot : IRobot
{
public void GiveItem(IRobot receiver) { }
}
Contre-exemple :
Covariance des valeurs de retour des sous-types
Elles ne doivent jamais être d’un type plus générique que celle de leur parent (mais elles peuvent être du même type).
Si c’est le cas, on prend le risque de retourner une valeur d’un type que l’on n’arrivera pas à traiter avec du polymorphisme.
interface IRobot
{
string DropItem();
}
class LogisticRobot : IRobot
{
public string DropItem()
{
string str = "";
//...
return str;
}
}
Contre-exemple :
Covariance des exceptions dans les sous-types
Aucune nouvelle exception doit être balancée dans un sous-type sauf lorsqu’elle est elle-même un sous-type de l’exception balancée dans le parent (ou du même type).
C’est pertinent dans le sens où si l’on décide de traiter les exceptions liées au polymorphisme dans un try… catch…, on risque de tomber sur des exceptions que l’on ne pourra pas attraper.
class Robot
{
public virtual void Move()
{
//...
throw new MovementException("Peut pas bouger :(");
}
}
class LogisticRobot : Robot
{
public override void Move()
{
//...
// DoorTooSmallException hérite de MovementException
throw new DoorTooSmallException("Peut pas passer la porte :(");
}
}
Contre-exemple :
Préconditions jamais renforcées dans les sous-types
Pour faire simple, chaque méthode, quelle qu’elle soit, s’exécute uniquement sous une certaine condition. Typiquement : les paramètres ne doivent pas être null.
Si la précondition est renforcée, cela signifie que certains comportements seront exécutés grâce au polymorphisme, mais ne fonctionneront pas.
C’est ce qu’il se passe avec les robots : pour que la méthode fonctionne correctement avec un manitou, il ne faut pas qu’il y ai de porte sur le trajet.
Contre-exemple :
Notez que la précondition ne se fait généralement pas à l’intérieur de la fonction, mais en dehors à l’appel de celle-ci. Ceci n’est à prendre qu’à titre d’exemple.
Postconditions jamais affaiblies dans les sous-types
Pour faire simple, une certaine condition doit être validée pour que le résultat d’une méthode, quelle qu’elle soit, soit jugé comme correct. Par exemple : le résultat ne doit pas être < 0.
Si la postcondition est affaiblie, cela signifie que la méthode risque de produire un résultat qui ne pourra pas être traité en faisant du polymorphisme.
C’est ce qu’il se passe avec les robots : le manitou à le choix de soit apporter le café, ou soit de s’arrêter si jamais il n’y arrive.
Contre-exemple :
Notez que la postcondition ne se fait généralement pas à l’intérieur de la fonction, mais en dehors à l’appel de celle-ci. Ceci n’est à prendre qu’à titre d’exemple.
Invariances préservées dans les sous-types.
Pour faire simple, certains types mettent en place des conditions afin d’éviter certains changements d’état problématiques. Par exemples : cette propriété ne doit jamais dépasser la valeur 10.
Si l’on retire l’invariance, on ouvre la porte à des potentiels changements d’état et effets secondaires non prévus lorsqu’on fait du polymorphisme.
Par exemple : si on veut que les robots ne dépassent jamais une certaine vitesse, mais que certains ne respectent pas cette règle, ils risquent de renverser votre café en vous l’apportant.
Contre-exemple :
Contrainte historique
C’est à peu près la même chose qu’au-dessus. Une méthode d’un sous-type ne doit jamais venir modifier une propriété immuable de son parent.
Cela risque de causer des instabilités lorsque l’on fait du polymorphisme, car on s’attend à ce qu’une propriété ai une valeur fixe, mais pour certaines instances, ça n’est pas le cas.
Par exemple : si certains robots peuvent modifier la localisation vers laquelle vous les envoyez, vous n’êtes pas prêt de boire votre café.
Contre-exemple :
class Robot
{
public virtual void Move(string location)
{
//...
}
}
class LogisticRobot : Robot
{
public override void Move(string location)
{
//...
if(IsLost())
{
// Pour que la mission s'accomplisse correctement,
// La variable location ne doit JAMAIS être modifiée.
location = "Warehouse entrance";
}
//...
}
}
Les 3 premiers principes sont relativement simples à respecter, car c’est de la logique pure et dure, sans aucune ouverture à interprétation. Si elles ne sont pas respectées, les problèmes sont inévitables.
Les autres sont plus obscures, mais elles restent relativement logiques. Si vous avez du mal à les comprendre, gardez simplement en tête que :
- Tout paramètre d’entrée doit être exploitable par la méthode.
- Toute valeur de retour doit être exploitable à sa sortie.
- Toutes les méthodes doivent travailler sur les mêmes données, et produire les mêmes effets secondaires.
Je vous l’admets, tout ceci semble bien compliqué, d’autant plus que le compilateur ne va pas beaucoup vous aider (au mieux, il vous ferra une petite tape dans le dos).
C’est pour cela qu’encore une fois, il est important de bien tout tester, surtout lorsque vous faites du polymorphisme.
Je vous conseille de vous faire une petite batterie de tests avec quelques instances de tous les sous-types potentiels. C’est un peu long, mais croyez-moi, ça l’est beaucoup moins que le débuggage qui vous attend en cas de problème.
Voilà pour le principe de substitution de Liskov, un concept assez simple à comprendre, mais qui peut s’avérer difficile à respecter tant il est facile de se faire piéger.
Cependant, là où il est ~théoriquement~ possible de faire du code stable sans respecter le SRP et l’OCP (mais ce n’est pas une raison), une violation de ce principe entraîne SYSTÉMATIQUEMENT des problèmes si vous faites du polymorphisme. Et croyez-moi, je parle d’expérience.
Donc retrousser vos manches, engloutissez votre café, et faites chauffer votre cerveau ! Ne vous inquiétez pas, l’investissement sur la qualité et la robustesse de votre codebase vaut le détour.
Le principe de substitution de Liskov (Liskov Substitution Principle) correspond au L de SOLID : un ensemble de 5 concepts fondamentaux pour construire des applications maintenables, pérennes et évolutives.
Vous pouvez retrouver tous mes autres articles sur SOLID ici. Ils ont tous pour but de vous faire découvrir ces principes et leur importance primordiale.
Mais pour résumé et une application concrète des principes SOLID dans un vrai programme, allez lire le bonus de cet article.
Et pour les gens qui raffolent d’architecture logicielle et de bonnes pratiques, voici d’autres articles qui vont vous intéresser :
Quant à moi, je vous laisse, il faut que j’emmène mon chameau chez le vétérinaire, le pauvre souffre d’une tendinite. Je vous donne rendez-vous la semaine prochaine pour un nouvel article sur le blog des développeurs ultra-efficaces !
Accéder au bonus : Résumé et application des principes SOLID.
Afin d'éviter les spams, messages haineux ou insultants envers les autres commentateurs, tous les commentaires sont soumis à modération.