• 4 heures
  • Facile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 30/10/2024

« L » pour le principe de substitution de Liskov

Découvrez la substitution de Liskov

L'héritage est, à priori, une bonne idée. Vous disposez déjà d'un concept et vous voulez en ajouter un autre. Et ce concept n'est qu'une implémentation plus spécifique du concept d'origine. C'est simple, il vous suffit de créer une nouvelle classe par héritage !

Cependant, il devient facile au fil du temps d'abuser de ce processus d'héritage. J'ai travaillé sur des projets où la hiérarchie des classes s'étendait sur 10 niveaux. Il est facile de perdre le fil des opérations spécifiques effectuées par les classes filles. Tôt ou tard, il arrive que certaines classes soient ajoutées et dérèglent le système existant.

Il s'agit alors d'une violation du principe de substitution de Liskov, selon lequel :

L'ajout de classes héritées ne devrait pas entraver le fonctionnement d'un système déjà existant.

C'est ce que j'appelle le principe zéro surprise. Lors de l'ajout d'une nouvelle classe dans la hiérarchie, le système existant ne doit pas dysfonctionner lorsqu'il utilise la nouvelle classe. Dans le cas contraire, vous aurez une surprise. Et elle ne sera pas bonne. 

Pourquoi utiliser le principe de substitution de Liskov ?

Imaginons que vous disposiez d'une classe Félin. Elle a une méthode appelée  manger()  . Pour nourrir votre chat, vous pouvez appeler la méthode avec une marque standard de croquettes pour chats en paramètre, par exemple Friskouz : le chat est content (comme peut l’être un chat). Si vous ajoutez une nouvelle classe, Tigre, qui correspond bien à une sorte de Félin, la méthode  manger()  sera de nouveau implémentée. 

Cependant, les tigres ne raffolent pas de nourriture sous plastique. Ils mangent de la viande crue. Ce n’est pas ce qui était attendu ! Suuurprise !

Le problème survient lorsque vous estimez que l’appel de méthode “manger des Friskouz” convient à tous. Mais pourtant un tigre est bien un félin ? Donc cela devrait convenir. En biologie animale, oui ; en programmation objet bien conçue, non ! Vous ne pouvez donc pas simplement entrer un tigre dans votre système, là où il y avait un Félin.

Et cela va à l'encontre du principe de substitution de Liskov puisque vous ne pouvez pas remplacer une classe de base (Félin), par une classe héritée (Tigre), sans impacter le reste du système.

Le principe de substitution de Liskov vous aide en limitant votre recours au mécanisme d'héritage. S'il est facile de gérer les classes d'implémentation concrètes de bas niveau, il vaut mieux prendre du recul et réfléchir lors du traitement d'une abstraction de haut niveau.

Essayons par exemple de résoudre le problème du félin et du tigre. Vous disposez véritablement de deux classes concrètes (c'est-à-dire d'une implémentation spécifique). Vous devez présenter quelques interfaces/abstractions de niveau supérieur :

  • Carnivore, qui ne consomme que de la viande.

  • Omnivore, qui consomme de la viande et d'autres aliments (comme Friskouz).

Désormais, lorsque vous ajoutez un animal quel qu'il soit, vous pouvez faire en sorte qu'il implémente l'une de ces deux interfaces.

Appliquez le principe de substitution de Liskov à votre code

Dans le film Jurassic Park, ils ont commis l'erreur de laisser des personnes s'approcher des animaux carnivores.  Bon, OK, vous vouliez savoir comment utiliser les interfaces en conformité avec le principe de substitution de Liskov... OK ! Eh bien, la difficulté consiste à choisir créer les interfaces dans la hiérarchie.

Tout comme dans l'exemple du félin, il semblait judicieux au départ de considérer le tigre comme un genre de félin. Mais le problème est apparu clairement au moment de l'implémentation. Il est souvent difficile de savoir à l'avance à quel moment le principe de Liskov sera enfreint. Cependant, lorsque vous découvrez une "infraction", vous devez repenser votre façon d'utiliser le concept d'héritage. Aussi, lorsque vous envisagez d'utiliser le concept d'héritage, posez-vous les questions suivantes :

  • Dans la classe fille, est-ce que toutes les redéfinitions de méthodes sont justifiées et pertinentes ?

  • L'implémentation d'une méthode redéfinie risque-t-elle de poser problème, et de générer le lancement d'une exception ? Si tel est le cas, c'est ennuyeux. 

  • L'implémentation d'une méthode redéfinie conduit-elle à ignorer l'appel et à ne rien faire ? Généralement, ce n'est pas bon signe, mais cela peut se justifier. Soyez attentif et voyez si c'est le cas pour une seule méthode (OK !) ou pour plusieurs méthodes (KO !).

Si nous avions introduit le concept du joker dans notre jeu de cartes, lequel ne dispose d'aucune valeur ni couleur, le fait de demander ces valeurs serait une erreur :

class Joker extends PlayingCard {

    public Rank getRank() {
        
        throw new UnsupportedOperationException();
    
    }

    public Suit getSuit() {

        throw new UnsupportedOperationException();
    
    }
    
};

Notre code ne prévoit pas qu'une carte puisse être sans valeur ou sans couleur. Donc l'implémentation d'un joker en tant que simple carte de jeu obtenue par héritage serait incorrecte.

Alors comment gérer le joker ?

Jusqu'à présent, nous avons correctement mis en œuvre les principes SOLID. Nous avons parlé des interfaces, puis retardé les implémentations. Mais dans ce cas, nous disposons d'une classe concrète comme point de départ. Cela rend les choses plus complexes. Rétrospectivement, nous aurions dû être informés de l'existence de jokers durant les discussions avec le client. Mais il arrive parfois que de nouvelles choses surviennent, et vous devrez vous adapter... !

Imaginons à présent que vous deviez revenir en arrière et modifier les énumérations de valeurs et de couleurs pour supporter le nouveau concept (le joker). Quelle est votre solution ?

Faites l'expérience vous-même ! 

AjoutezNONEaux deux énumérations. La résolution du problème n'est donc pas trop complexe : 

package com.openclassrooms.cardgame.model;

public enum Rank {

    NONE(0),
    TWO (2),
    THREE (3),
    FOUR (4),
    FIVE (5),
    SIX (6),
    SEVEN (7),
    EIGHT (8),
    NINE (9),
    JACK (10),
    QUEEN (11),
    KING (12),
    ACE (13);
    
    int rank;
    
    private Rank(int value) {
        rank = value;
    }

    public int value() {
        return rank;
    }
    
}

Et pour les couleurs :

package com.openclassrooms.cardgame.model;

public enum Suit {

    NONE(0),
    DIAMONDS (1),
    HEARTS (2),
    SPADES (3),
    CLUBS (4);
    
    int suit;
    
    private Suit(int value) {
        suit = value;
    }
    public int value() {
        return suit;
    }

}

En résumé

  • Le principe de substitution de Liskov s'applique aux hiérarchies d'héritage. Il est enfreint lorsqu'une classe dérivée ne peut prendre la place d'une classe de base sans provoquer un dysfonctionnement du système.

  • Afin d'avoir la certitude de ne pas enfreindre cette règle, essayez d'envisager des abstractions/interfaces de haut niveau avant les implémentations de bas niveau/concrètes.

Dans le chapitre suivant, nous réfléchirons à l'intérêt de conserver des interfaces de petite taille grâce au principe de ségrégation des interfaces.

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