Developpez.com - Rubrique C++

Le Club des Développeurs et IT Pro

Débat C++ : Suivez vous le principe de substitution de Liskov en POO ?

Le 2008-11-08 16:03:56, par Alp, Expert éminent sénior
Bonjour,

Dans un monde où la POO est employée à toutes les sauces, dont certaines sont mauvaises, on voit des principes essentiels de programmation orientée objet bafoués.

L'un d'entre eux est le Principe de Substitution de Liskov (Liskov Substitution Principle, LSP).

En quoi consiste-t-il ?

Introduit en 1987[1] par Barbara Liskov, le principe de substitution de Liskov est, formellement, le suivant :
Si une propriété P est vraie pour une instance x d'un type T, alors cette propriété P doit rester vraie pour tout instance y d'un sous-type de T
où dans notre cas les sous-types de T sont les classes qui dérivent de T.

Moins formellement, il s'agit en fait que l'on puisse remplacer dans un code source toute instance de type Mere par une instance d'un type Fille qui dérive de Mere sans altérer en quoique ce soit la caractère correct du programme.

La question est donc : suivez-vous ce principe dans vos hiérarchies de classes C++ ? Que pensez-vous de ce principe ? Lui trouvez-vous une utilité ? (j'espère )

[1] Voir : http://citeseer.ist.psu.edu/cache/pa...ov94family.pdf
  Discussion forum
78 commentaires
  • coyotte507
    Membre expérimenté
    Avec la virtualité si on remplace les instances de type Mère par les instances de type Fille, en changeant rien d'autre, le programme aura très souvent un comportement différent.
  • Alp
    Expert éminent sénior
    Oui bien sûr, c'est le principe même de polymorphisme. Mais le programme se comportera correctement, c'est ça l'idée.

    Il s'agit de garder un programme correct en interchangeant des types dans une hiérarchie, cf le 1er post.

    Parfois, lorsqu'une classe B ne devrait pas hériter d'une classe A mais dont elle hérite quand même, on se retrouve avec un programme incohérent. C'est plutôt répandu comme violation du LSP.
  • Kromagg
    Membre habitué
    Je ne connaissais pas ce principe, enfin je l'utilise souvent, mais je ne savais pas que c'était un principe de la POO et qu'il avait un petit nom
    Maintenant est-ce que le programme va se comporter correctement, je pense que tout dépend de ce que l'on fait dans les classes filles. Il pourrait très bien se comporter correctement, mais ne donnera pas le résultat voulu.
  • Alp
    Expert éminent sénior
    Envoyé par Naoss
    Je ne connaissais pas ce principe, enfin je l'utilise souvent, mais je ne savais pas que c'était un principe de la POO et qu'il avait un petit nom
    Maintenant est-ce que le programme va se comporter correctement, je pense que tout dépend de ce que l'on fait dans les classes filles. Il pourrait très bien se comporter correctement, mais ne donnera pas le résultat voulu.
    Oui oui, mais le but c'est qu'en remplaçant on n'aboutisse pas à une situation incohérente dans le programme.
  • Kromagg
    Membre habitué
    On ne peut pas le garantir alors, tout dépend de la finalité de la classe fille, ce qu'elle va gérer. Je prend l'exemple d'un flux avec une classe mère comme ceci
    Code :
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    class BaseStream
        {
        protected:
    
            /// Le flux est-il ouvert ?
            bool mOpen;
    
        public:
    
            /**
             * Constructeur par défaut.
             */
            BaseStream(void) {}
    
            /**
             * Destructeur.
             */
            virtual ~BaseStream(void) {}
    
            /**
             * Ouvre le stream.
             * @Param       _overWrite : mode d'ouverture du flux.
             * @Retour      True si l'ouverture à réussie, false sinon.
             */
            virtual bool open(bool _overWrite) = 0;
    
            /**
             * Lecture d'une portion des données du flux vers le buffer..
             * @Param		_buffer : buffer qui va contenir les données inscrite dans le flux.
             *				_count : taille en bytes à lire dans le flux.
             * @Retour		Nombre de bytes lus.
             */
            virtual size_t read(char* _buffer, size_t _count) = 0;
    
            /**
             * Ecriture d'une portion des données du buffer vers le flux.
             * @Param		_buffer : buffer qui va contenir les données à inscrire dans le flux.
             *				_count : taille en bytes à inscrire dans le flux.
             * @Retour		Nombre de bytes écris.
             */
            virtual size_t write(const char* _buffer, size_t _count) = 0;
    
            /**
             * Indique si la fin du flux est atteinte.
             * @Retour		True si c'est la fin, false sinon.
             */
            virtual bool isEndOfStream(void) const = 0;
    
            /**
             * Indique si le flux est ouvert.
             * @Retour      True s'il l'est, false sinon.
             */
            virtual bool isOpen(void) const = 0;
    
            /**
             * Ferme le flux.
             */
            virtual void close(void) = 0;
        };
    Maintenant 2 classes fille : DataStream et FileStream.
    Disons que FileStream est un flux charger de gérer un fichier (donc sur un disque physique) et DataStream est un flux contenant un ensemble de données gérer dans un buffer (en mémoire).
    Le but est de charger une ressource. Dans notre cas que la ressource soit présente sur le disque dur (fichier xml par exemple) ou en mémoire le résultat final sera le même, tout ce que l'on veut c'est charger une ressource, donc on peut dire ici que le programme devrait fonctionner correctement.
  • coyotte507
    Membre expérimenté
    Je le bafoue : j'exige des paramètres dans le constructeur ou une initialisation différente pour certaines classes dérivées -- éventuellement transtypées ensuite en classes de base.

    Mais je pense pas que je doive changer mon code
  • koala01
    Expert éminent sénior
    Salut,

    J'essaie - généralement - de le respecter, et principalement lorsque j'essaye d'expliquer ce qu'est l'héritage.

    C'est la raison pour laquelle j'insiste sur la valeur "sémantique" des termes utilisés pour représenter deux classes pour lesquelles l'héritage est envisagé, en essayant d'inciter la personne à se poser la question de savoir "s'il est sémantiquement correct de dire que la classe B est réellement une 'amélioration' de la classe A, ou s'il s'agit d'un objet se contentant d'avoir les mêmes caractéristiques".

    Sémantiquement, parlant, il est effectivement correct de dire que "l'homme est un (animal) mammifère", alors que, effectivement, bien que les caractéristiques soient identique, il est impossible de prétendre qu'une pile (qui utilise, rappelons le, le principe LIFO) est une file (qui utilise, elle, le principe FIFO), ou vice-versa.

    Ceci n'empêchant nullement de trouver une "point sémantiquement commun" dans une autre classe que nous pourrions nommer "structure dynamique"

    De la même manière, et malgré les écueils qui risquent quasi immanquablement de se présenter, je n'ai aucune difficulté à admettre l'héritage multiple: il est sémantiquement correct de dire q'une turbo-génératrice est une génératrice, tout comme il est sémantiquement correct de dire que c'est un turbine
  • Luc Hermitte
    Expert éminent sénior
    On pourrait pinailler sur le fait que Barbara Liskov a introduit une définition, et que l'on a tiré un principe de cet article. Mais ce n'est pas important.
    Envoyé par Alp
    Moins formellement, il s'agit en fait que l'on puisse remplacer dans un code source toute instance de type Mere par une instance d'un type Fille qui dérive de Mere sans altérer en quoique ce soit la caractère correct du programme.

    La question est donc : suivez-vous ce principe dans vos hiérarchies de classes C++ ? Que pensez-vous de ce principe ? Lui trouvez-vous une utilité ? (j'espère )
    J'estime que ce principe est essentiel. Souvent on voit des critiques du C++ parce qu'il permet l'héritage multiple et que personne ne sait s'en servir.
    La vérité est que l'héritage fut vendu pour de mauvaises raisons (i.e. importer du code), et que beaucoup en sont restés là -- et ne savent pas s'en servir (alors imaginez l'héritage multiple). Avec une bonne compréhension et un respect du LSP, l'essentiel des critiques faites à l'encontre de l'héritage multiple s'évanouissent.

    Autrement, si dans une hiérarchie je n'ai pas de substituabilité, je passe en héritage privé (merci le C++) et basta: j'ai ainsi importé du code sans avoir fragilisé mon système en permettant des substitutions qui n'ont pas de sens ou qui sont instables.

    NB: Il est difficile de ne pas rapprocher le LSP de la programmation par contrats. Cf l'article de Robert C. Martin où il explique le LSP en s'appuyant sur des carrés et des rectangles. Cf aussi le fait qu'une liste triée, n'est pas une liste. Sujet déjà bien abordé par ici
  • loufoque
    Expert confirmé
    Il faut aussi faire attention aux invariants dans l'autre sens. Si tu as un Carré, sous-type d'un Rectangle, il ne faut pas qu'il soit possible de modifier l'objet par sa vue Rectangle de manière à ce que les invariants de Carré ne soient plus satisfaits.
    La solution évidente dans ce cas est d'avoir des objets immutables.

    J'utilise personnellement peu l'héritage et préfère les concepts. L'affinement de concepts est similaire à la dérivation.
    Mais lorsqu'on utilise les concepts, ce genre de propriétés est vraiment évident. D'autant plus qu'on ne manipule que des interfaces non-intrusives, et non pas des classes.
  • Florian Goo
    Membre éclairé
    Ce principe n'est ni plus ni moins que la raison d'être des interfaces.
    C'est pour cela qu'on dit souvent «Préférez la composition à l'héritage».