12 oct. 2006

Le principe de substitution de Liskov

Après avoir eu un bref aperçu du principe d'encapsulation, du principe de responsabilité unique (SRP, pour single responsability principle) et du principe "ouvert/fermé" (OCP, pour open/closed principle), il ne nous reste que peu de principes à étudier. Parmi eux se trouve le principe de substitution de Liskov (LSP, pour Liskov substitution principle), un principe souvent décrit comme corolaire de OCP et énoncé par deux femmes - Barbara Liskov et Jeannette Wing[1] - fait assez rare dans une communauté fortement dominée par les hommes pour être noté.

Notes

[1] le rapport technique qui énonce ce principe est disponible en téléchargement sur citeseerx.ist.psu.edu

Pourquoi ?

Ce principe s'attache à définir les règles d'utilisation d'un sous-type, et décrit par conséquent les règles de construction des classes dérivées.

Dans un langage orienté objet, faire d'une classe l'ancêtre d'une autre peut avoir deux raisons : dans un cas, on peut utiliser l'héritage dans le but de limiter la réécriture de code. Cette vision très réductrice (voire dangereuse) de l'héritage est bien évidemment à éviter, car elle ne modélise pas la notion d'identité mais tente d'anticiper au niveau de la conception un ensemble de problèmes liés à l'implémentation. Il est dans ce cas préférable d'utiliser la composition plutôt que l'héritage - ce sujet pourra faire l'objet d'un billet ultérieur. L'autre but de l'héritage est d'étendre ou de modifier le comportement d'une classe mère tout en préservant la notion d'identité de celle-ci. Cette notion est représentée par un lien du type "is-a" et permet de définir une abstraction à partir de la définition de plusieurs cas concrets similaires (par exemple, un chat et un chien sont des animaux). On utilise alors cette abstraction en lieu et place des classes concrètes, de manière à rendre la conception plus générique et espérer ainsi transformer les cas concrets en détails d'implémentation.

