IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Navigation

Inscrivez-vous gratuitement
pour pouvoir participer, suivre les réponses en temps réel, voter pour les messages, poser vos propres questions et recevoir la newsletter

Langage C++ Discussion :

Héritage sans virtualité ?


Sujet :

Langage C++

Vue hybride

Message précédent Message précédent   Message suivant Message suivant
  1. #1
    Membre éclairé
    Homme Profil pro
    Doctorant en Astrophysique
    Inscrit en
    Mars 2009
    Messages
    312
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Localisation : France

    Informations professionnelles :
    Activité : Doctorant en Astrophysique
    Secteur : Enseignement

    Informations forums :
    Inscription : Mars 2009
    Messages : 312
    Par défaut Héritage sans virtualité ?
    Bonjour à tous.

    Je me pose actuellement la question de savoir si ce que je suis en train de faire est ok du point de vue du C++, ou si c'est un blasphème qui mérite les pires des chatiments.

    Le code sur lequel je travaille comprend déjà pas mal de virtualité, mais j'ai besoin d'objets de bases que je souhaiterai être assez efficace du point de vue du calcul pur (et donc éviter l'overhead du à la virtualité pourrait être pas mal).

    Le schéma très simple serait le suivant :

    ClasseBase
    -> ClasseA
    -> ClasseB
    -> ClasseC

    Je veux juste utiliser l'héritage pour éviter des copier-coller énormes et pas pratiques parce que le fichier de la classe de base doit peser un truc comme 60Ko. Donc je prévois une ClasseBase, sans virtualité et sans destructeur virtuel.

    Bien entendu, dans le code, ClasseA, ClasseB et ClasseC seront considérées comme des classes "indépendantes".

    La question est donc la suivante : ce genre de choses est-il OK du point de vue du C++, ou cela n'est pas clean , voire dangereux et peut mener à des désastres (et si oui lesquels).

    Merci beaucoup

  2. #2
    Membre éprouvé
    Profil pro
    Inscrit en
    Novembre 2004
    Messages
    2 766
    Détails du profil
    Informations personnelles :
    Localisation : France

    Informations forums :
    Inscription : Novembre 2004
    Messages : 2 766
    Par défaut
    La virtualité est une option, pas une obligation. Cela dépend de tes besoins (besoin de polymorphisme ?).

    Si c'est uniquement pour factoriser des fonctionnalités, tu peux aussi utiliser la composition. Et si tu choisis de l'héritage, envisager l'héritage privé.

  3. #3
    Expert éminent
    Avatar de koala01
    Homme Profil pro
    aucun
    Inscrit en
    Octobre 2004
    Messages
    11 644
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Âge : 53
    Localisation : Belgique

    Informations professionnelles :
    Activité : aucun

    Informations forums :
    Inscription : Octobre 2004
    Messages : 11 644
    Par défaut
    Salut,

    La virtualité a beau être une option, elle n'en est pas moins indispensable du point de vue orienté objet dés que l'on parle d'héritage.

    En effet, le fondement même de la conception orientée objets est la substituabilité : le fait que l'on puisse considérer un objet d'un type particulier (par exemple de type Voiture) comme étant du type de la classe de base (par exemple : véhicule) tout en gardant les comportements (du moins, ceux qui existent dans la classe de base) adaptés au type réellement manipulé.

    Retirer la virtualité du concept orienté objets, reviens à retirer le moteur ou les roues d'une voiture : on obtient quelque chose qui n'est plus en état de fournir les services que l'on en attend de manière correcte

    En outre, il faut être conscient que l'héritage est quand meme la relation la plus forte qui puisse exister entre deux objets car c'est une relation EST-UN, au sens sémantique du terme (on peut décemment dire qu'une voiture EST, du point de vue sémantique, UN véhicule ).

    Il faut donc veiller à respecter strictement le Principe de Substitution de Liskov (Liskov Subtsitution Principle en anglais, ou LSP pour les intimes ) et à recourir à l'héritage uniquement quand LSP est strictement respecté.

    Si tu ne peux pas décemment estimer que tes objets de type classeA, classeB ou classeC sont des objets de type de ClasseBase du point de vue de la sémantique, tu ne dois, en vertu de LSP, purement et simplement pas envisager l'héritage

    Enfin, il s'agit de faire attention à la "granularité" de tes classes de base, car, si tu remontes suffisamment haut dans la hiérarchie de classes, tu pourrais presque estimer que la classe Voiture et la classe flûte peuvent avoir une classe de base "Objet". (c'est d'ailleurs le principe sur lequel se basent d'autres langages )

    Seulement, les points communs entre une flute et une voiture sont tellement limités qu'une telle classe n'offre aucun avantage et, au contraire, place un certain nombre de restrictions sur ce qui peut etre fait par la suite

    Outre le respect de LSP, l'héritage devrait n'être envisagé que si tu prévois effectivement de placer des objet de différents types dérivés dans une seule et meme collection d'objets (par exemple : de placer des objets dont le type est classeA, classeB ou classeC dans un tableau de pointeurs de type ClasseBase)

    Bien souvent, l'héritage peut etre remplacer par une composition, et il doit l'être si LSP n'est pas respecté

    Enfin, il faut savoir que C++ est multi paradigme et que, outre le paradigme impératif et le paradigme orienté objets, il offre un paradigme qui a de grandes chances de t'intéresser également : le paradigme générique.

    En effet, l'idée du paradigme orienté objets est de ne plus réfléchir en terme de données mais en terme de services attendus de la part des objets que l'on va manipuler.

    Le paradigme générique va plus loin, et l'idée qui le sous tend est de se dire que : si je ne sais pas encore quel est le type de la donnée que je vais manipuler, je sais, en revanche, comment je vais la manipuler".

    Tu peux alors réfléchir en termes de "fonctionnalités transversales", ou en termes de "concepts" et envisager d'implémenter de manière générique ces différentes fonctionnalités ou concepts pour, par la suite, décider que classeA, classeB et / ou classeC utilisent ces fonctionnalités, ou ces concepts

    Il faut "simplement" que tes différentes classes présentent une interface commune permettant à ces fonctionnalités de faire leur travail.

    Mais l'interface qui devient nécessaire peut se limiter à des comportement simples tels que des mutateurs ou des accesseurs "basiques"

    L'énorme avantage, c'est que les concepts que tu implémentes déterminent le type réellement manipulé au moment de la compilation, et ne nécessitent donc pas la virtualité et que, de plus, tes classes restent indépendantes des autres car UneClasse<Type1> n'a aucune relation (hormis le fait qu'elle ait la meme interface) que UneClasse<Type2>

    PS : l'héritage privé n'est pas une relation de type EST-UN comme l'est l'héritage public : c'est en fait une relation de type EST-IMPLEMENTE-SOUS-LA-FORME-DE, et ne crée aucune relation entre les différentes classes qui héritent de manière privée d'une même classe de base (on ne peut pas placer les classes dérivées dans un tableau de pointeurs sur la classe de base cfr l'entrée de la FAQ sur le sujet)
    A méditer: La solution la plus simple est toujours la moins compliquée
    Ce qui se conçoit bien s'énonce clairement, et les mots pour le dire vous viennent aisément. Nicolas Boileau
    Compiler Gcc sous windows avec MinGW
    Coder efficacement en C++ : dans les bacs le 17 février 2014
    mon tout nouveau blog

  4. #4
    Membre Expert
    Avatar de white_tentacle
    Profil pro
    Inscrit en
    Novembre 2008
    Messages
    1 505
    Détails du profil
    Informations personnelles :
    Localisation : France

    Informations forums :
    Inscription : Novembre 2008
    Messages : 1 505
    Par défaut
    Pour faire plus court que Koala, non, ce n’est pas un problème, tant que tu n’utilises jamais de pointeur vers la classe de base pour détruire des instances de tes classes filles (ce qui conduirait sinon à des fuites mémoire).

    L’héritage privé ou protégé, qui ont entre autres comme effet d’empêcher cette utilisation erronée, sont donc à envisager très fortement dans ce cas.

    Autre solution, comme le suggère Koala, passer par la généricité si le but est de factoriser du code. Cela peut, suivant les cas, améliorer (pas de coût de l’appel virtuel) ou diminuer (augmentation de taille du code et donc saturation des cache) les perfs, c’est donc à mesurer si c’est critique pour toi.

  5. #5
    Membre éprouvé
    Profil pro
    Inscrit en
    Novembre 2004
    Messages
    2 766
    Détails du profil
    Informations personnelles :
    Localisation : France

    Informations forums :
    Inscription : Novembre 2004
    Messages : 2 766
    Par défaut
    Citation Envoyé par koala01 Voir le message
    La virtualité a beau être une option, elle n'en est pas moins indispensable du point de vue orienté objet dés que l'on parle d'héritage.
    Dès que l'on parle d'héritage public.

  6. #6
    Membre éclairé
    Homme Profil pro
    Doctorant en Astrophysique
    Inscrit en
    Mars 2009
    Messages
    312
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Localisation : France

    Informations professionnelles :
    Activité : Doctorant en Astrophysique
    Secteur : Enseignement

    Informations forums :
    Inscription : Mars 2009
    Messages : 312
    Par défaut
    Merci beaucoup pour vos réponses, je vois comme je m'y attendais, que c'est un sujet sensible. Pour les concepts, ils ne sont pas dans la norme C++ 2011 si je me trompe pas...

    En fait voici mon problème :
    Dans mon programme, j'ai besoin de vecteurs/matrices/tenseurs 3 et 4D de petite taille fixe (pour représenter des dimensions physiques). Ca me saoulait depuis quelques temps déjà de ne pas trouver mon bonheur dans quelques librairies dispo (les trucs types BLAS/ATLAS sont surtout optimisées pour les matrices de grande taille), donc j'ai pris le problème à bras le corps et j'ai codé mes propres trucs en une semaine. Les perfs sont plutôt bonne puisque c'est meilleur que des valarray. Bref ça me satisfait entièrement comme ça.

    Du coup je m'étais dis la chose suivante :
    Je vais coder une classe MathArray qui va implémenter tous les opérateurs qui vont travailler sur chacun des éléments de façon indépendante (genre pas une multiplication de matrices) et de cette classe je vais dériver MathVector, MathMatrix etc... qui ajouteront quelques opérateurs spécifiques.

    La question est simple : comment faire cela proprement par héritage (pas par composition) ? Les MathVector, MathMatrix SONT des tableaux MathArray. A part si quelqu'un a une solution "immédiatement" implémentable, je pense que je vais garder pour l'instant mon héritage public sans virtualité (avec un héritage privé, mes vector et matrix hériteraient des +, - etc.. en privé ce qui me bloquerait un peu non ?).

    J'ai regardé du côté du CRTP qui à mon avis serait la bonne façons, mais pour certains cas c'est un peu prise de tête.

    Si certains d'entre vous ont des avis sur la question.

    Merci beaucoup

    EDIT : je viens de penser à un truc tordu. J'utilise le CRTP à certains autres endroits de mon code pour 2 ou 3 fonctions de classes "abstraites" (virtualité "statique" pour le coup), mais là pour bien réécrire toutes les fonctions et opérateurs surchargés de ma classe de base, ce serait une très grosse prise de tête. Du coup, je me disais : pourquoi ne pas simplement CRTP-isé le destructeur ?

    Un truc genre:
    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    template<class TCRTP, class T, unsigned int SIZE> class MathArray
    {
        ~MathArray() {delete static_cast<TCRTP*>(this);}
    }
    Ca marcherait un truc comme ça (TCRTP est un type dérivé par exemple "MathVector") ?

  7. #7
    Membre éprouvé
    Profil pro
    Inscrit en
    Novembre 2004
    Messages
    2 766
    Détails du profil
    Informations personnelles :
    Localisation : France

    Informations forums :
    Inscription : Novembre 2004
    Messages : 2 766
    Par défaut
    Citation Envoyé par Kaluza Voir le message
    Les MathVector, MathMatrix SONT des tableaux MathArray.
    On peut également dire que les MathVector sont des MathMatrix...

    Réfléchis à ce que ça implique. :-)

  8. #8
    Rédacteur
    Avatar de 3DArchi
    Profil pro
    Inscrit en
    Juin 2008
    Messages
    7 634
    Détails du profil
    Informations personnelles :
    Localisation : France

    Informations forums :
    Inscription : Juin 2008
    Messages : 7 634
    Par défaut
    Citation Envoyé par Kaluza Voir le message
    Ca marcherait un truc comme ça?
    stackoverflow (le destructeur de base appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement le destructeur de derivée qui automatiquement appel le destructeur de base qui appelle explicitement....)

    Tu aurais pu envisager une fonction tierce :
    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    template<class T>
    struct base
    {
       ~base()
       {
          static_cast<T&>(*this).destroy_it();
       }
    };
     
    struct derived : public base<derived>
    {
       ~derived()
       {destroy_it();}
     
    private:
       friend struct base<derived>;
       void destroy_it()
       {}
    };
    ce qui en soit est souvent pas terrible, mais :

    De base, l'appel d'un destructeur non virtuel sur un pointeur dont le type dynamique est différent du type statique provoque un comportement indéterminé.

    Plutôt rendre protégé les constructeurs et destructeurs de la classe MathArray pour qu'on ne puisse créer/supprimer un objet vecteur ou matrice à partir de cette classe.

    Ceci dit, rien n'empêche d'avoir un héritage privé et d'utiliser ensuite using pour re 'publier' les fonctions qui t'intéressent:
    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct base
    {
       int foo()const;
    private:
       base()=default;
       ~base()=default;
    };
     
    struct derived : private base
    {
       using base::foo;
    };
    Enfin, j'aurais plutôt vu les opérateurs et autres fonctions de manipulation à l'extérieur des classes de représentation des matrices/vecteurs/tenseurs plutôt que comme membres de la classe.

  9. #9
    Modérateur
    Avatar de Obsidian
    Homme Profil pro
    Chercheur d'emploi
    Inscrit en
    Septembre 2007
    Messages
    7 472
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Âge : 49
    Localisation : France, Essonne (Île de France)

    Informations professionnelles :
    Activité : Chercheur d'emploi
    Secteur : High Tech - Éditeur de logiciels

    Informations forums :
    Inscription : Septembre 2007
    Messages : 7 472
    Par défaut
    Hello,

    Citation Envoyé par koala01 Voir le message
    La virtualité a beau être une option, elle n'en est pas moins indispensable du point de vue orienté objet dés que l'on parle d'héritage.
    Ben justement, moi je ne trouve pas. Au contraire, je trouve que c'est une des forces du C++ de faire clairement le distingo entre les deux et de permettre d'exprimer une approche objet d'un programme en limitant au minimum les contraintes résolues au runtime.

    En effet, le fondement même de la conception orientée objets est la substituabilité : le fait que l'on puisse considérer un objet d'un type particulier (par exemple de type Voiture) comme étant du type de la classe de base (par exemple : véhicule) tout en gardant les comportements (du moins, ceux qui existent dans la classe de base) adaptés au type réellement manipulé.
    Pas nécessairement.

    Retirer la virtualité du concept orienté objets, reviens à retirer le moteur ou les roues d'une voiture : on obtient quelque chose qui n'est plus en état de fournir les services que l'on en attend de manière correcte
    Ben non. Si ton véhicule est réputé avoir des roues, le fait de le spécialiser en voiture n'implique pas forcément que tu vas modifier ses roues. Et étendre la classe ne supprime en rien l'existant.

    En outre, il faut être conscient que l'héritage est quand meme la relation la plus forte qui puisse exister entre deux objets car c'est une relation EST-UN, au sens sémantique du terme (on peut décemment dire qu'une voiture EST, du point de vue sémantique, UN véhicule ).

    Il faut donc veiller à respecter strictement le Principe de Substitution de Liskov (Liskov Subtsitution Principle en anglais, ou LSP pour les intimes ) et à recourir à l'héritage uniquement quand LSP est strictement respecté.
    Eh bien, pour moi, c'est justement le LSP qui va faire que la virtualité doit être forcément optionnelle et, dans ce cas, explicite (même si Java adopte l'approche inverse).

    Le LSP est le principe qui impose que toute propriété qui est vraie pour tout objet de type A l'est forcément aussi pour tout objet d'un type B sous-type de A. Le principe de substitution dit clairement qu'on doit pouvoir remplacer un A par un B de façon complètement transparente. C'est particulièrement important lorsque les routines qui reçoivent en arguments des objets d'un type donné n'ont pas connaissance de l'existence de sous-classe de ce type. Et justement, en virtualisant des fonctions-membres, on brise cette transparence.

    Ce n'est pas un détail parce que virtualiser des fonctions rend non déterministes des choses qui l'étaient pourtant au départ. Prenons l'exemple classique : une classe « forme » que je dérive en « cercle », « carré », « triangle », etc.

    Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
    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
    61
    62
    63
    64
    65
    66
    67
    68
    69
    #include <string>
    #include <iostream>
     
    using namespace std;
     
    class Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Cercle : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Carre : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Triangle : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
     
    string Forme::QueSuisJe ()
    {
        return "Forme";
    }
     
    string Cercle::QueSuisJe ()
    {
        return "Cercle";
    }
     
    string Carre::QueSuisJe ()
    {
        return "Carré";
    }
     
    string Triangle::QueSuisJe ()
    {
        return "Triangle";
    }
     
    int main (void)
    {
        unsigned int i;
        Forme *  tableau [4];
        Forme    fo;
        Cercle   ce;
        Carre    ca;
        Triangle tr;
     
        tableau[0] = &fo;
        tableau[1] = &ce;
        tableau[2] = &ca;
        tableau[3] = &tr;
     
        for (i=0;i<sizeof tableau / sizeof (*tableau);++i)
        cout << "Position " << i << ", je suis un(e) " << tableau[i]->QueSuisJe() << endl;
     
        return 0;
    }

    Code shell : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    $ ./programme
    Position 0, je suis un(e) Forme
    Position 1, je suis un(e) Cercle
    Position 2, je suis un(e) Carré
    Position 3, je suis un(e) Triangle
    $ _

    On pourrait déjà objecter que c'est le mauvais exemple parce que « Cercle », « Carré » et « Triangle » sont plus restrictives que « Forme » en général qui peut être n'importe quoi, mais c'est une question de définition. Il s'agit de savoir si la classe Forme peut définir à elle seule n'importe quelle forme (avec une liste de points, par exemple), ou si elle ne sert qu'à indiquer qu'il s'agit d'une forme, sans plus de détails. Mais ce n'est pas ce qui nous intéresse.

    Maintenant, imaginons que je compte allouer de la place pour stocker les chaînes renvoyées par les objets de mon tableau. Sans virtualité, je sais qu'ils sont tous les mêmes, et en plus je connais cette chaîne à l'avance. J'ai donc tout-à-fait le droit d'écrire par exemple :

    Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
        ptr = new char[tableau[0]->QueSuisJe().length() * sizeof tableau / (sizeof *tableau)];

    Par contre, je ne peux absolument le garantir à l'avance avec des fonctions virtuelles. Ça a même toutes les chances de planter puisque j'ai volontairement choisi le nom « Forme » plutôt que « Polygone » ou autre pour qu'il soit le plus court de tout les noms définis ici et que les autres provoquent systématiquement un dépassement de tableau. :-)

    Pire encore : si j'admets ce qui vient d'être dit et que je ne m'en tiens qu'à un seul élément du tableau, si :
    1. Je mesure la longueur de la chaîne renvoyée par l'élément tableau[0] ;
    2. j'alloue l'espace correspondant ;
    3. Je trie mon tableau ;
    4. Je stocke la chaîne que me renvoie mon élément [0] dans l'espace alloué


    Alors le programme risquera de planter aussi puisque la chaîne renvoyée ne sera plus la même. Moralité : non seulement je ne peux pas systématiquement substituer une classe virtualisée par une classe dérivée à la compilation de façon déterministe, mais je ne peux pas non plus, même à l'exécution, substituer entre elles les différentes instances d'une même classe virtualisée. le LSP est enterré.

    Évidemment, ça ne veut pas dire qu'il faut se passer de virtualité en général : c'est à la fois un principe naturel aux utilisateurs et extrêmement utile en pratique. Mais je trouve qu'il est important de séparer ces notions et de laisser le choix au programmeur.

    Ça a également le mérite de mettre en évidence le fait que le LSP peut être, selon les cas, interprété de manières diamétralement opposées.

  10. #10
    Expert éminent
    Avatar de koala01
    Homme Profil pro
    aucun
    Inscrit en
    Octobre 2004
    Messages
    11 644
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Âge : 53
    Localisation : Belgique

    Informations professionnelles :
    Activité : aucun

    Informations forums :
    Inscription : Octobre 2004
    Messages : 11 644
    Par défaut
    Citation Envoyé par Obsidian Voir le message
    Ben justement, moi je ne trouve pas. Au contraire, je trouve que c'est une des forces du C++ de faire clairement le distingo entre les deux et de permettre d'exprimer une approche objet d'un programme en limitant au minimum les contraintes résolues au runtime.
    Si tu veux qu'un objet qui "passe pour être" d'un type de base puisse avoir un comportement (aka: une propriété valide de la classe dérivée, héritée de la classe de base) adapté au type réel de l'objet, tu n'as pas le choix : tu passe d'office par la virtualité à un moment ou à un autre...
    Pas nécessairement.
    J'aurais peut etre pu dire éventuellement adaptés (pour faire la distinction entre les comportements hérités de la classe de base qui ne nécessitent aucune adaptation et ceux qui justement en nécessitent), en effet...
    Ben non. Si ton véhicule est réputé avoir des roues, le fait de le spécialiser en voiture n'implique pas forcément que tu vas modifier ses roues. Et étendre la classe ne supprime en rien l'existant.
    En fait, dans ma phrase, qui se voulait une comparaison, j'abandonnais le mode "conception objet" pour le mode "vie courante"...

    Il est vrai qu'après avoir parlé de classes voitures et véhicule, cela pouvait porter à confusion.

    Je reprend donc : supprime la virtualité du langage, et tu te retrouve avec n'importe quel objet de la vie courante "amputé" de ce qui lui permet de rendre les services que l'on en attend.
    Eh bien, pour moi, c'est justement le LSP qui va faire que la virtualité doit être forcément optionnelle et, dans ce cas, explicite (même si Java adopte l'approche inverse).
    Si C++ a décidé que la virtualité devait être explicite, c'est essentiellement pour une raison :

    La première, c'est parce que la règle générale en C++ est de ne pas payer pour ce que l'on utilise pas.

    La virtualité ayant un cout, et le comportement de toutes les fonctions n'ayant pas forcément besoin d'être adapté, il n'est pas normal, dans cette optique, de forcer les fonctions à être virtuelles.

    Cependant
    Le LSP est le principe qui impose que toute propriété qui est vraie pour tout objet de type A l'est forcément aussi pour tout objet d'un type B sous-type de A. Le principe de substitution dit clairement qu'on doit pouvoir remplacer un A par un B de façon complètement transparente. C'est particulièrement important lorsque les routines qui reçoivent en arguments des objets d'un type donné n'ont pas connaissance de l'existence de sous-classe de ce type. Et justement, en virtualisant des fonctions-membres, on brise cette transparence.
    Justement, non

    C'est l'utilisation de l'objet "passant pour etre" du type de base qui doit être transparente, et non sa conception!!!

    Je m'explique :

    Si tu as une classe de base Forme, spécialisée en Cercle, Rectangle et triangle.

    les comportements valides de cette classe de base pourraient parfaitement être "calculeAire", "claculePerimetre" ou encore "tracer".

    En tant qu'utilisateur de la classe Forme, tu es parfaitement en droit de récupérer, par exemple, une collection de pointeurs sur Forme et de les transmettre un à un à une fonction qui "fera quelque chose" des objets "passant pour etre " de type Forme qu'elle recevra.

    Tu vas donc écrire une boucle pouvant ressembler à (C++11 inside)
    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    for( auto forme : tab)
    {
        function(forme);
    }
    En tant qu'utilisateur de la classe Forme, tu auras remarqué qu'elle expose les fonctions dont je viens de parler et tu es donc parfaitement ne droit d'espérer, si tu invoque n'importe lequel de ces comportements valides au départ d'un "objet passant pour être" de type Forme, qu'il sera, effectivement, adapté au type réel de l'objet transmis: Tu t'attends, en effet, à ce que le cercle, le triangle ou le rectangle soient correctement affichés et non qu'ils soient représentés sous la forme d'un hexagone, et tu t'attend à ce que le cacul de l'aire ou du périmètre renvoie une valeur correcte

    A charge du concepteur de la classe de faire en sorte que ces comportements s'adaptent effectivement au type réel.
    Ce n'est pas un détail parce que virtualiser des fonctions rend non déterministes des choses qui l'étaient pourtant au départ. Prenons l'exemple classique : une classe « forme » que je dérive en « cercle », « carré », « triangle », etc.
    Bien au contraire, ca rend les comportements que l'on peut attendre de tout objet "passant pour etre" du type de base pleinement déterministes en fonction du type réellement utilisé
    Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
    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
    61
    62
    63
    64
    65
    66
    67
    68
    69
    #include <string>
    #include <iostream>
     
    using namespace std;
     
    class Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Cercle : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Carre : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
    class Triangle : public Forme
    {
        public:
            virtual string QueSuisJe ();
    };
     
     
    string Forme::QueSuisJe ()
    {
        return "Forme";
    }
     
    string Cercle::QueSuisJe ()
    {
        return "Cercle";
    }
     
    string Carre::QueSuisJe ()
    {
        return "Carré";
    }
     
    string Triangle::QueSuisJe ()
    {
        return "Triangle";
    }
     
    int main (void)
    {
        unsigned int i;
        Forme *  tableau [4];
        Forme    fo;
        Cercle   ce;
        Carre    ca;
        Triangle tr;
     
        tableau[0] = &fo;
        tableau[1] = &ce;
        tableau[2] = &ca;
        tableau[3] = &tr;
     
        for (i=0;i<sizeof tableau / sizeof (*tableau);++i)
        cout << "Position " << i << ", je suis un(e) " << tableau[i]->QueSuisJe() << endl;
     
        return 0;
    }

    Code shell : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    $ ./programme
    Position 0, je suis un(e) Forme
    Position 1, je suis un(e) Cercle
    Position 2, je suis un(e) Carré
    Position 3, je suis un(e) Triangle
    $ _
    Rassures moi, tu ne voudrais quand meme pas qu'un carré te réponde "je suis un triangle, quand même
    On pourrait déjà objecter que c'est le mauvais exemple parce que « Cercle », « Carré » et « Triangle » sont plus restrictives que « Forme » en général qui peut être n'importe quoi, mais c'est une question de définition.
    Mais c'est le but même de l'héritage!!!

    Le but de l'héritage est de pouvoir spécialiser des classes dans le respect des invariants!

    C'est d'ailleurs pour cela que Carre n'héritera jamais ni de Rectangle ni de Losange et qu'il ne sera jamais dérivé en l'une de ces classes!

    La propriété qui fait que l'on peut questionner une forme sur son aire, sur son périmètre ou qu'on peut lui demander de se tracer ont beau être des invariants, cela ne signifie absolument pas que cela ne souffre aucune adaptation, bien au contraire!
    Il s'agit de savoir si la classe Forme peut définir à elle seule n'importe quelle forme (avec une liste de points, par exemple), ou si elle ne sert qu'à indiquer qu'il s'agit d'une forme, sans plus de détails. Mais ce n'est pas ce qui nous intéresse.
    Justement, c'est ce qui nous intéresse!!!

    La classe Forme est là pour fournir une interface (comprend : des propriétés valides) commune à tout objet susceptible d'être considéré comme étant une forme.

    A ce titre, si tu as besoin d'une fonction permettant de déterminer ce qu'est ton objet, ta fonction QueSuisJe() a tout à fait sa place dans la classe Forme, mais il est purement et simplement normal que son comportement s'adapte en fonction du type réel de l'objet.

    Si le comportement ne s'adaptait pas, tu créerais une fonction qui ne sert strictement à rien, pour la simple et bonne raison que l'on se doute que, toute forme utilisée est... une forme

    Maintenant, imaginons que je compte allouer de la place pour stocker les chaînes renvoyées par les objets de mon tableau. Sans virtualité, je sais qu'ils sont tous les mêmes, et en plus je connais cette chaîne à l'avance. J'ai donc tout-à-fait le droit d'écrire par exemple :

    Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
        ptr = new char[tableau[0]->QueSuisJe().length() * sizeof tableau / (sizeof *tableau)];
    Mais quel intérêt aurais tu à avoir XXX fois la même chaine de caractères Ne crois tu pas qu'une seule fois serait amplement suffisante
    Par contre, je ne peux absolument le garantir à l'avance avec des fonctions virtuelles. Ça a même toutes les chances de planter puisque j'ai volontairement choisi le nom « Forme » plutôt que « Polygone » ou autre pour qu'il soit le plus court de tout les noms définis ici et que les autres provoquent systématiquement un dépassement de tableau. :-)
    Mais, encore une fois, à quoi pourrait t'avance d'avoir XXX fois la chaine "Forme", étant donné que, travaillant avec un tableau de pointeur sur Forme, tu sais parfaitement que tu travailles... avec des formes
    Pire encore : si j'admets ce qui vient d'être dit et que je ne m'en tiens qu'à un seul élément du tableau, si :
    1. Je mesure la longueur de la chaîne renvoyée par l'élément tableau[0] ;
    2. j'alloue l'espace correspondant ;
    3. Je trie mon tableau ;
    4. Je stocke la chaîne que me renvoie mon élément [0] dans l'espace alloué


    Alors le programme risquera de planter aussi puisque la chaîne renvoyée ne sera plus la même. Moralité : non seulement je ne peux pas systématiquement substituer une classe virtualisée par une classe dérivée à la compilation de façon déterministe, mais je ne peux pas non plus, même à l'exécution, substituer entre elles les différentes instances d'une même classe virtualisée. le LSP est enterré.
    C'est surtout que tu fais n'importe quoi, pour justifier une position totalement injustifiable!

    Parce que, dés le moment où tu dis que la fonction QueSuisje n'est pas virtuelle, dans le cas présent, elle pert purement et simplement toute utilité, et toi toute crédibilité
    Évidemment, ça ne veut pas dire qu'il faut se passer de virtualité en général : c'est à la fois un principe naturel aux utilisateurs et extrêmement utile en pratique. Mais je trouve qu'il est important de séparer ces notions et de laisser le choix au programmeur.
    Mais on laisse au programmeur le choix...

    Celui de déterminer si un comportement donné mérite (ou a besoin, ou vaut la peine) d'être redéfini, affiné, adapté, au type dérivé réellement utilisé.
    Ça a également le mérite de mettre en évidence le fait que le LSP peut être, selon les cas, interprété de manières diamétralement opposées.
    Bien au contraire, cela a juste le mérite de mettre ne évidence le fait que tu ne sembles absolument pas avoir compris le principe même de la conception orientée objet
    A méditer: La solution la plus simple est toujours la moins compliquée
    Ce qui se conçoit bien s'énonce clairement, et les mots pour le dire vous viennent aisément. Nicolas Boileau
    Compiler Gcc sous windows avec MinGW
    Coder efficacement en C++ : dans les bacs le 17 février 2014
    mon tout nouveau blog

  11. #11
    Modérateur
    Avatar de Obsidian
    Homme Profil pro
    Chercheur d'emploi
    Inscrit en
    Septembre 2007
    Messages
    7 472
    Détails du profil
    Informations personnelles :
    Sexe : Homme
    Âge : 49
    Localisation : France, Essonne (Île de France)

    Informations professionnelles :
    Activité : Chercheur d'emploi
    Secteur : High Tech - Éditeur de logiciels

    Informations forums :
    Inscription : Septembre 2007
    Messages : 7 472
    Par défaut
    Bien au contraire, cela a juste le mérite de mettre ne évidence le fait que tu ne sembles absolument pas avoir compris le principe même de la conception orientée objet
    Mouais, je pratique depuis suffisamment longtemps pour ne pas avoir besoin de relever ce genre de compliment. Je pense que c'est plutôt toi qui n'a pas compris où je voulais en venir.

    Ta vision des choses me laisse surtout penser que tu considères l'héritage comme le mécanisme des couches d'abstraction, ce qui n'est pourtant pas la même chose même si c'est une des principales utilisations que l'on en fait. Tu peux effectivement utiliser une classe composée de fonctions-membres virtuelles pures ou non pour implémenter une interface, mais cela reste deux notions bien distinctes.

    La définition même de l'héritage, c'est d'étendre une classe existante en lui ajoutant des attributs. De fait, une sous-classe B de A est implicitement une A munie d'éléments supplémentaires. Ça suffit à définir l'héritage, et non seulement ça n'implique pas forcément la virtualité, mais ça n'implique pas non plus la surcharge. Voici un exemple trivial :

    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Fiche
    {
        public:
            string Nom;
            string Prenom;
            string Age;
    };
     
    class FicheEtendue : public Fiche
    {
        public:
            string Adresse;
            string NumeroDeTelephone;
    };
    On est bien d'accord qu'il y a héritage, et qu'une « FicheEtendue » est sémantiquement une « Fiche ». Or, ici, non seulement il n'y a pas de virtualité mais il n'y a même pas de fonction-membre du tout. Sur ce point, il s'agit donc déjà de deux notions complètement distinctes. À l'usage, toute fonction écrite antérieurement à la rédaction de B peut continuer à fonctionner de manière transparente avec des objets de type B sans même le savoir. Et bien sûr, l'idée première est d'éviter d'avoir à réécrire le même code pour l'objet B que pour l'objet A quand il est officiellement avéré que c'est le même.

    On est bien d'accord également que dans ce cas précis, les fonctions qui traitent des objets A n'ont pas besoin d'avoir une vue, même indirecte, sur les nouveaux champs de B pour continuer à faire proprement leur travail.

    Maintenant, est-il nécessaire d'avoir virtualité lorsqu'il y a surcharge ? Pas forcément non plus. Pas parce que le travail reste le même d'une classe à l'autre, mais parce que le comportement à avoir face à l'appelant est censé, par défaut, être celui auquel il s'attend, ce qui est légitime comme concept. Voici un autre exemple : une fonction « Affiche() » qui afficherait à l'écran, ligne par ligne, les champs de l'objet.

    Code : Sélectionner tout - Visualiser dans une fenêtre à part
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Fiche
    {void Affiche ();
    };
     
    class FicheEtendue : public Fiche
    {void Affiche ();
    };
    Est-ce que je dois virtualiser cette fonction ou pas ? Ça dépend du point de vue : soit je sais à l'avance que ma classe va être étendue et je veux récupérer tous les champs, soit je n'affiche les cinq champs que dans les fonctions qui utilisent explicitement « FicheEtendue » et je m'en tiens aux trois champs dans ceux qui utilisent « Fiche », ce qui est le comportement normal hors virtualisation.

    Par ailleurs, poser la virtualisation comme condition nécessaire de l'héritage implique également que les fonctions membres concernées soient déclarées comme telles dans la classe mère, ce qui t'empêcherait de fait d'hériter des classes écrites par des tiers et dont tu ne disposerait pas du code source. À tout le moins, tu pourrais les déclarer virtuelles à partir de tes propres classes, mais pas utiliser tes objets avec les bibliothèques initiales.

    Justement, non

    C'est l'utilisation de l'objet "passant pour etre" du type de base qui doit être transparente, et non sa conception!!!

    Je m'explique :

    Si tu as une classe de base Forme, spécialisée en Cercle, Rectangle et triangle.

    les comportements valides de cette classe de base pourraient parfaitement être "calculeAire", "claculePerimetre" ou encore "tracer".

    En tant qu'utilisateur de la classe Forme, tu es parfaitement en droit de récupérer, par exemple, une collection de pointeurs sur Forme et de les transmettre un à un à une fonction qui "fera quelque chose" des objets "passant pour etre " de type Forme qu'elle recevra.
    Le LSP est résumé en une définition courte et claire : http://fr.wikipedia.org/wiki/Princip...te-subtyping-1

    Si q(x) est une propriété démontrable pour tout objet x de type T, alors q(y) est vraie pour tout objet y de type S tel que S est un sous-type de T.

    Ainsi, la notion de sous-type telle que définie par Liskov et Wing est fondée sur la notion de substituabilité : 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é 3.

    Définition que l'on retrouve page 1 du document original :

    Subtype Requirement: Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

    C'est bien ce qui est écrit : lorsque tu substitues un B à un A en ses lieu et place, il doit se comporter de la même façon, et les propriétés qui sont vraies pour A doivent être vraies pour B aussi et ce, dans ce contexte précis.

    D'où mon exemple. Il a beau être inutile, c'est la démonstration d'un cas où les objets B ne sont pas systématiquement substituables à A. Et ce parce qu'une propriété qui était toujours vraie pour A (taille de la chaîne renvoyée constante) ne l'est plus avec ses dérivés, ne serait-ce que parce qu'il en y a plusieurs. On note bien que la définition du LSP donnée ci-dessus est une proposition mathématique et que le terme « propriété » s'entend dans le sens logique du terme. On ne parle pas ici, en particulier, d'un membre d'une classe ou du résultat d'une de ses fonctions.

    Pour autant, ton exemple avec l'aire reste valide. S'il est établi dès le départ que CalculeAire() renvoie une aire, tu peux virtualiser ta fonction et lui faire également renvoyer une autre aire, même si elle complètement différente. Par contre :

    Mais quel intérêt aurais tu à avoir XXX fois la même chaine de caractères Ne crois tu pas qu'une seule fois serait amplement suffisante
    C'est le même problème : tu sais à l'avance que n objets de type A renverront toujours la même description et qu'il est inutile de la stocker plusieurs fois. Ça te permet par exemple d'éviter de rappeler la même fonction membre lorsque tu fais une itération sur un tableau de A. C'est une propriété démontrable de A. Si tu surcharges cette fonction membre, ça ne changera rien car c'est toujours la fonction membre de A qui sera appelée. Par contre, si tu la virtualises, le code devient invalide parce que l'invariant ne l'est plus.

    Alors évidemment, dans ce dernier cas, la propriété cesse d'exister avec A même, puisque la fonction membre doit déjà être déclarée virtuelle à ce stade. Il n'empêche qu'on retombe quand même sur mes deux assertions de départ :

    • L'héritage n'implique pas la virtualisation (pas plus qu'elle ne l'interdit) ;
    • Ce n'est certainement pas le LSP qui va t'imposer la virtualisation non plus. Au pire, ils seront contradictoires, au mieux indépendants. Mais en aucun cas nécessaires l'un à l'autre.

Discussions similaires

  1. [JMerise] Version 0.3.9.7 - Héritage sans contrainte
    Par aidefz dans le forum JFreesoft
    Réponses: 3
    Dernier message: 14/09/2015, 15h31
  2. Modéliser héritage sans exclusion
    Par Lystik dans le forum UML
    Réponses: 3
    Dernier message: 31/07/2013, 17h24
  3. Implémentation héritage sans relation cyclique
    Par Driss35 dans le forum Diagrammes de Classes
    Réponses: 6
    Dernier message: 03/08/2012, 19h50
  4. [modelisation] Héritage sans nouvelles spécifications
    Par poukill dans le forum Diagrammes de Classes
    Réponses: 8
    Dernier message: 21/06/2007, 14h15
  5. Réponses: 3
    Dernier message: 04/06/2007, 08h34

Partager

Partager
  • Envoyer la discussion sur Viadeo
  • Envoyer la discussion sur Twitter
  • Envoyer la discussion sur Google
  • Envoyer la discussion sur Facebook
  • Envoyer la discussion sur Digg
  • Envoyer la discussion sur Delicious
  • Envoyer la discussion sur MySpace
  • Envoyer la discussion sur Yahoo