Table of Contents

Conception de systèmes extensibles utilisant le principe de substitution de Liskov

La construction de systèmes extensibles reste un défi persistant dans l'ingénierie logicielle. Au fur et à mesure que les exigences évoluent, la capacité d'ajouter de nouveaux comportements sans réécrire le code existant sépare les architectures maintenables des architectures fragiles. Le principe de substitution de Liskov (LSP) fournit une base rigoureuse pour atteindre cet objectif en définissant des règles précises pour le comportement de sous-type. Lorsqu'il est appliqué correctement, LSP veille à ce que de nouveaux composants puissent être introduits avec confiance, en préservant la justesse dans l'ensemble du système.

Comprendre le principe de la substitution de Liskov

Barbara Liskov a introduit le principe qui porte son nom dans un document de conférence de 1987 intitulé « Data Abstraction and Hiérarchy ».La définition formelle stipule : "Si pour chaque objet o1 de type S il y a un objet o2 de type T tel que pour tous les programmes P définis en termes de T, le comportement de P est inchangé lorsque o1 est substitué à o2, alors S est un sous-type de T." En termes plus simples, les objets d'une classe dérivée doivent se comporter de manière à ce que le contrat de classe de base reste intact. Si un programme fonctionne avec un objet de classe de base, il devrait fonctionner également bien avec tout objet de sous-classe sans produire de résultats inattendus.

