📖 Cet article fait partie de la série sur le thème SOLID
Continuons notre exploration des principes SOLID agrémentés d'exemples en TypeScript. Après avoir vu ensemble le Single Responsibility Principle (SRP) et le Open/Closed Principle (OCP), nous nous interessons aujourd'hui au Liskov Substitution Principle (LSP).
Formulé par Barbara Liskov et Jeanette Wing en 1994, ce principe définit les conditions pour qu'une classe dérivée puisse être utilisée de manière transparente à la place de sa classe de base.
En d'autres termes, il garantit que les sous-classes peuvent être substituées aux super-classes sans affecter le comportement attendu du programme 🤩
Définition du principe
La définition la plus évidente du principe de substitution de Liskov est la suivante :
Voilà, c'est tout pour aujourd'hui ! Merci d'avoir lu cet article, à bientôt ! 👋
La première fois que j'ai entendu parler de ce principe, c'était dans le cadre d'une conférence sur youtube dans lequel le speaker illustrait le principe à l'aide de cette formule on ne peut plus limpide. Après avoir contemplé pendant quelques secondes l'idée un changement radical de carrière afin de devenir berger de moyenne montagne, je me suis dit qu'il y avait forcément une définition plus abordable.
En voici une tentative:
Si S est un sous-type 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
Si ce n'est toujours pas clair, un exemple valant mieux qu'un long discours, nous allons voir ensemble ce que ça donne avec quelques illustrations en TypeScript.
Un premier exemple théorique
L'exemple suivant ne brille pas par son originalité, c'est en général celui qu'on donne pour illustrer le LSP. Il a cependant le mérite d'être simple et efficace.
Supposons que nous ayons une hiérarchie de classes représentant des formes géométriques, avec une classe
Shape
comme classe de base, et des classes dérivées Rectangle
et Circle
abstract class Shape {
abstract getArea(): number;
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
}
getArea(): number {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
getArea(): number {
return Math.PI * Math.pow(this.radius, 2);
}
}
Dans cet exemple, la classe Shape
est une classe abstraite qui définit une méthode getArea()
permettant de
calculer l'aire d'une forme. Les classes Rectangle
et Circle
sont des sous-classes de Shape
et
implémentent la méthode getArea()
en fonction de leur propre logique interne.
Grâce au principe de substitution de Liskov, nous pouvons utiliser une instance de Rectangle
ou de Circle
partout où une instance de Shape
est attendue, sans altérer le comportement du programme. Par exemple :
function printShapeArea(shape: Shape) {
console.log(`Area: ${shape.getArea()}`);
}
const rectangle = new Rectangle(5, 3);
const circle = new Circle(2);
printShapeArea(rectangle); // 15
printShapeArea(circle); // ~12.566
Dans cet exemple, la fonction printShapeArea
accepte un paramètre de type Shape
. Que la shape passée soit
une instance de Rectangle
ou de Circle
, le comportement de notre fonction sera toujours le même : nos deux
classes sont bien permutables.
👉 Le principe de substitution de Liskov est respecté ici car les sous-classes ne font pas que respecter le contrat en terme de signature (de la méthode
getArea()
), elles en respectent aussi le comportement attendu.
Bien, maintenant que nous avons vu un exemple théorique et un poil rébarbartif 🥱, voyons ce que ça donne dans un cas plus concret.
Un exemple concret
Une fois n'est pas coutume, je vais prendre l'exemple de la création d'un jeu vidéo pendant le développement duquel nous allons rencontrer un problème de conception. Nous allons voir comment le LSP peut nous aider à le résoudre.
Nous sommes en pleine phase de conception de notre super et original beat-them-all médieval qui répond pour le moment au doux nom de code BIM, DANS LES DENTS 👊.
Nous n'en sommes qu'au prémices du développement de ce futur Game of the year, mais avons déjà modélisé un
premier type d'unité : les Warrior
avec deux premières implémentations : Soldier
et Knight
.
Ces deux ennemis n'ont pour l'instant que deux types d'actions possibles : attack()
et parry()
. Voici à
quoi ressemble le code pour l'instant :
abstract class Warrior {
abstract attack(): void;
abstract parry(): void;
}
class Soldier extends Warrior {
attack() {
console.log('Soldier attacks');
}
parry() {
console.log('Soldier parries');
}
}
class Knight extends Warrior {
attack() {
console.log('Knight attacks');
}
parry() {
console.log('Knight parries');
}
}
PLutôt stylé !
Ceci étant, nous ne reculons devant rien, et introduisons un troisième type de Warrior
: le sauvage et
redoutable Berserker
!
Ce guerrier n'a pas froid aux yeux ! Il attaque sans relache et sème la terreur dans les rangs ennemis. Il n'a cependant pas de bouclier. Pas de problème, nous avons pu gérer cette spécificité dans ses internals :
class Berserker extends Warrior {
attack() {
console.log('Berserker attacks');
}
parry() {
throw new Error('Berserker cannot parry');
}
}
Forts satisfaits de notre travail, nous continuons à implémenter les autres features de notre jeu. Par
exemple, nous avons mis en place une petite feature de tutorial qui explique les bases du jeu en faisant
combattre différents types de Warrior
:
function fight(warrior1: Warrior, warrior2: Warrior) {
warrior1.attack();
warrior2.parry();
}
Cette fonction prend en paramètre deux Warrior
et fait en sorte que le premier attaque et que le second
riposte. Nous pouvons donc l'utiliser pour faire combattre nos deux premiers Warrior
:
const soldier = new Soldier();
const knight = new Knight();
fight(soldier, knight); // Soldier attacks, Knight parries
Mais que se passe-t-il si nous essayons de faire combattre notre Berserker
contre notre Knight
?
const knight = new Knight();
const berserker = new Berserker();
fight(berserker, knight); // Knight attacks, ❌ Error : Berserker cannot parry 😱
En effet, notre Berserker
ne peut pas parer les attaques de son adversaire puisqu'il n'a pas de bouclier !
Nous avons donc un problème de conception. Nous avons une classe Berserker
qui ne respecte pas le contrat
comportemental défini par la classe de base Warrior
.
💡 C'est une violation du LSP :
Berserker
etKnight
ne peuvent pas être substituées l'une par l'autre. La cause : la classeWarrior
ne devrait pas déclarer une méthodeparry()
si toutes les classes dérivées ne sont pas censées l'implémenter.
Il existe plusieurs façon de résoudre ce problème, la plus évidente étant de créer une nouvelle classe
abstraite / interface ShieldedWarrior
qui étend Warrior
et qui déclare la méthode parry()
:
abstract class ShieldedWarrior extends Warrior {
abstract parry(): void;
}
class Soldier extends ShieldedWarrior {
attack() {
console.log('Soldier attacks');
}
parry() {
console.log('Soldier parries');
}
}
class Knight extends ShieldedWarrior {
attack() {
console.log('Knight attacks');
}
parry() {
console.log('Knight parries');
}
}
class Berserker extends Warrior {
attack() {
console.log('Berserker attacks');
}
}
Cela nous permettrait de s'assurer statiquement que seules des instances de ShieldedWarrior
sont passées à
la fonction fight()
:
function fight(warrior1: ShieldedWarrior, warrior2: ShieldedWarrior) {
warrior1.attack();
warrior2.parry();
}
Exemples de violation du LSP
Certaines violations courantes du principe de substitution de Liskov peuvent se produire lorsqu'une sous-classe ne respecte pas les contrats de la classe de base ou introduit des comportements inattendus :
-
Changement du comportement de la classe de base : Si une sous-classe modifie le comportement attendu de la classe de base.
-
Exceptions non gérées : Si une sous-classe lance des exceptions qui ne sont pas déclarées dans la classe de base ou ses supertypes. Les exceptions non gérées peuvent perturber le flux normal du programme et entraîner des erreurs inattendues.
-
Dépendance aux détails d'implémentation : Si une sous-classe dépend des détails d'implémentation de la classe de base, cela peut entraîner une violation du LSP. Les sous-classes doivent être capables de se substituer à la classe de base sans connaître ni dépendre de ses détails internes.
-
Retour de valeurs incompatibles : Si une sous-classe renvoie un type de valeur incompatible avec celui défini dans la classe parente. Par exemple, si la classe de base définit une méthode renvoyant un type spécifique, une sous-classe ne doit pas renvoyer un sous-type différent ou un type complètement différent.
Conclusion
En respectant le principe de substitution de Liskov (LSP) dans nos conceptions logicielles, nous garantissons la cohérence et la flexibilité de notre code. En permettant à des sous-classes d'être substituées à leurs classes de base sans altérer le comportement attendu du programme, nous facilitons l'extensibilité, la maintenabilité et la réutilisabilité de notre code.
En utilisant TypeScript, nous pouvons appliquer le LSP de manière efficace en créant des hiérarchies de classes où les sous-classes respectent les contrats et les comportements définis par leurs classes de base. Cela nous permet de créer des systèmes plus modulaires, où de nouvelles fonctionnalités peuvent être ajoutées sans avoir à modifier le code existant.
En conclusion, en appliquant le principe de substitution de Liskov dans notre développement, nous favorisons une conception solide et cohérente. Cela conduit à des systèmes plus flexibles, extensibles et robustes, où les composants peuvent être facilement combinés et réutilisés, ouvrant la voie à une évolution harmonieuse de nos applications.