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