Cette article est la suite d’une série de plusieurs articles sur les principes SOLID (je vous invite à relire l’article Conception d’applications SOLID pour en savoir plus). L’article précédent était consacré à OCP (Open Closed Principle).
Principe de Substitution de Liskov (LSP: The Liskov Substitution Principle)
Présentation de LSP
A l’origine, ce principe a été énoncé par Barbara Liskov et Jeannette Wing.
LSP est une définition particulière concernant les sous-types d’une classe. Il a été exprimé de manière plus simple par Robert C. Martin de la manière suivante : « Tout type de base doit pouvoir être remplacé par ses sous-type ».
Ce principe peut aussi être expliqué grâce à la conception par contrat : il impose que les pré-conditions ne peuvent pas être renforcées dans une sous-classe et que les post-conditions ne peuvent pas être affaiblies dans une sous-classe.
En utilisant ce principe, le code ne devra pas dépendre de la hiérarchie des classes. Par conséquent, il ne devra pas utiliser de cast ou d’opérateur « as » ou « is ».
Comment mettre en pratique LSP ?
Pour le mettre en pratique et le vérifier, il suffit de se demander si une classe peut facilement être remplacée par une classe de base.
L’exemple présenté sur Wikipedia (lien) est très explicite :
Imaginons qu’une classe Carre hérite de Rectangle. Rectangle contient une propriété Height (hauteur) et une proprité Width (largeur).
Dans un carré, il est indiqué que les côtés ont tous la même taille. Or, si vous appeler une méthode en passant un Carre en tant que Rectangle alors cette méthode pourra modifier les propriétés du carré. Par exemple, la largeur pourra être différente de la hauteur, ce qui fera que le carré devient inconsistant.
La solution la plus simple pour respecter le principe LSP serait alors de proposer uniquement des getters.
En procédant de la sorte, le carré serra toujours valide.
Un exemple concret
Cette fois-ci, je vous propose un exemple tiré de la vie réelle.
Nous avons une classe nommée Oiseau qui contient deux méthodes : Manger et Voler.
Nous démarrons l’écriture du code et créons une classe Canari et une classe Autruche qui héritent toutes les deux de Oiseau.
Voici le code correspondant :
[csharp] public class Oiseau{
public virtual void Manger()
{
Console.WriteLine("Je suis un oiseau et je mange…" );
}
public virtual void Voler()
{
Console.WriteLine("Je suis un oiseau et je vole…" );
}
}
public class Canari: Oiseau
{
public override void Manger()
{
Console.WriteLine("Je suis un canari et je mange…" );
}
}
public class Autruche: Oiseau
{
public override void Voler()
{
throw new NotSupportedException();
}
}
class Program
{
static void Main(string [] args)
{
List <Oiseau> oiseaux = new List <Oiseau>();
oiseaux.Add(new Canari ());
oiseaux.Add(new Autruche ());
foreach (var oiseau in oiseaux)
oiseau.Voler();
}
}
[/csharp]
En analysant ce code, vous remarquerez tout de suite qu’il y a un problème. Les autruches ne savent pas voler et pourtant ce sont des oiseaux.
Pour vérifier si notre code respecte le principe de Substitution de Liskov, il suffit d’analyser la classe Canari et la classe Autruche.
Est-ce que le type Canari peut être remplacé par un type Oiseau dans tous les cas ? Oui
Est-ce que le type Autruche peut être remplacé par un type Oiseau dans tous les cas ? Non, à cause de la méthode Voler qui génère une exception.
Il va donc falloir corriger cela.
Refactoring: amélioration de l’héritage
Pour résoudre ce problème et respecter LSP, il y a une solution très simple : il suffit de créer une classe intermédiaire pour différencier ce comportement.
Nous allons donc mettre en place une classe OiseauQuiSaitVoler et une classe OiseauQuiNeSaitPasVoler.
La classe Autruche va alors hériter de OiseauQuiNeSaitPasVoler.
Le code final sera comme ceci :
[csharp] public class Oiseau{
public virtual void Manger()
{
Console.WriteLine("Je suis un oiseau et je mange…" );
}
}
public class OiseauQuiSaitVoler: Oiseau
{
public virtual void Voler()
{
Console.WriteLine("Je suis un oiseau et je vole…" );
}
}
public class OiseauQuiNeSaitPasVoler : Oiseau
{
}
public class Autruche: OiseauQuiNeSaitPasVoler
{
public override void Manger()
{
Console.WriteLine("Je suis une autruche, je sais manger mais pas voler…");
}
}
public class Canari: OiseauQuiSaitVoler
{
public override void Manger()
{
Console.WriteLine("Je suis un canari et je mange…" );
}
}
class Program
{
static void Main(string [] args)
{
List <OiseauQuiSaitVoler> oiseaux = new List <OiseauQuiSaitVoler>();
oiseaux.Add(new Canari ());
oiseaux.Add(new Canari ());
foreach (var oiseau in oiseaux)
oiseau.Voler();
}
}
[/csharp]
Conclusion
Nous venons de voir le principe de Substitution de Liskov. Vous avez pu constater que le principe est simple à comprendre. Dans le quotidien du développeur, il n’est pourtant pas toujours simple à mettre en oeuvre.
Pensez donc toujours à faire attention aux cast, aux opérateurs « as » ou « is » et faites attention à ne pas endommager la compatibilité avec une sous-classe.
Vous pouvez télécharger de démonstration le projet à l’adresse suivante : LspDemo
Pour continuer dans cette série, je vous invite à lire le prochain article sur ISP (Interface Seggration Principle).
Je crois que la définition en début d’article est inversée. En effet, si on reprend l’article wikipedia, la définition dit
if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S
Ce qui donne en traduction que tout objet de type T (le type de base) doit pouvoir être remplacé par un type S (un type dérivé de T). Le définition devrait donc être « tout type de base doit pouvoir être remplacé par ses sous-type. »
Merci Philippe, vous avez raison. J’ai corrigé l’article.
Merci Pascal pour cette série d’articles. Restant dans la même logique de la définition de ISP, je pense que les questions : « Est-ce que le type Canari peut être remplacé par un type Oiseau dans tous les cas ? » et « Est-ce que le type Autruche peut être remplacé par un type Oiseau dans tous les cas ? » sont inversées.