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 :

Yes

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.

Nos deux premiers ennemis

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 !

Le redoutable guerrier 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 et Knight ne peuvent pas être substituées l'une par l'autre. La cause : la classe Warrior ne devrait pas déclarer une méthode parry() 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 :

  1. Changement du comportement de la classe de base : Si une sous-classe modifie le comportement attendu de la classe de base.

  2. 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.

  3. 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.

  4. 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.