Le principe s'étend au-delà des signatures de méthode simples. LSP exige une compatibilité comportementale : la sous-classe doit non seulement avoir les mêmes méthodes mais aussi respecter les hypothèses que le code client fait sur la classe de base. Cela comprend les conditions préalables (ce qui doit être vrai avant d'appeler une méthode), les conditions post-conditions (ce qui doit être vrai après), et les invariants (conditions qui restent constantes tout au long de la vie de l'objet).

Si vous prétendez qu'un est un , alors chaque fonction fonctionnant sur un devrait fonctionner sans changement avec un . La violation classique est le problème rectangle-square, où un s'étend . Un permet une largeur et une hauteur indépendantes; un impose l'égalité. Lorsque le code client fixe la largeur et s'attend à ce que la hauteur reste inchangée, le carré rompt cette attente. Cette violation illustre pourquoi le LSP n'est pas seulement une question de syntaxe mais de sémantique.

Les quatre conditions clés du LSP

Pour garantir la compatibilité du sous-type comportemental, le LSP impose quatre conditions spécifiques que les sous-classes doivent remplir. Ces conditions, dérivées du principe de la conception par contrat, fournissent une liste de contrôle pour l'évaluation des hiérarchies de classes.

Les conditions préalables ne peuvent être renforcées

Si la méthode de la classe de base permet au paramètre d'être un entier, une sous-classe qui limite aux entiers positifs renforce la condition préalable. Le code client écrit contre la classe de base peut passer un entier négatif et s'attendre à ce qu'il fonctionne, mais la sous-classe le rejette. Cela viole le LSP. Les conditions préalables doivent rester les mêmes ou devenir plus faibles dans les sous-classes.

Les conditions post-conditions ne peuvent être affaiblies

Les conditions post-conditions définissent ce que la méthode garantit après exécution. Si la méthode de classe de base garantit une valeur de retour non nulle, une sous-classe qui renvoie parfois null affaiblit la condition post. Les clients qui se fient au contrat de classe de base échoueront avec une exception de pointeur nul. Les sous-classes doivent s'assurer que les conditions post-conditions sont au moins aussi fortes que celles de la classe de base.

Les invariants doivent être conservés

Les invariants sont des conditions qui restent vraies pour la durée de vie de l'objet. Par exemple, un a l'invariant que les éléments sont toujours commandés. Si une sous-classe viole cet invariant (par exemple, en insérant un élément hors de l'ordre), elle brise les attentes du programme.

La contrainte historique

La contrainte d'historique indique que la sous-classe ne doit pas permettre de modifier l'état que la classe de base interdit. Par exemple, si une classe de base n'a pas de méthode de setter, une sous-classe qui ajoute un setter viole la LSP parce que le code client peut supposer l'immutabilité. La contrainte d'historique est souvent négligée mais est critique lorsqu'il s'agit d'objets mutables dans des systèmes orientés objet.

Pourquoi le LSP est essentiel pour l'extensibilité

Lorsque le LSP est honoré, le polymorphisme fonctionne comme prévu. Une nouvelle sous-classe peut être branchée dans un ancien code avec zéro changement. Cela réduit le risque de régression et accélère le développement. Sans LSP, la hiérarchie de la classe de base devient fragile. Les développeurs doivent inspecter chaque sous-classe pour comprendre le comportement spécial, ce qui entraîne des frais de maintenance et un potentiel de bug accru.

Une classe de base définit une méthode . Des sous-classes comme et mettent en œuvre la méthode. Si toutes les sous-classes suivent la méthode LSP, ajouter un nouveau est simple. Mais si une sous-classe lance une exception lorsque le montant dépasse une limite (contrairement à la classe de base qui réussit toujours), alors le code client qui s'attend à la réussite se brisera. Le principe impose un contrat qui rend le système prévisible.

LSP encourage également la conception par contrat, ce qui améliore la documentation et la communication d'équipe. Les développeurs peuvent se fier à la spécification de la classe de base sans lire chaque implémentation de sous-classe. Ceci est particulièrement utile dans les grandes bases de code avec de nombreux contributeurs.

Violations courantes des PSL et comment les éviter

La reconnaissance des violations des PSL est essentielle pour écrire des systèmes de maintenance. Ci-dessous sont des modèles fréquents qui enfreignent le principe, ainsi que des stratégies pour les corriger.

Le problème de la ligne rectangle

Comme mentionné, la modélisation d'un carré comme sous-classe de rectangle viole le LSP parce que le carré limite la largeur et la hauteur pour être égal. Un meilleur design est de faire à la fois rectangle et carré classes séparées qui implémentent une interface partagée , ou d'utiliser une méthode d'usine qui renvoie des objets appropriés.

Sous-classe lance des exceptions inattendues

Si la méthode de classe de base ne déclare aucune exception, une méthode de sous-classe qui lance une exception cochée viole la LSP. Même lancer une exception non cochée comme peut surprendre les clients si la classe de base ne l'a jamais fait. Les sous-classes ne devraient lancer que des exceptions que la classe de base permet, ou aucune du tout. Utilisez des exceptions cochées avec sagesse, et documentez le comportement d'exception dans le contrat de base.

Méthode Dépassement Retours Type de Weaker

Dans des langues comme Java et C#, les types de retour covariant sont autorisés (une méthode de sous-classe peut renvoyer un type plus spécifique). Cependant, l'inverse n'est pas : le retour d'un type plus faible ou moins spécifique rompt le contrat. Par exemple, si la classe de base retourne un , une sous-classe qui retourne un viole le LSP.

Sous-classe Supprime le comportement

Parfois, une sous-classe remplace une méthode avec un corps vide, en supprimant effectivement la fonctionnalité. Si le client compte sur cette méthode ayant un effet, le comportement change. Par exemple, un qui étend une mutable et remplace pour ne rien faire brise le contrat de la classe de base.

Renforcement des conditions préalables

Souvent vus lorsque les méthodes principales qui acceptent des paramètres facultatifs. Si la classe de base accepte pour un paramètre, une sous-classe qui lance une exception sur crée une violation préalable. La documentation indiquant si est autorisée et le maintien de cette réserve dans toutes les sous-classes est essentiel.

Pour éviter les violations, commencez par des interfaces qui définissent des comportements minimes et ciblés. Composition préférée sur l'héritage lorsque la relation "is-a" est discutable. Écrire des tests de contrat qui vérifient à la fois le comportement de base et de sous-classe, et les exécuter en intégration continue.

Application du PSL dans la conception du système

La conception pour le LSP nécessite une réflexion délibérée dans l'architecture et la mise en œuvre. Voici des lignes directrices pratiques à intégrer dans votre workflow de développement.

Utiliser les contrats abstraits

Définir des classes de base ou des interfaces qui expriment le comportement attendu sans implémentation. Inclure la documentation des conditions préalables, postconditions et invariants. Dans les langues qui supportent la conception par contrat (comme Eiffel), vous pouvez les appliquer contractuellement. Dans la plupart des langues courantes, vous pouvez compter sur la documentation et les tests unitaires.

Préférez la composition sur l'héritage

Lorsque la relation entre deux classes n'est pas strictement « is-a », utilisez la composition. Par exemple, au lieu d'une extendant , avoir une classe qui contient une avec des dimensions égales. Cela évite la violation LSP entièrement. La composition a également tendance à produire des systèmes plus flexibles qui sont plus faciles à tester.

Écrire des tests contractuels

Ces tests devraient valider que les conditions préalables, postconditions et invariants sont réunies. Par exemple, un test pour pourrait vérifier que la valeur retournée est positive pour des dimensions données. Toute sous-classe doit passer les mêmes tests pour assurer la conformité au PSL. Cette technique, souvent appelée «essais substituatifs», capture les violations tôt.

Utiliser le sous-typage comportemental

Quand vous hériter, considérez d'abord l'aspect comportemental. Demandez : « Si je remplace une instance de la classe de base par cette sous-classe, les clients remarqueront-ils une différence de comportement ? » Si la réponse est oui, redessinez l'héritage. Suivez le principe de la moindre surprise.

Réfacteurs lorsque des violations sont constatées

Pendant les examens de code ou après les échecs de test, refactorer la hiérarchie. Extraire le comportement commun dans une classe de base ou une interface abstraite, et pousser le comportement spécialisé dans des classes séparées. Utilisez le modèle Méthode template pour s'assurer que les sous-classes suivent un algorithme cohérent tout en permettant des variations dans des étapes spécifiques.

LSP dans les langues de programmation modernes

La façon dont le LSP s'applique varie selon les langues en raison des différences dans les systèmes de dactylographie, les modèles de succession et la gestion des exceptions.

Java et C#

Utilisez les interfaces pour les contrats abstraits et assurez-vous que les classes d'implémentation satisfont à toutes les conditions. Les violations LSP mentionnées plus haut (affaiblissement de l'exception, renforcement des conditions préalables) sont courantes en Java et C#. Utilisez l'annotation en Java ou le mot clé en C# pour éviter les signatures accidentelles de méthode.

Type de script

Le système de typage structurel de TypeScript , qui rend le LSP encore plus critique. Puisque la compatibilité de type est basée sur la structure plutôt que sur la hiérarchie nominale, une classe qui a les mêmes méthodes mais un comportement différent peut être substituable syntaxiquement mais pas comportementalement.

Python

Le Python est dactylographié dynamiquement, ce qui signifie que les violations de LSP ne deviennent apparentes qu'à l'exécution. Sans les vérifications du compilateur, écrivez des tests unitaires robustes et utilisez des classes de base abstraites (ABC) du module pour définir les méthodes requises.

Allez

Go utilise des interfaces implicitement. Un type satisfait une interface s'il implémente toutes les méthodes. LSP in Go est mis en œuvre par le fait que les interfaces sont petites et ciblées. Cependant, soyez prudent: si deux types satisfont la même interface mais se comportent différemment, le code client s'attend à ce que le contrat échoue.

LSP et autres principes SOLID

Le PSL n'existe pas isolément. Il interagit avec les quatre autres principes SOLID de manière importante.

Principe de responsabilité unique (PRS)

SRP aide à garder les classes ciblées, ce qui réduit les chances de violer LSP. Une classe avec une seule responsabilité est plus facile à sous-typer sans changer accidentellement de comportement. Par exemple, séparer la logique de validation du stockage des données rend la base et les sous-classes plus simples.

Principe ouvert/fermé (POC)

OCP déclare que les classes doivent être ouvertes pour extension mais fermées pour modification. LSP permet à OCP en permettant aux sous-classes d'étendre le comportement sans modifier le code existant. Si LSP est violé, vous ne pouvez pas ajouter de nouvelles sous-classes en toute sécurité sans changer de clients, brisant ainsi OCP. Les deux principes sont étroitement liés.

Principe de séparation des interfaces (PSI)

Les interfaces de gras sont ainsi plus petites et plus spécifiques. Cela réduit la probabilité qu'une sous-classe mette en œuvre des méthodes qui ne sont pas pertinentes, ce qui entraîne souvent des violations de LSP (p. ex., des implémentations vides ou de lancer).

Principe d'inversion de la dépendance (DIP)

Lorsque vous dépendez d'interfaces, LSP veille à ce que toute implémentation concrète puisse être remplacée librement. Sans LSP, la couche d'abstraction devient indigne de confiance et les développeurs peuvent dépendre directement des implémentations, violant DIP.

Essais de conformité aux PSL

La vérification du LSP n'est pas toujours simple. Cependant, les méthodes de test systématiques peuvent aider à attraper les violations tôt. Voici des stratégies pour intégrer le test LSP dans votre workflow.

Créer une classe d'essai de contrat de base

Écrire une classe de test abstraite ou une suite de test qui exerce le contrat de classe de base. Pour chaque condition préalable et post-condition, écrire un test. Par exemple, si la méthode de classe de base sur un lance une exception lorsque la pile est vide, inclure un test qui vérifie cela. Ensuite, pour chaque sous-classe, exécuter les mêmes tests. Si une sous-classe échoue, elle viole le LSP.

Utiliser les tests basés sur les biens

Des outils comme QuickCheck (pour Haskell, également disponible dans d'autres langues via des bibliothèques comme ou ) génèrent des entrées aléatoires et vérifient que les invariants détiennent. Pour LSP, vous pouvez exprimer des propriétés comme: "Pour toute séquence valide d'appels de méthode, l'état après avoir appelé une méthode sur la sous-classe correspond au comportement de la classe de base."

Exécution des invariants comportementaux

En Java, vous pouvez utiliser le mot-clé ou une bibliothèque comme . En C#, ou . Ces vérifications permettent de vérifier les invariants et les conditions préalables pendant le développement, en captant les violations tôt.

Exemples réels de PTS en action

De nombreuses bibliothèques et cadres standard comptent sur le LSP pour fonctionner correctement. La compréhension de ces exemples approfondit l'appréciation du principe.

Cadre de collections Java

L'interface définit le comportement des collections ordonnées. Des sous-classes comme , et `CopyOnWriteArrayList` adhèrent tous au contrat d'interface. Elles supportent , , , etc., avec la sémantique attendue. Si une nouvelle implémentation viole LSP (par exemple, en ne permettant pas sans documentation), elle briserait le code existant qui repose sur comportement.

Couches d'accès aux bases de données

Lors de la mise en œuvre des dépôts de bases de données, une interface de base définit des méthodes comme et . Les implémentations concrètes pour MySQL, PostgreSQL et le stockage in-memory suivent le même contrat. LSP s'assure que l'échange de la base de données sous-jacente ne modifie pas la logique d'application.

Pipelines de traitement des flux

Dans la programmation fonctionnelle, les transformations sur les flux (comme la carte, le filtre, la réduction) sont définies par des contrats. Toute fonction passée à doit être une transformation pure préservant l'invariant du flux (par exemple, ne modifiant pas l'état externe).

Conclusion

Le principe de substitution de Liskov est plus qu'un concept théorique, c'est un outil pratique pour concevoir des logiciels extensibles et durables. En s'assurant que les sous-classes peuvent se positionner pour leurs classes de base sans modifier le comportement correct, les développeurs construisent des systèmes qui se développent organiquement avec de nouvelles fonctionnalités.

Pour appliquer efficacement le LSP, vous devez vous concentrer sur les contrats comportementaux, rédiger des tests complets et utiliser la composition lorsque l'héritage se sent forcé. Reconnaître les quatre conditions – conditions préalables, postconditions, invariants et contraintes historiques – comme des vérifications pour chaque nouvelle sous-classe.

Pour plus de détails, explorez l'article original de Barbara Liskov (Abstraction et hiérarchie de données), Robert C. Martins discussion des principes SOLID, et Martin Fowlers article sur la substituabilité.Ces ressources fournissent des informations plus approfondies sur la façon dont le LSP fait partie de votre métier de logiciel.