Un problème se pose toutefois lorsqu'on redéfinit le comportement d'une méthode virtuelle. Supposons que dans mon framework, je définisse une classe Rectangle. Plus tard, un client hérite de cette classe pour créer une classe Carre (j'ai déjà utilisé cet exemple dans un billet antérieur) :

class Rectangle
{
   int width, height;
public:
   virtual void setExtent(int w, int h) { width = w; height = h; }
}
class Carre : public Rectangle { public: virtual void setExtent(int w, int h) { if (w != h) throw std::exception("w !=h"); Rectangle::setExtent(w, h); } };

Le code du framework utilise de manière intensive la classe Rectangle, par exemple dans cette fonction :

void setRectangeExtent(int length)
{
   leRectangle->setExtent(length, length * 2);
   assert(leRectangle->getArea() == (length*length*2));
}

Le problème est simple : si leRectangle est en fait une instance de la classe Carre, ce code génèrera une exception, qui sera propagée plus en amont jusqu'à retrouver un gestionnaire d'exception. Mon framework n'a pas prévu cela - et c'est légitime, puisque ma classe Rectangle n'était pas supposée générer d'exceptions. On peut bien évidemment corriger le problème en évitant de générer une exception, mais il faut dans ce cas mettre de coté l'une des deux valeurs afin de s'assurer que l'invariant de la classe Carre reste cohérent. Là, un autre problème se pose puisque la post-condition de ma fonction vérifie l'aire du rectangle - qui n'a aucune chance d'être égale à length*length*2 si le rectangle est en fait un Carre[1].

Entrées et sorties : pré-conditions et post-conditions

Ce point à propos de la post-condition est un point important. Meyer[2] a très justement formulé la façon dont les pré-conditions et post-conditions peuvent être modifiées lorsqu'on redéfinit le comportement d'une fonction. Selon lui (paraphrasé),

Lorsqu’on redéfinit une fonction, une pré-condition ne peut être remplacée que par une plus faible, et une post-condition ne peut être remplacée que par une plus forte

Son raisonnement est simple : le contrat d'une classe est la seule chose qu'un utilisateur est censé connaitre. Ce contrat est explicité d'une part par l'interface de la classe et d'autre part par l'ensemble des pré-conditions et des post-conditions utilisées. Si une classe dérivée renforce les pré-conditions, alors le client ne peut pas l'utiliser - car certaines valeurs parmi l'ensemble des valeurs d'entrée possible ne sont plus valides, et l'utilisateur n'a aucun moyen de le savoir. Si cette classe dérivée rend les post-conditions moins fortes, les sorties peuvent prendre des valeurs non attendues par l'utilisateur.

Dans notre cas, si on veut que le code fonctionne, il nous faut supprimer la post-condition. Clairement, il y a là un problème de logique.

Retour sur le principe

Une instance de Carre ne peut pas être substituée à une instance de Rectangle. Pour que cela soit possible, il faudrait modifier le code existant - ce qui n'est pas souhaitable, et qui montre en outre un lien entre ce problème et OCP.

Ainsi que l'a justement remarqué Robert C. Martin[3], le sous-typage est une question de redéfinition de comportement - non pas du comportement intrinsèque, mais du comportement tel qu'il est visible de l'extérieur de la classe, et sur lequel les clients peuvent se baser. C'est cela, le contrat d'une classe. Modifier ce comportement "externe" - c'est à dire modifier le contrat de la classe - c'est introduire des myriades de cas particuliers qui ne sont identifiables que par l'inspection du type de l'objet manipulé. Une telle différenciation est vouée à l'échec, puisqu'il est toujours possible d'écrire un nouveau cas particulier qui n'aura pas été pris en compte auparavant. Bien évidemment, OCP est inapplicable dans ce cas, et l'encapsulation devient toute relative.

Conclusion

Le Principe de Substitution de Liskov cible ce type de problème, en définissant la façon dont les sous-types doivent se comporter par rapport à leur parent. Encore une fois, l'exemple que j'ai choisi montre que le comportement d'un Carre est différent du comportement d'un Rectangle - la classe Carre ne dérive donc pas de Rectangle.

La formulation formelle de ce principe est la suivante[4]:

Soit q(x) une propriété prouvable à propos des objets x de type T. Alors q(y) doit être vrai pour les objets y de type S lorsque S est un sous-type de T.

Barbara Liskov et al. se sont employés à définir un sous-type en fonction de la façon dont il se comportait - la formulation du document cité plus haut est donc tournée dans l'autre sens. Je vous invite à la lire, ainsi que le document associé.

Plus simplement, on peut définir le Principe de Substitution de Liskov avec ces quelques mots :

Une fonction qui utilise un objet d'une classe mère doit pouvoir utiliser toute instance d'une classe dérivée sans avoir à le savoir.

Notes

[1] je fais ici abstraction du fait que j'ai déjà démontré que la classe Carre ne peut pas hériter de la classe Rectangle dans le billet suscité

[2] Bertrand Meyer, Object Oriented Software Construction, Prentice Hall, 1988

[3] PDF: The Liskov Subsitution Principle

[4] source: entrée Wikipedia correspondante

Commentaires

1. Le samedi, octobre 14 2006, 09:57 par Victor Nicollet

La fonction SetWidth de Carre ne devrait-elle pas être, en fait, un SetExtents?

2. Le samedi, octobre 14 2006, 12:42 par Emmanuel Deloget
Si si. Je m'ai trompé. Je corrige... :)
3. Le mercredi, août 12 2015, 15:05 par Faksu

Intéressant, et très ludique comme style d'écriture.

Petite remarque, il serait bien je pense de mettre une définition de ce que sont les post et pré-conditions. Ou un lien vers des ressources comme vous le faîtes d'habitude. Cette dépendance implicite m'a personnellement posé problème pour appréhender la fin du billet.

Sinon, tout le reste, super !

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet