SOLID – L: Liskov substitution

« Un carré est un rectangle ». Voilà ce que les gentils professeurs de géométrie nous ont dit. Mais, peut-on en dire de même en orienté objet ?

Carre extend Rectangle ?

En orienté objet, un rectangle est constitué de 2 attributs : « largeur » et « hauteur » avec leurs « setter » et « getter ». Rien d’incroyable:

Un carré, par contre, n’a qu’un attribut : « coté », avec un setter et un getter. Mais imaginons qu’il étende la classe Rectangle, comme dans « la vraie vie ». Comment l’objet Carre va-t-il gérer les méthodes setter et getter de rectangle ? Les méthodes « set/getHauteur » et « set/getLargeur » ne sont pas relevantes ou pertinentes pour un carré ! Essayons de contourner le problème:

Tout cela « fonctionne », mais avouez qu’on est plus dans de la bidouille pour faire rentrer un carré dans un rectangle. Ceci est une violation du principe de substitution de Liskov. Notre amie Barbara Liskov (oui c’est une dame) a un jour dit :

Si « S » est un sous-type de « T », alors tout objet de type « T » peut être remplacé par un objet de type « S » sans altérer les propriétés désirables du programme concerné.

Pour en revenir à nos carrés et rectangles, cela veut dire que partout où, dans votre code, vous vous attendez à recevoir un rectangle en paramètre, vous pouvez le remplacer par un carré, sans que cela pose un souci.

Ce n’est malheureusement pas le cas, car le développeur qui travaille sur un rectangle veut pouvoir changer la largeur et la hauteur indépendamment l’un de l’autre. C’est impossible avec un carré, ce qui « altère les propriétés d’un programme ».

Exemple concret

Imaginons que vous fassiez un jeu de voitures. Vous avez une classe « Voiture » avec quelques méthodes :

Votre jeu fait un carton et suite à la pression médiatique, votre patron cède, et vous demande d’ajouter des voitures volantes. « VoitureVolante extend Voiture », ça vous parait pas mal:

Sauf que vous avez la méthode « changePneu » qui vous ennuie un peu, car une voiture volante n’a pas de pneus. Que va faire votre méthode dans ce cas-là? Que pouvez-vous faire ? Au choix :

  • Réécrire la méthode et ne rien faire (méthode vide)
  • Réécrire la méthode et lancer une exception « PasDePneuException ».
  • Réécrire la méthode et nettoyer les réacteurs ou changer le train d’atterrissage. Mais est-ce le bon nom de méthode ?

Aucune des trois réponses n’est viable. En effet, cette méthode n’a pas lieu d’exister dans le cas d’un voiture qui n’a pas de pneu. Conclusion, une voiture volante n’est pas une voiture.

Ça sent le problème…

Voici donc quelques pistes qui vous feront sentir que vous êtes sur la mauvaise voie :

  • Vous avez une méthode qui n’a pas lieu d’être dans votre classe, mais vous devez l’implémenter.
  • Vous avez du réécrire une méthode de la classe parente car celle-ci ne doit « surtout pas » être exécutée ou ne rien faire.
  • Vous avez réécrit une méthode de votre parent et vous lancez une exception, alors que celle-ci n’était pas prévue dans la classe de base. Du coup, vous changez tous les appels en les englobant par un « try/catch »
  • Vous utilisez des « instanceof » pour exécuter (ou non) une méthode spécifique (aussi mauvais que le try/catch)
  • Vous devez modifier une classe parente pour que votre nouvelle classe fonctionne. Par exemple, ajouter des arguments optionnels.

C’est liste est non-exhaustive, mais assez complète. Evidemment, il faut rester vigilant : Certaines de ces affirmations ne sont pas toujours une preuve qu’il y a un problème. Par exemple, les méthodes vides sont monnaies courantes lorsque nous utilisons le pattern « Template method » ou « Null object ». Nous parlerons de ces patterns (et des autres) dans un autre article.

Comment faire rouler ma voiture volante ? Utiliser le pattern « Adapteur »

Bon alors évidemment, c’est difficile de dire à votre patron : « Boss ! Vous savez, dans notre jeu, la voiture bonus qui vole… Hum… Je ne peux pas l’implémenter car elle ne répondra pas au principe de substitution de Liskov ». S’il vous répond qu’il comprend et qu’il ne faut pas l’implémenter, c’est que vous avez vraiment un patron très cool!

Pour ce genre de problème, une des solutions est d’utiliser un pattern : L’adaptateur. Je vais vous en parler très brièvement, car je compte aussi en écrire un article. En gros, c’est faire rentrer un carré dans un rond (ou un rectangle!), ou bien transformer le port « Lightning » de votre IPhone en USB. Voici un petit bout de code qui devrait vous faire comprendre le principe.

D’abord nous avons la nouvelle interface IVoiture et la classe VoitureVolante qui n’ont pas ou peu de point commun:

Ensuite, on va rendre la voiture volante compatible avec la voiture de base grâce à l’adaptateur:

Et à l’exécution, c’est assez simple:

Certes, nous avons créé 2 classes « au lieu » d’une, mais la classe VoitureVolante ne se retrouve pas avec des méthodes poubelles, et ne fait que ce qu’elle doit faire.

Conclusion

Alors, qu’est-ce qu’il en ressort ? Sachez juste que l’orienté objet, ce n’est pas toujours « comme dans la vraie vie ». N’étendez pas une classe qui contient trop de méthodes pour votre nouvelle classe. Le but est de se dire : Si je n’étendais pas cette classe (ou l’interface), est-ce que j’aurais de toute façon écrit cette méthode ? S’appellerait-elle de la même manière ? Aurait-elle le même comportement globale? Est-ce que ma sortie (return) et mes erreurs (throw new Exception) sont identiques à ma classe parente?

Si vous avez répondu « oui » à toutes les questions, alors, félicitations, vous avez répondu au principe de substitution de Liskov.

Rejoindre la conversation

17 commentaires

  1. Salut,

    Interessant tout ça…

    Par contre, la fonction changePneu() est quand même implémentée et vide…

    Et si l’interface était remplacé par une classe abstraite… (avec les fonctions « obligatoires » en « abstract protected ») ?

    Merci.

    Fabrice

  2. Salut Fabrice!

    Oui la méthode changePneu est vide, mais c’est le rôle d’un adaptateur. Par contre, ma classe VoitureVolante, elle, reste tout à fait correcte.

    La classe abstraite qui contient des méthodes abstraites va aussi nous obliger à les implémenter, donc le problème reste le même.

    Une classe abstraite qui a des méthodes vides (et donc ne doivent pas être réimplémentées) pour pallier à ce problème est aussi une violation du principe de Liskov. En effet, écrire ce genre de méthode à postériori, c’est reporter le problème et au final, avoir quand même la méthode « changePneu » dans la classe « VoitureVolante »… Et, ca, ce n’est pas ce que l’on veut 🙂

  3. Le pattern Adaptateur est pertinent en ce qui concerne la méthode avance() qui gagne un niveau d’abstration en encapsulant la méthode concrète de l’objet agrégé.
    Mais c’est vraiment étrange de garder une méthode changePneu() qui sert à rien. Ca pollue.

    Si j’instancie un chien et une araignée je suis pas censé pouvoir appeler
    $araignee->remueLaQueue().
    Je vais prendre ca pour un bug de modélisation.

    Au lieu du pattern Adaptateur je verrais plutôt l’usage d’un mixin (ou Trait), puisqu’un mixin ajoute un comportement particulier à la classe qui l’utilise. Comme un plugin.
    Ca évite l’usage de l’héritage quand celui-ci ajoute plus de contraintes qu’autre chose.
    Ainsi chaque classe a son propre Trait, c’est bien compartimenté.
    Eventuellement pour être sur que chaque classe n’utilise pas le Trait de l’autre, chacune implémente l’interface qui lui va bien.
    Qu’en pensez-vous ?

  4. Bonjour Joris,

    Merci pour votre très bonne remarque! Et je comprends qu’une méthode vide puisse faire peur. Ca me fait plaisir de voir de personne ici n’aime ca 🙂

    Comme vous je pense que l’araignée ne devrait pas remuer la queue. C’est effectivement une erreur de conception.
    Tout comme j’ai fait en sorte que la voiture volante ne puisse pas changer de pneus… Ca serait aussi une erreur de conception.

    Le plus gros problème, c’est que toutes mes autres classes qui utilisent un objet de type « Voiture » sont susceptibles d’appeler la méthode « changePneu ».
    Impossible de ne pas l’implémenter d’une manière ou d’une autre car la classe doit répondre à un contrat (interface). Si ce n’est pas ma voiture volante, ça doit être quelqu’un d’autre (l’adaptateur dans mon cas).

    Le trait permet d’externaliser du code comme un plugin, mais étant donné que vous devez implémenter la méthode « changePneu » de l’interface « Voiture », vous devrez ajouter un « use changePneuTrait » dans votre classe « VoitureVolante ».

    Au final vous pourrez quand même faire:


    $voitureVolante = new VoitureVolante();
    $voitureVolante->changePneu();

    Là où j’ai peut-être été un peu vite, c’est que l’adaptateur n’étend pas une classe « Voiture » mais implémente une interface « Voiture », ce qui est fort différent.
    J’aurais du changé le nom du mon interface (Ex: IVoiture), car le but est de ne pas utiliser l’héritage de classe, qui comme vous dites « ajoute plus de contraintes qu’autre chose ».
    Cependant une interface est plus que recommandé.

    Est-ce que cela répond à votre question?

  5. Est-ce que ce principe s’applique également aux classes abstraites?

    Si « T » est une classe abstraite, il ne peut y avoir d’objets dans le programme qui soient remplacés par un sous-type « S ».

    Prenons un exemple:

    abstract class T{
    function __construct(){}
    }

    class S extends T{
    function __construct($arg){}
    }

    En implémentant un argument non-optionnel, « S » viole le principe de Liskov. Pourtant, « T » est abstrait et n’existe donc pas vraiment dans le programme en dehors de « S ».

    En cas de violation, comment faire alors si « S » a impérativement besoin de $arg dans son constructeur?

    merci!

  6. Salut Raph! Question pas évidente 🙂

    Selon moi, le principe s’applique également aux classes abstraites… Et je dirais même aux classes « normales ».

    Par contre, elle ne s’applique pas aux constructeurs. En effet, Liskov parle de pouvoir changer un « objet » par un sous-type (dans une signature de méthode par exemple). Or, pour avoir avoir cet objet, il faut passer par le constructeur qui est appelé plus tôt.

    Vu que tu sais quel constructeur (classe) tu appelles, tu connais son type et donc ses paramètres… A moins de faire un truc hyper dynamique:

    new $maClasse();

    Mais alors là, mieux vaut passer par le pattern « Factory »

  7. Bonjour Nicolas
    J’aime quand les façons de penser des autres bousculent la mienne ^^
    Je vois mieux ce que vous voulez dire, mais pour aller jusqu’au bout de mon raisonnement :

    « D’autres classes de votre modèle héritent de Voiture parce qu’elles sont susceptibles d’appeler changerPneu() » : ok mais cela implique obligatoirement que ces classes soient équipées de pneus (buggy, fourgon…).

    Je continue de croire qu’une erreur subsiste malgré l’usage de l’Adaptateur.
    vous dites que VoitureVolante est obligée d’implémenter l’interface IVoiture
    paske IVoiture est la classe mère disponible et qu’elle oblige la classe enfant posséder la méthode changerPneu().
    Mais en fait pourquoi est-elle obligée d’implémenter cette interface ?
    Après tout vous le dites à juste titre en début du billet : VoitureVolante n’est pas une Voiture.
    Une voiture est un type à la fois déjà bien concret
    et un sous-type de Vehicule qui lui doit etre abstrait.
    A la rigueur un Buggy peut hériter de Voiture mais pas la voiture volante.

    Ainsi je pense que le problème est dans la taxinomie.
    Voiture : véhicule, terrestre, 4 roues
    VoitureVolante : véhicule, aérien, 4…réacteurs.
    De base les 2 entités n’ont plus grand chose en commun.

    On peut transposer cette situation avec le téléphone fixe et le smartphone.
    $telephoneFixe->dialWith($skype) n’a aucun sens, tout comme $voitureVolante->changerPneu().

    Ainsi je dirais qu’au niveau objet, il faut revoir les niveaux d’abstraction.
    Voici ce que j’ai en tete :

    interface Vehicule {
    function avancer();
    function remplirReservoir();
    }
    interface VehiculeTerreste extends Vehicule{
    function reculer();
    function changerPneu();
    function mettreClignotant($direction);
    }
    interface VehiculeAerien extends Vehicule{
    function changerReacteur();
    function verifierAltitude();
    function decoller();
    function atterrir();
    }
    interface Avion extends VehiculeAerien {
    function sortirTrainAtterrissage();
    function rentrerTrainAtterrissage();
    }
    interface VoitureVolante extends VehiculeAerien{
    function actionnerToitOuvrant();
    }

    qu’en pensez vous ?

  8. Bonjour Joris et bonne année 🙂

    Attention, je ne pense pas avoir dit que « VoitureVolante » devait implémenter « IVoiture » puisque comme vous dites, je dis que la voiture volante N’EST PAS une voiture.
    C’est très différent de faire un adaptateur qui implémente cette interface. J’aurais aussi pu faire un ChevalAdapteurToVoiture car un cheval n’est pas une voiture non plus. Je pense que sa méthode « changePneu » aurait aussi été vide.

    Buggy, par contre, comme vous dites peut hériter de Voiture ou implémenter IVoiture.

    Dans votre exemple, vous prévoyez beaucoup de choses, alors je rajoute une couche: Que faites-vous des véhicules sans réservoir, comme les voitures solaires ou les chaises roulantes? 🙂

    Au final, un véhicule ne fait qu’avancer?
    Cela ne vas pas être simple si les autres classes (existantes) recoivent comme attribut un objet avec « juste » la méthode « avance »:

    class Course{
    //Et voilà pourquoi on est obligé de donner un objet avec l'interface IVoiture.
    //Parce que j'utilise un typage fort
    public function addVoiture(IVoiture $voiture){
    $voiture->changePneu();
    $voiture->avance();
    }
    }

    Au final, ma course de voitures ne sera pas très passionante: personne ne pourra freiner, ni changer ses pneus.

    Je pense que tout dépend de ce que doit faire le jeu avec les « voitures » (IVoiture). Peut-être, faudrait-il les renommer, par la suite, l’interface en « ItemPlayable » ou « Coursable » par exemple.

    Mais en tout cas, il faut toujours pouvoir faire $course->addVoiture(IVoiture) (ou ->addItemPlayable) pour que tout soit transparent pour le joueur (et le développeur) et continuer à utiliser le typage pour ne pas tomber dans certains travers du style:

    if instanceof VehiculeTerreste $voiture->changePneu()

    Votre diagramme de classes est correcte, cependant, cela ne résoud pas le problème du « addVoiture » (= »addVehiculeTerrestre ») pour la première version du jeu qui ne fonctionne qu’avec des voitures.
    Comment feriez-vous pour ajouter des avions dans la course?
    Enleveriez-vous le typage?
    Donneriez-vous l’interface commune aux deux, c’est-à-dire Vehicule? Cela voudrait dire que plus personne ne pourrait reculer?
    Utiliseriez-vous un adaptateur qui, je le rappelle, n’est utile que pour ce cas-ci?

  9. Bonsoir et bonne année egalement !
    Chaise roulante et voiture solaire : oui la problematique c’est bien de flairer le bon niveau d’abstraction pour prévoir l’evolution future du modele, les nouveaux vehicules, pourquoi pas des vehicules fictifs.
    Si je comprend bien en fait l’Adaptateur sert a rendre un objet « pas dans le moule » compatible avec le type attendu par l’instance Jeu quitte a y sacrifier des methodes qui n’auront pas d’implémentation

  10. En gros moi je suis le jeu j’ai besoin que les vehicules que tu ajoute dans la partie propose telles methodes pour que je sache bien les manipuler. Et quand effectivement apres 50 tours de pistes j’itere sur tous les vehicules pour leur changer les pneus (methode d’interface) et bien les voitures executeront la « vraie methode » tandis que les voiture volantes ne feront rien ou alors leur implementation de changePneu() sera un wrapper qui en interne
    appelle la methode changeReacteur()

    J’ai bon ? 🙂

  11. Exactement, le role de l’adaptateur c’est « juste ca », et tant pis s’il a des méthodes vides, il est là pour faire rentrer dans le moule.

    Et c’est l’adaptateur de la voiture volante (pas la voiture volante elle-même) qui va être donné au jeu. Et comme vous dites, quand on va changer les pneus, on va changer les pneus de toutes les voitures « normales » ainsi qu’à tous les adaptateurs (AdaptateurCheval, AdaptateurAvion, etc.)… Qui ne vont rien faire OU éventuellement changer les réacteurs de leur attribut privé « VoitureVolante »… Les adapteurs des chevaux changeront les sabots 🙂

  12. Pas mal les sabots héhé 🙂
    L’autre intéret c’est que le Jeu n’a pas besoin de savoir s’il manipule un objet  » d’origine  » ou un Adaptateur.
    Et ainsi d’éviter les code-smells a grands renforts de instance-of.
    De l’intéret de passer un objet d’interface IVehicule, qui permet de pas se soucier du type concret de l’objet : peu importe qui tu es réellement du moment que tu présentes les facultés que j’attend de toi.

    Maintenant que j’ai bien cerné l’intéret de l’Adaptateur, et la problématique qu’il résoud, effectivement une méthode changerRoue() qui existe mais ne fait rien ne me choque plus. Et en effet il ne s’agit pas d’une erreur de conception puisqu’on le fait dans le cadre d’un Adaptateur.

    On résoud aussi le probleme de l’évolution :
    si demain en plus de la voiture solaire j’ajoute des skateboard, ca marche aussi du moment que skateboard est adaptable à IVehicule.

    J’ai appris quelque chose aujourd’hui. Merci !

  13. Bonjour à Tous
    J’ai un peu du mal avec le concept d’adaptateur d’ici

    Et si plutot on crée une classe vehicule, mere de voiture et de voiturevolante avec les attributs et méthodes de voiture qu’on souhaite donner a voitureVolante (exe: couleur, chevaux,…) et de laisser dans voiture le changePneu() et le avance() qui fait rouler() et dans voitureVolante le volePlusVite(), avance() qui ferait voler() ?

    ainsi on reste en phase avec la realite et ca reste clair pour tout le monde.
    Le code existant continue d’appeler la classe voiture avec les meme methode, et on a ps la voitureVolante qui a changePneu() …

  14. Bonjour Jeff,

    Effectivement, dans un monde idéale, ce serait comme cela qu’il faudrait faire.

    Mais comme tu dis: le code existant continue d’utiliser la classe Voiture. Mais comment vas-tu donner en paramètre une VoitureVolante et ainsi la faire « avancer » dans le jeu?

    Je prévois un article sur les patterns, donc stay tuned 😉

  15. Hello, partant du principe que nous somme dans du code legacy et que l’interface comprenant changerPneu() est là. Je dirais que quitte à devoir garder ce changerpneu() pour la voiture volante j’aurais encapsulé cette soudaine variation et utilisé une stratégie en composition plutôt qu’un adapteur.
    En gros on fait remonter une unique implémentation de changerPneu() dans la superClasse Voiture, laquelle se contente de délèguer la réelle façon de changer le pneu à une stratégie de rechaussage variant selon le sous-type de iVoiture.
    Ceci vaut bien sur si on avait protégé le code client de iVoiture par un mécanisme d’instanciation et d’injection de dépendance genre Factory.

Laisser un commentaire

Répondre à Design Pattern : Adaptateur • Pain Grilled Annuler la réponse

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *