• 4 heures
  • Facile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 15/02/2023

"L" pour Liskov Substitution Principle ou principe de substitution de Liskov

Principe de substitution de Liskov (LSP)

Le principe de substitution de Liskov tire son nom de Barbara Liskov, l'informaticienne qui l'a défini.

Barbara Liskov (source: Wikimedia Commons)
Barbara Liskov (source: Wikimedia Commons)

Elle a présenté ce principe lors de sa conférence sur l'abstraction des données de 1987. Quelques années plus tard, elle a publié un article (article en anglais) co-écrit avec Jeannette Wing, dans lequel les deux femmes ont défini ce principe comme suit : 

Si Φ(x) est une propriété démontrable pour tout objet x de type T, alors Φ(y) est vraie pour tout objet y de type S tel que S est un sous-type de T.

Je vous l'accorde, cette définition est un peu trop scientifique. Que signifie concrètement ce principe ?

Il veut en réalité dire que les objets d'une classe enfant doivent pouvoir remplacer les objets d'une classe parent sans nuire à l'intégrité de l'application.

Tout code appelant des méthodes sur des objets d'un type spécifique doit rester fonctionnel lorsque ces objets sont remplacés par des instances d'un sous-type.

Mais qu'est-ce qu'un sous-type ?

Un sous-type est soit une classe qui vient étendre une autre classe, soit une classe qui implémente une interface.

Prenons un exemple concret. Commençons avec une simple calculatrice :

public class CalculatriceSomme
{
   protected readonly int[] _nombres;

   public CalculatriceSomme(int[] nombres)
   {
      _nombres = nombres;
   }

   public int Calculer() => _nombres.Sum();
}

Lorsque la classe  CalculatriceSomme  est instanciée, elle a besoin d'un array d'entiers. Lorsque la méthode  Calculer  est appelée, elle renvoie la somme des entiers de cet array.

Faisons maintenant hériter la classe  CalculatriceSommeNombrePairs  de la classe  CalculatriceSomme  , qui joue le rôle de sous-type :

public class CalculatriceSommeNombrePairs: CalculatriceSomme
{
   public CalculatriceSommeNombrePairs(int[] nombres)
      :base(nombres)
   {
   }

   public new int Calculer() => _nombres.Where(x => x % 2 == 0).Sum();
}

Utilisons maintenant ces deux classes dans l'application console :

public class Programme
{
   static void Main(string[] args)
   {
 int[] nombres = new int[] { 5, 7, 9, 8, 1, 6, 4 };
 
      CalculatriceSomme somme = new CalculatriceSomme(nombres);
      Console.WriteLine($"Somme de tous les nombres : {somme.Calculer()}");

      CalculatriceSommeNombrePairs sommePairs = new CalculatriceSommeNombrePairs(nombres);
      Console.WriteLine($"Somme de tous les nombres pairs : {sommePairs.Calculer()}");
   }
}

Le résultat obtenu est le suivant :

Somme de tous les nombres : 40.
Somme de tous les nombres pairs : 18.

Parfait ! Ça marche !

Mais si ces résultats sont corrects, quel est le problème ?

Selon le principe de substitution de Liskov, une classe enfant doit pouvoir remplacer sa classe parent. Nous devrions donc pouvoir remplacer  CalculatriceSommeNombrePairs  par  CalculatriceSomme  sans que rien ne change.

Modifions donc la variable  sommePairs  de la méthode  Main  :

CalculatriceSomme sommePairs = new CalculatriceSommeNombrePairs(nombres);

Le résultat obtenu est le suivant :

Somme de tous les nombres pairs : 40.

Pardon ? 🤔

Si le résultat est 40 et non 18, c'est parce que la variable  sommePairs  est de type  CalculatriceSomme  . Or, cette classe joue le rôle de classe de base. Par conséquent, c'est la méthode  Calculer  de  CalculatriceSomme  qui sera exécutée.

Cela ne fonctionne pas, car la classe enfant,  CalculatriceSommeNombrePairs  , ne se substitue pas à sa classe parent,  CalculatriceSomme  .

Vous pouvez corriger ce problème en modifiant légèrement les deux classes avec les modificateurs  virtual  et  override  . Rappelez-vous, le modificateur  virtual  indique qu'une méthode, une propriété, un indexeur ou un événement peut être remplacé. Une classe dérivée peut donc ensuite utiliser le modificateur  override  .

Dans la classe  CalculatriceSomme  , modifiez la méthode  Calculer  comme suit :

public virtual int Calculer() => _nombres.Sum();

Ensuite, ajoutez le modificateur override à la méthode  Calculer  de la classe  CalculatriceSommeNombrePairs  :

public override int Calculer() => _nombres.Where(x => x % 2 == 0).Sum();

La méthode  Calculer  de la classe enfant remplace désormais la classe parent. Ainsi, si vous exécutez de nouveau le programme avec la ligne :

CalculatriceSomme sommePairs = new CalculatriceSommeNombrePairs(nombres);

La valeur retournée sera 18 et non plus 40, car la méthode  Calculer  de la classe  CalculatriceSommeNombrePairs  remplace celle de la classe parent  CalculatriceSomme  .

Parfait ! Est-ce que nous avons fini ?

Pas tout à fait. Malheureusement, le comportement de la classe dérivée  CalculatriceSommeNombrePairs  a changé et elle ne peut pas remplacer  CalculatriceSomme  , la classe de base.

Dans le chapitre précédent, nous avons découvert les contrats, qui sont en réalité des classes abstraites et des interfaces. J'ai utilisé une interface pour illustrer le principe ouvert/fermé. Dans cet exemple, je vais utiliser une classe abstraite pour coder une classe de base optimisée dans laquelle les classes abstraites sont généralement utilisées.

public abstract class Calculatrice
{
   protected readonly int[] _nombres;

   public Calculatrice(int[] nombres)
   {
      _nombres = nombres;
   }

   public abstract int Calculer();
}

Mais, on dirait la classe  CalculatriceSomme  , non ?

Oui, car j'utilisais la classe  CalculatriceSomme  comme une classe de base. Mais, j'ai modifié la méthode  Calculer()  en ajoutant le modificateur abstract, qui est remplaçable. Souvenez-vous, dans le chapitre précédent, j'ai expliqué qu'une classe abstraite devait disposer d'au moins une méthode abstraite.

Faisons maintenant en sorte que les classes  CalculatriceSomme  et  CalculatriceSommeNombrePairs  héritent de  Calculatrice  , la nouvelle classe de base :

public class CalculatriceSomme: Calculatrice
{
   public CalculatriceSomme(int[] nombres)
      :base(nombres)
   {
   }

   public override int Calculer() => _nombres.Sum();
}

public class CalculatriceSommeNombrePairs: Calculatrice
{
   public CalculatriceSommeNombrePairs(int[] nombres)
      :base(nombres)
   {
   }
   
   public override int Calculer() => _nombres.Where(x => x % 2 == 0).Sum();
}

Mettez à jour l'application console :

public class Programme
{
   static void Main(string[] args)
   {
 int[] nombres = new int[] { 5, 7, 9, 8, 1, 6, 4 };

      Calculatrice somme = new CalculatriceSomme(nombres);
      Console.WriteLine($"Somme de tous les nombres : {somme.Calculer()}");

      Calculatrice sommePairs = new CalculatriceSommeNombrePairs(nombres);
      Console.WriteLine($"Somme de tous les nombres pairs : {sommePairs.Calculer()}");
   }
}

Le résultat est identique :

Somme de tous les nombres : 40.
Somme de tous les nombres pairs : 18.

Comme vous le voyez, vous pouvez insérer toute référence à une sous-classe (  CalculatriceSomme  ou  CalculatriceSommeNombrePairs  ) dans une variable de classe de base (  Calculatrice somme ,  Calculatrice sommePairs  ) sans qu'il y ait changement de comportement.

La fonctionnalité reste intacte, et les sous-classes continuent de jouer le rôle de substitut d'une classe de base.

En résumé 

  • Les objets d'une classe enfant doivent pouvoir remplacer les objets de la classe parent.

  • Un sous-type est soit une classe qui vient étendre une autre classe, comme une classe abstraite, soit une classe qui implémente une interface.

  • Une classe abstraite sert souvent de classe de base.

  • L'appel de méthodes sur des objets d'un type spécifique doit rester fonctionnel lorsque ces objets sont remplacés par les instances d'un sous-type.

Maintenant que nous avons vu le principe "L", passons à la lettre "I", qui renvoie au principe de ségrégation des interfaces.

Exemple de certificat de réussite
Exemple de certificat de réussite