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

Guru Of The Week (GOTW) - Problèmes résolus pour les développeurs C++

Date de publication : 22 octobre 2008


I. Problèmes résolus : items 31 à 40
I31. Fonctions virtuelles pures
I31-1. Problème
I31-2. Solution
I32. Macros et préprocesseur
I32-1. Problème
I32-2. Solution
I33. Inlining (fonctions en ligne)
I33-1. Problème
I33-2. Solution
I34. Déclarations anticipées
I34-1. Problème
I34-2. Solution
I35. Le mot clef typename
I35-1. Problème
I35-2. Solution
I36. Initialisation
I36-1. Problème
I36-2. Solution
I37. Héritage multiple - Première partie
I37-1. Problème
I37-2. Solution
I38. Héritage multiple - Deuxième partie
I38-1. Problème
I38-2. Solution
I39. Héritage multiple - Troisième partie
I39-1. Problème
I39-2. Solution
I40. Polymorphisme contrôlé
I40-1. Problème
I40-2. Solution


I. Problèmes résolus : items 31 à 40


I31. Fonctions virtuelles pures

Difficulté : 7 / 10

idea Est-ce toujours une bonne chose de faire une fonction virtuelle pure, tout en fournissant un corps à cette fonction ?

I31-1. Problème

  1. Question JG : Qu'est-ce qu'une fonction virtuelle pure ? Donnez un exemple.
  2. Question Guru : Pourquoi déclarer une fonction virtuelle pure et aussi écrire une définition (body) ? Donnez autant de raisons ou situations que vous pouvez.

I31-2. Solution

1. Qu'est-ce qu'une fonction virtuelle pure ? Donnez un exemple.
Une fonction virtuelle pure est une fonction virtuelle dont vous voulez forcer les classes dérivées. Si une classe comporte des fonctions virtuelles impossibles à instancier, c'est une "classe abstraite" et vous ne pouvez pas créer des objets de ce type.

    class AbstractClass {
    public:
        // déclare une fonction virtuelle pure :
        // cette classe est désormais abstraite
        virtual void f(int) = 0;
    };

    class StillAbstract : public AbstractClass {
        // impossible d'instancier f(int),
        // aussi cette classe est-elle toujours abstraite
    };

    class Concrete : public StillAbstract {
    public:
        // instancie f(int),
        // aussi cette classe est concrète
        void f(int) { /*...*/ }
    };

    AbstractClass a;    // erreur, classe abstraite
    StillAbstract b;    // erreur, classe abstraite
    Concrete      c;    // ok, classe "concrète"
2. Pourquoi déclarer une fonction virtuelle pure et aussi écrire une définition (body) ? Donnez autant de raisons ou situations que vous pouvez
Il y a trois principales raisons pour faire cela : La 1ère est courante, la 2ème est assez rare, et la 3ème est une solution de rechange utilisée de temps en temps par des programmeurs expérimenté travaillant avec des compilateurs un peu faibles. La plupart des programmeurs ne devraient jamais utiliser que la 1e.

  • a. Destructeur virtuel pur
Toutes les classes de base doivent avoir un destructeur virtuel (consultez votre manuel C++ favori pour y trouver les raisons). Si la classe doit être abstraite (vous voulez éviter son instanciation) mais qu'il s'avère qu'elle n'a aucune autre fonction virtuelle pure, voici une technique courante pour créer le destructeur virtuel pur :

    // fichier b.h
    class B {
    public: /*...d'autres choses...*/
        virtual ~B() = 0; // destructeur virtuel pur
    };
Bien sûr, tout destructeur de classe dérivée doit appeler le destructeur de classe de base, ainsi le destructeur doit rester défini (même s'il est vide) :

    // fichier b.cpp
    B::~B() { /* éventuellement vide */ }
Si cette définition ne devait pas être fournie, vous pourriez toujours dériver les classes de B, mais elles ne pourraient jamais être instanciées, ce qui n'est pas particulièrement utile.

  • b. Acceptation consciente forcée d'un comportement par défaut
Si une classe dérivée choisit de ne pas surcharger une fonction membre virtuelle pure, elle se contente d'hériter du comportement de base par défaut de sa version. Si vous voulez fournir un comportement par défaut mais si vous ne voulez pas laisser les classes dérivées simplement en hériter "silencieusement" comme ça, vous pouvez la faire virtuelle pure tout en lui fournissant un comportement par défaut que l'auteur de la classe dérivée devra appeler délibérément s'il le veut :

    class B {
    public:
        virtual bool f() = 0;
    };

    bool B::f() {
        return true;  // c'est un bon comportement par défaut, mais
    }                 // il ne devrait pas être utilisé en aveugle

    class D : public B {
    public:
        bool f() {
            return B::f(); // si D veut le comportement par défaut,
        }             // il doit le dire
    };
  • c. Solution de rechange pour les diagnostics de mauvais compilateurs
Il y a des situations où il se peut que vous mettiez accidentellement fin à une fonction virtuelle pure (indirectement à partir d'un constructeur ou destructeur de base ; référez-vous à votre manuel advanced C++ favori pour avoir des exemples). Bien sûr, un code bien écrit ne devrait normalement pas vous poser ce genre de problème, mais nul n'est parfait et ça peut toujours arriver. Malheureusement, il y a des compilateurs qui ne vous préviendront pas de ce problème. Ceux qui ne le font pas peuvent vous donner de mauvais messages d'erreur qui prennent un temps infini à dépister. "Argh" criez-vous quand vous diagnostiquez enfin le problème par vous-même quelques heures plus tard. "Pourquoi le compilateur ne m'a-t-il pas simplement dit ce que c'était ?!"

Une façon de vous protéger contre cette perte de temps à debugger votre programme consiste à fournir des définitions pour des fonctions virtuelles pures qui ne devraient jamais être appelées, et à introduire de vrais codes erronés dans ces définitions qui vous feront immédiatement savoir si vous les avez appelés par accident. Exemple :

    class B {
    public:
        virtual bool f() = 0;
    };

    bool B::f() {   // ceci ne devrait JAMAIS être appelé
        if( PromptUser( "pure virtual B::f called -- "
                        "abort or ignore?" ) == Abort )
            DieDieDie();
    }
Dans la classique fonction DieDieDie(), faites ce qu'il faut sur votre système pour accéder au debogueur ou afficher une pile ou obtenir d'une manière ou d'une autre des informations de diagnostic. Voici quelques méthodes courantes qui vous feront passer dans le débogueur sur la plupart des systèmes. Prenez celle que vous préférez :

    void DieDieDie() {  // on plante par le biais du pointeur nul
        memset( 0, 1, 1 );
    }

    void DieDieDie() {  // une autre méthode à la C
        assert( false );
    }

    void DieDieDie() {  // retour au dernier "catch(...)"
        class LocalClass {};
        throw LocalClass();
    }

    void DieDieDie() {  // pour les "standardophiles"
        throw logic_error();
Vous saisissez le concept… Soyez créatif. :-)


I32. Macros et préprocesseur

Difficulté : 4 / 10

idea Avec toutes les caractéristiques type-safe (typage fort, etc) de C++, pourquoi devriez-vous encore utiliser #define ?

I32-1. Problème

  1. Avec des caractéristiques souples comme la surcharge et les templates, qui sont fortement typés, pourquoi un programmeur C++ devrait-il jamais écrire "#define" ?

I32-2. Solution

Les caractéristiques de C++ éliminent souvent, mais pas toujours, le besoin d'utiliser #define. Par exemple, l'instruction "const int c = 42;" est meilleure que "#define c 42" parce qu'elle assure la sécurité du type, évite les éditions accidentelles pré-processeurs, et pour quelques autres raisons. Il existe néanmoins toujours quelques bonnes raisons d'écrire #define.

1. Header Guards (gardes d'entête)
C'est une astuce habituelle pour éviter les inclusions multiples d'en-tête :

    #ifndef MYPROG_X_H
    #define MYPROG_X_H

    // ... le reste du fichier d'en-tête x.h va ici...

    #endif
2. Accès aux caractéristiques pré-processeur
Souvent, on aime bien insérer des choses comme des numéros de ligne et des temps de construction en code diagnostic. Une façon facile de le faire consiste à utiliser des macro prédéfinies telles que __FILE__, __LINE__, __DATE__ et __TIME__. Pour la même raison et pour d'autres, il est souvent utile d'utiliser les opérateurs pré-processeurs de transformation en chaîne littérale et de concaténation de symboles (# et ##).

3. Code spécifique à la compilation
C'est la catégorie d'utilisation la plus riche et la plus importante pour le pré-processeur. Bien que je sois tout sauf un fan de la magie du pré-processeur, il y a des choses que vous ne pouvez pas faire aussi bien autrement, voire même que vous ne pouvez pas faire du tout autrement.

  • a) Code de débogage
Parfois, vous voulez bâtir votre système avec certaines pièces de code "supplémentaires" (typiquement des informations de débogage), et parfois vous ne le voulez pas :

    void f()
    {
    #ifdef MY_DEBUG
        cerr << "some trace logging" << endl;
    #endif

        // ... le reste de f() va ici...
    }
Il est possible de faire ça au moment où vous lancez le programme, bien sûr. En prenant la décision au moment de la compilation, vous évitez à la fois la perte de temps et la souplesse de différer la décision jusqu'au moment de lancer le programme.

  • b) Code spécifique de plateforme
Habituellement, il vaut mieux traiter le code spécifique de la plateforme en usine pour assurer une meilleure organisation du code et une plus grande souplesse pendant le fonctionnement du programme. Parfois néanmoins, il n'y a tout simplement pas assez de différences pour justifier en retour en usine, et le pré-processeur peut être une façon utile de modifier les codes facultatifs.

  • c) Variantes de représentation des données
Un exemple courant est qu'un module peut définir une liste de codes d'erreur, que des utilisateurs extérieurs devraient voir comme une simple énumération avec commentaires, mais qui, à l'intérieur du module, devrait être stockée dans une carte pour un affichage facile. Cela donne :

    // Pour les utilisateurs externes :
    enum Errors {
      ERR_OK = 0,           // No error
      ERR_INVALID_PARAM = 1 // <description>
      ...
    }

    // Pour l'utilisation interne du module :
    map<Error,const char*> lookup;
    lookup.insert( make_pair( Error(0), "No error" ) );
    lookup.insert( make_pair( Error(1), "<description>" ) );
    // ...
Nous aimerions avoir les deux représentations sans définir les informations concrètes (code/msg pairs) deux fois. Avec la magie des macro, on peut simplement écrire une liste d'erreur comme suit, en créant la structure qui convient au moment de la compilation :

    DERR_ENTRY( ERR_OK,            0, "No error" ),
    DERR_ENTRY( ERR_INVALID_PARAM, 1, "<description>" ),
    //...
Les implémentations de DERR_ENTRY et des macros qui y sont liées sont laissées au lecteur.

Ce sont trois exemples courants ; il y en a beaucoup d'autres.


I33. Inlining (fonctions en ligne)

Difficulté : 4 / 10

idea Contrairement à l'opinion populaire, le mot clef inline n'est pas une sorte de formule magique. C'est néanmoins un outil utile quand on l'emploie comme il faut. La question est : quand doit-on l'utiliser ?

I33-1. Problème

  1. "Inliner" une fonction augmente-t-il son efficacité ?
  2. Quand et comment décider d'"inliner" une fonction ?

I33-2. Solution

info Ecrivez ce que vous savez, sachez ce que vous écrivez.
1. Inliner une fonction augmente-t-il son efficacité ?
Pas nécessairement.

Avant tout, si vous avez essayé de répondre à cette question sans vous demander tout d'abord ce que vous vouliez optimiser, vous êtes tombé dans un piège classique. La première question doit être : "Qu'entendez-vous par efficacité ?" Cela signifie-t-il minimiser la taille du programme ? l'empreinte mémoire ? le temps d'exécution ? maximiser la vitesse de développement ? le process ? autre chose ?

Ensuite, contrairement à l'opinion populaire, l'inlining peuvent améliorer OU aggraver l'un de ces éléments :

  1. Taille du programme : Beaucoup de programmeurs présument de l'inlining augmente la taille des programmes, parce qu'au lieu d'avoir une seule copie d'un code de fonction, le compilateur en crée une copie partout où la fonction est utilisée. C'est souvent vrai, mais pas toujours : si la taille de la fonction est inférieure à celle du code que le compilateur doit générer pour appeler la fonction, l'inlining réduira la taille du programme.
  2. Empreinte mémoire : Habituellement, l'inlining a peu ou pas d'effet sur l'utilisation de la mémoire par le programme, excepté au niveau de la taille de base du programme (voir plus haut).
  3. Temps d'exécution : Beaucoup de programmeurs présument qu'inliner une fonction améliore son temps d'exécution, parce que cela évite la perte de temps de l'appel de fonction, et parce que "voir à travers le voile" de l'appel de fonction donne à l'optimisateur de compilation davantage d'opportunités pour faire son travail. Cela peut être vrai, mais souvent ça ne l'est pas : si la fonction n'est pas appelée très souvent, il n'y aura habituellement pas d'amélioration visible du temps global d'exécution du programme. En fait, il peut même se produire exactement le contraire : si l'inlining augmente la taille de la fonction appelée, il réduit les caractéristiques de groupement de l'appelant, ce qui signifie que la vitesse globale du programme peut être de fait réduite si la boucle interne de l'appelant ne correspond plus au cache du processeur. Pour mettre ce point en perspective, n'oubliez pas que la plupart des programmes ne sont pas liés à l'unité centrale. Il est probable que le goulot d'étranglement le plus courant soit la liaison entrée/sortie, ce qui peut inclure n'importe quoi, depuis la bande passante du réseau au délai d'attente pour l'accès à un fichier ou à une base de données.
  4. Vitesse de développement, temps d'exécution : Pour être le plus utile possible, le code inliné doit être visible pour l'appelant, ce qui signifie que l'appelant doit dépendre du contenu du code inliné. Dépendre des détails internes d'implémentation d'un autre module augmente le couplage pratique des modules (néanmoins cela n'augmente pas leur couplage théorique, parce que l'appelant n'utilise pas effectivement le contenu des modules appelés). Habituellement, quand les fonctions changent, il n'est pas nécessaire de recompiler les appelants (seuls leurs liens sont révisés, et encore…). Quand des fonctions inlinées changent, les appelants sont obligés de recompiler.
Finalement, si vous cherchez à améliorer l'efficacité d'une manière ou d'une autre, commencez toujours par examiner vos algorithmes et vos structures de données... ils vous donneront des améliorations globales de plusieurs ordres de grandeur, alors que les optimisations de process, comme généralement l'inlining (notez bien "généralement") donneront des résultats moins sensibles.

info Sachez dire "Pas maintenant"
2. Quand et comment décider d'"inliner" une fonction ?
Comme pour n'importe quelle autre optimisation : lorsqu'un profileur vous a dit de le faire, et pas une minute avant. Les seuls moments où vous pourriez inliner immédiatement, c'est avec une fonction vide qui restera probablement vide, ou si vous êtes absolument obligé de le faire, en général quand vous écrivez une template non-exporté.

En définitive, l'inlining a toujours un coût, au minimum davantage de couplage, et vous ne devriez jamais payer pour quoi que ce soit avant de savoir que vous en tirerez un profit - c'est à dire quelque chose de mieux en retour. "Mais je peux toujours dire où se trouvent les goulots d'étranglement" vous dites-vous ? Ne vous inquiétez pas, vous n'êtes pas seul. La plupart des programmeurs pensent cela un jour ou l'autre, mais ils ont tort. Sur toute la ligne. Il est notoire que les programmeurs devinent mal où se trouvent vraiment les goulots d'étrangement de leur code.

Habituellement, seules les preuves expérimentales (profilage) vous aident à dire où se situent les vrais points chauds. Neuf fois sur dix, un programmeur ne peut pas identifier le principal goulot d'étranglement dans son code sans un outil de profilage. Après plus d'une décennie dans ce business, j'attends encore de voir une exception conséquente chez un programmeur avec qui j'aurais travaillé ou dont j'aurais entendu parler... même si tout un chacun peut toujours proclamer haut et fort que cette règle ne s'applique pas à lui. :-)

[Notez une autre raison pratique à cela : les profileurs ne sont pas aussi bons pour identifier les fonctions inlinées qui ne DOIVENT PAS être inlinées.]

info En ce qui concerne les tâches à forte densité de calcul (ex. bibliothèques numériques) ?
Certaines personnes rédigent des codes de bibliothèques petits et serrés, tels que des bibliothèques scientifiques et d'ingénierie avancées, et s'en sortent parfois assez bien avec un inlining sans instruments. Mais même ces développeurs ont tendance à inliner judicieusement et à faire les réglages plutôt tard que tôt. Notez que rédiger un module puis comparer les performances avec "inlining on" et "inlining off" est généralement une mauvaise idée, parce que "all on" ou "all off" est une mesure grossière qui ne vous parlera que du cas moyen... ça ne vous dira pas QUELLES fonctions ont bénéficié de l'inlining (ni dans quelle mesure). Et même dans ces cas, habituellement vous avez plutôt intérêt à utiliser un profileur et à optimiser votre code en vous basant sur ses conseils.

info En ce qui concerne les accesseurs ?
Certains personnes m'objecteront que les fonctions accesseurs une ligne (du genre "X& Y::f() { return myX_; }") sont une exception raisonnable, et pourraient/devraient être automatiquement inlinées. Je comprends le raisonnement, mais faites attention : en fin de compte, tout code inliné augmente le couplage, aussi à moins que vous soyez d'avance certain que l'inlining vous aidera, ça ne coûte rien de repousser cette décision au moment du profilage. Plus tard, quand le code sera stable, un profileur pourra faire ressortir ce que l'inlining peut aider, et dans quelle mesure : a) vous saurez que ce que vous faites vaut la peine d'être fait ; et b) vous aurez évité tous les couplages et les possibles pertes de temps jusqu'à la fin du cycle de développement de projet. Pas mal...

En résumé.
A partir des normes de codage GotW :

  • évitez l'inlining et tout réglage détaillé tant que les profils de performances n'en ont pas prouvé le besoin
  • corollaire : en général, évitez l'inlining
[Notez bien qu'il est bien dit "évitez" (et non pas "ne faites jamais") et que ces mêmes normes de codage encouragent l'inlining précoce dans une ou deux situations restreintes]


I34. Déclarations anticipées

Difficulté : 8 / 10

idea Les déclarations anticipées sont un moyen formidable d'éliminer des dépendances de temps de compilation inutiles. Mais voici un exemple de piège typique des déclarations anticipées... Comment l'éviteriez-vous ?

I34-1. Problème

Question JG

  • 1. Les déclarations anticipées sont des outils très utiles. Dans ce cas, elles ne marchent pas comme ce à quoi le programmateur s'attend. Pourquoi les lignes marquées sont-elles des erreurs ?

    // file f.h
    #ifndef XXX_F_H_
    #define XXX_F_H_

    class ostream;  // error
    class string;   // error

    string f( const ostream& );

    #endif
Question Guru

  • 2. Sans inclure aucun autre fichier, pouvez-vous écrire les déclarations anticipées correctes pour ostream et string ci-dessus ?

I34-2. Solution

1. Les déclarations anticipées sont des outils très utiles. Dans ce cas, elles ne marchent pas comme ce à quoi le programmateur s'attend. Pourquoi les lignes marquées sont-elles des erreurs ?

Malheureusement, vous ne pouvez pas déclarer de manière anticipée ostream et string de cette façon, parce que ce ne sont pas des classes... ce sont des typedefs de templates. (c'est vrai, autrefois vous déclariez de manière anticipée ostream et string de cette façon, mais c'était il y a plusieurs années, et ce n'est plus possible en C++ Standard).

2. Sans inclure aucun autre fichier, pouvez-vous écrire les déclarations anticipées correctes pour ostream et string ci-dessus ?

Malheureusement, la réponse est qu'il n'y a pas de façon standard ni pratique de le faire. La norme dit :

info Ajouter des déclarations ou des définitions à l'espace de nom std ou à des espaces de noms au sein de l'espace de noms std n'est pas défini pour un programme C++, sauf spécifié autrement.
Entre autres choses, cela permet aux prescripteurs de fournir des implémentations de la librairie standard qui ont davantage de paramètres de template pour les templates de librairie que ce que la norme exige (convenablement paramétré par défaut, bien sûr, pour rester compatible). Le mieux que vous puissiez faire (ce qui n'est pas une solution au problème "sans inclure aucun autre fichier") est ceci :

    #include <iosfwd>
    #include <string>
L'en-tête iosfwd contient bona fide une déclaration anticipée. Pas l'en-tête string. C'est tout ce que vous pouvez faire qui soit encore pratique. Heureusement, faire une déclaration anticipée de string et ostream n'est pas un gros problème en pratique, car ces lignes sont généralement courtes et largement utilisées. Cela vaut aussi pour la plupart des en-têtes standard. Toutefois, faites attention aux pièges, et résistez à la tentation de lancer des templates à déclaration anticipée – ni quoi que ce soit d'autre – appartenant à l'espace de nom std... c'est réservé aux éditeurs de compilateurs et de bibliothèque, et à eux seuls.


I35. Le mot clef typename

Difficulté : 9,5 / 10

idea "Qu'y a-t-il dans un nom (de type) ?" Voici un exercice qui démontre pourquoi et comment utiliser typename, en utilisant un idiome courant dans la librairie standard.

I35-1. Problème

Question Guru : Y a-t-il quelque chose de mauvais dans le code ci-dessus ? Si oui, quoi ?

    template<class T>
    struct X_base {
      typedef T instantiated_type;
    };

    template<class A, class B>
    struct X : public X_base<B> {
      bool operator()( const instantiated_type& i ) {
        return ( i != instantiated_type() );
      }
      // ...
    };

I35-2. Solution

Y a-t-il quelque chose de mauvais dans le code ci-dessus ? Si oui, quoi ?
Cet exemple illustre le problème de pourquoi et comment utiliser "typename" pour se référer à des noms dépendants, et peut apporter un éclairage à la question : "Qu'est-ce qu'un nom ?"

    template<class T>
    struct X_base {
      typedef T instantiated_type;
    };

    template<class A, class B>
    struct X : public X_base<B> {
      bool operator()( const instantiated_type& i ) {
        return ( i != instantiated_type() );
      }
      // ... 
    };
1. Utilisation de "typename" pour des noms dépendants

Le problème avec X est que "instantiated_type" est sensé se référer au typedef supposé hérité de la classe de base X_base<B>. Malheureusement, au moment où le compilateur doit analyser la définition inlignée de X<A,B>::operator()(), les noms dépendants (c'est à dire les noms qui dépendent des paramètres de template, comme le nom hérité X_Base::instantiated_type) ne sont pas visibles, aussi le compilateur se plaindra-t-il de ne pas savoir ce que "instantiated_type" est sensé signifier. Les noms dépendants n'apparaîtront que plus tard, au moment où le template sera effectivement instancié.

Si vous vous demandez pourquoi le compilateur n'a pas pu comprendre, imaginez que vous êtes un compilateur et demandez-vous comment comprendre ce que "instantiated_type" signifie ici. A la dernière ligne, vous ne pouvez pas comprendre, parce que vous ne savez pas encore ce qu'est B, et si plus tard il n'y aura pas une spécialisation pour X_base<B> qui ne fera pas de X_base<B>::instantiated_type quelque chose d'inattendu – un nom de type, ou même une variable membre. Dans le template non-spécialisé X_base ci-dessus, X_base<T>::instantiated_type sera toujours T, mais rien ne peut empêcher quelqu'un de changer cela pour une spécialisation, par exemple :

    template<>
    struct X_base<int> {
        typedef Y instantiated_type;
    };
Accepté, le nom du typedef induirait un peu en erreur, mais c'est légal. Ou même :

    template<>
    struct X_base<double> {
        double instantiated_type;
    };
A présent, le nom induit moins en erreur, mais le template X ne peut pas fonctionner avec X_base<double> comme une classe de base parce que instantiated_type est une variable membre, pas un nom de type.

A la dernière ligne, le compilateur ne saura pas comment analyser la définition de X<A,B>::operator()() à moins que nous disions ce qu'est instantiated_type... au minimum si c'est un type ou autre chose. Ici, nous voulons que ce soit un type. La façon de dire au compilateur que quelque chose comme cela est sensé être un nom de type consiste à entrer le mot clef "typename". Pour nous, il y a deux façons de s'y prendre : la moins élégante consiste à simplement écrire typename partout où nous nous référons au instantiated_type :

    template<class A, class B>
    struct X : public X_base<B> {
      bool operator()
        ( const typename X_base<B>::instantiated_type& i )
      {
        return
          ( i != typename X_base<B>::instantiated_type() );
      }
      // ... 
    };
J'espère que vous avez tressailli en lisant cela. Comme d'habitude, les typedefs rendent ce genre de chose beaucoup plus lisible, et en fournissant un autre typedef, le reste de la définition fonctionne comme il a été écrit à l'origine :

    template<class A, class B>
    struct X : public X_base<B> {
      typedef typename X_base<B>::instantiated_type
              instantiated_type;
      bool operator()( const instantiated_type& i ) {
        return ( i != instantiated_type() );
      }
      // ... more stuff ...
    };
Avant d'aller plus loin dans la lecture, y a-t-il quoi que ce soit qui vous paraisse inhabituel concernant l'ajout de ce typedef ?

2. Le point secondaire (et subtil)

J'aurais pu utiliser des exemples plus simples pour illustrer cela (plusieurs apparaissent dans la norme au chapitre 14.6.2), mais cela n'aurait pas fait ressortir la chose inhabituelle : La seule raison d'être de la base vide X_base semble être de fournir le typedef. Toutefois, les classes dérivées finissent habituellement simplement par un nouveau typedef. Cela ne semble-t-il pas redondant ? ça l'est, mais seulement un petit peu... après tout, c'est toujours la spécialisation de X_base<> qui est responsable de la détermination de ce que le type approprié doit être, et ce type peut changer pour différentes spécialisations.

La librairie standard contient des classes de base comme ceci : "bags-o-typedefs". Elles sont destinées à être utilisées d'une seule façon. Nous espérons que ce problème de GotW nous aidera à prévenir certaines questions concernant pourquoi des classes dérivées "re-typedef-ent" ces typedefs, de façon apparemment redondante, et à montrer que cet effet n'est pas vraiment une défaillance de la conception de langage et qu'il est juste une autre facette de cette question vieille comme le monde : "Qu'est-ce qu'un nom ?"

Plaisanterie

En bonus, voici une petite plaisanterie :

    #include <iostream>
    using std::cout;
    using std::endl;

    struct Rose {};

    struct A { typedef Rose rose; };

    template<class T>
    struct B : T { typedef typename T::rose foo; };

    template<class T>
    void smell( T ) { cout << "awful" << endl; }

    void smell( Rose ) { cout << "sweet" << endl; }

    int main() {
        smell( A::rose() );
        smell( B<A>::foo() );
    }
:-)


I36. Initialisation

Difficulté : 3 / 10

idea Quelle est la différence entre initialisation directe et initialisation de copie, et quand doit-on les utiliser ?

I36-1. Problème

Question JG

1. Quelle est la différence entre "initialisation directe" et "initialisation de copie" ?

Question Guru

2. Lesquels des cas suivants utilisent l'initialisation directe, et lequels utilisent l'initialisation de copie ?

  struct T : S {
    T() : S(1),             // initialisation de base
          x(2) {}           // initialisation de membre
    X x;
  };

  T f( T t ) {              // passage d'un argument de fonction
    return t;               // retour d'une valeur
  }

  S s;
  T t;
  S& r = t;
  reinterpret_cast<S>(t);   // effectuer un reinterpret_cast
  static_cast<S>(t);        // effectuer un static_cast
  dynamic_cast<T&>(r);      // effectuer un dynamic_cast
  const_cast<const T&>(t);  // effectuer un const_cast

  try {
    throw T();              // lancer une exception
  } catch( T t ) {          // traiter une exception
  }

  f( T(s) );                // conversion de type notation fonctionnelle
  S a[3] = { 1, 2, 3 };     // initialiseurs entre accolades
  S* p = new S(4);          // expression new

I36-2. Solution

1. Quelle est la différence entre "initialisation directe" et "initialisation de copie" ?

L'initialisation directe signifie que l'objet est initialisé avec un constructeur unique (éventuellement de conversion), et équivaut à la forme "T t(u);":

    U u;
    T t1(u); // appelle T::T( U& ) ou similaire
L'initialisation de copie signifie que l'objet est initialisé avec un constructeur de copie, après avoir appelé pour la première fois une conversion définie par l'utilisateur si nécessaire. Elle équivaut à la forme "T t = u;":

    T t2 = t1;  // même type : appelle T::T( T& ) ou similaire
    T t3 = u;   // types différents: appelle T::T( T(u) )
                //  ou T::T( u.operator T() ) ou similaire
[En outre : La raison d'être des "ou similaire" ci-dessus est que les constructeurs de copie et conversion pourraient prendre quelque chose de légèrement différent comme référence (la référence pourrait être const ou volatile ou les deux), et le constructeur ou opérateur de conversion défini par l'utilisateur pourrait en plus prendre ou renvoyer un objet plutôt qu'une référence.]

NOTE : Dans le dernier cas ("T t3 = u;"), le compilateur pourrait appeler à la fois la conversion définie par l'utilisateur (pour créer un objet temporaire) et le constructeur de copie T (pour construire t3 à partir du temporaire), ou il pourrait choisir d'éluder le temporaire et de construire t3 directement à partir de u (ce qui finirait pour équivaloir à "T t3(u);"). Depuis juillet 1997 et dans le projet final de norme, la latitude du compilateur à élider des objets temporaires a été restreinte, mais c'est encore permis pour cette optimisation et pour l'optimisation de la valeur retournée. Pour plus de détails, cf. GotW #1 (pour les bases) et GotW #27 (pour les changements de 1997).

2. Lesquels des cas suivants utilisent l'initialisation directe, et lequels utilisent l'initialisation de copie ?

Le chapitre 8.5 [dcl.init] de la norme couvre la plupart de ces cas. Il y a eu aussi trois pièges qui en fait n'impliquent pas du tout l'initialisation... Les avez-vous repérés ?

  struct T : S {
    T() : S(1),             // initialisation de base
          x(2) {}           // initialisation de membre
    X x;
  };
L'initialisation de base et l'initialisation de membre utilisent toutes les deux une initialisation directe.

  T f( T t ) {              // passage d'un argument de fonction
    return t;               // retour d'une valeur
  }
Passer et retourner des valeurs utilisent tous les deux une initialisation de copie.

  S s;
  T t;
  S& r = t;
  reinterpret_cast<S>(t);   // effectuer un reinterpret_cast
Piège : reinterpret_cast n'initialise aucun objet : cela oblige seulement la réinterprêtation de ses bits comme s'ils désignaient un S.

static_cast<S>(t);        // effectuer un static_cast
static_cast utilise une initialisation directe.

  dynamic_cast<T&>(r);      // effectuer un dynamic_cast
  const_cast<const T&>(t);  // effectuer un const_cast
Autres pièges : Aucune initialisation d'objet nouveau n'est impliquée dans ces deux cas.

  try {
    throw T();              // lancer une exception
  } catch( T t ) {          // traiter une exception
  }
Lancer et attraper un objet d'exception utilise l'un et l'autre une initialisation de copie. Notez que dans ce code particulier, il y a deux copies pour un total de trois objets T : Une copie de l'objet lancé est faite à l'emplacement du lancement, et dans ce cas une seconde copie est faite parce que le traite-exception attrape l'objet lancé par valeur.

  f( T(s) );                // conversion de type notation fonctionnelle
La conversion de type "Constructor syntax" utilise l'initialisation directe.

  S a[3] = { 1, 2, 3 };     // initialiseurs entre accolades
Les initialiseurs entre accolades utilisent l'initialisation de copie.

  S* p = new S(4);          // expression new
Les expressions New utilisent l'initialisation directe.


I37. Héritage multiple - Première partie

Difficulté : 6 / 10

idea Certains langages, y compris la nouvelle norme SQL3, continuent à hésiter quant à savoir s'ils doivent ou non accepter l'héritage simple ou multiple. Ce GotW vous invite à envisager les problèmes.

I37-1. Problème

Question JG

1. Qu'est-ce que l'héritage multiple, et quelles possibilités ou complications l'héritage multiple introduit-il dans C++ ?

Question Guru

2. L'héritage multiple est-il nécessaire ? Si oui, montrez autant de situations différentes que vous pouvez et donnez des arguments pour que l'héritage multiple soit présent dans un langage. Sinon, dites pourquoi l'héritage simple (éventuellement combiné à des interfaces de type Java) vaut au moins aussi bien et pourquoi l'héritage multiple ne devrait pas être présent dans un langage.


I37-2. Solution

1. Qu'est-ce que l'héritage multiple, et quelles possibilités ou complications l'héritage multiple introduit-il dans C++ ?

Très brièvement : L'héritage multiple signifie la possibilité d'hériter de plusieurs classes de base directes. Par exemple :

    class Derived : public Base1, private Base2
    {
      //...
    };
Permettre l'héritage multiple introduit la possibilité qu'une classe ayant la même classe de base (directe ou indirecte) apparaisse plus d'une fois comme ancêtre. Voici un exemple de cette forme de "diamant de la mort" :

        B
       / \
     C1   C2
       \ /
        D
Ici, B est deux fois une classe de base indirecte de D : une fois par C1 et une fois par C2.

Cette situation introduit le besoin d'une caractéristique supplémentaire dans C++ : l'héritage virtuel. Le programmateur veut-il que D ait un sous-objet B ou deux ? Si c'est un, B devrait être une classe de base virtuelle ; si c'est deux, B devrait être une classe de base normale (non-virtuelle). Finalement, la principale complication des classes de base virtuelles est qu'elles doivent être initialisées directement par la classe la plus dérivée. Pour plus d'informations là-dessus et sur d'autres aspects de l'héritage multiple, reportez-vous à un bon texte comme les ouvrages C++PL3 ou "Effective C++" de Meyer.

2. L'héritage multiple est-iI nécessaire ?

Brièvement : Aucune caractéristique n'est strictement "nécessaire" dans la mesure où tout programme peut être écrit en assembleur. Toutefois, comme la plupart des gens préfèrerait ne pas coder leur propre mécanisme de fonction virtuelle en C, dans certains cas ne pas disposer de l'héritage multiple implique de pénibles travaux alternatifs.

Si oui, montrez autant de situations différentes que vous pouvez et donnez des arguments pour que l'héritage multiple soit présent dans un langage. Sinon, dites pourquoi l'héritage simple (éventuellement combiné à des interfaces de type Java) vaut au moins aussi bien et pourquoi l'héritage multiple ne devrait pas être présent dans un langage.

Brièvement : Oui et non. Comme n'importe quel outil, l'héritage multiple doit être utilisé avec précautions. Utiliser l'héritage multiple ajoute de la complexité (cf. Question JG ci-dessus), mais il y a des situations où cela reste plus simple et plus facile à corriger que les solutions alternatives. Comme certains ont pu le dire : "On n'a pas souvent besoin de l'héritage multiple, mais quand on en a besoin, on en a VRAIMENT besoin."

Il existe de nombreuses situations où l'héritage multiple est un outil pratique et approprié. J'en couvrirai seulement trois (en fait, la plupart des utilisations entrent dans l'une de ces trois catégories) :

  • 1. Classes d'Interface (Classes de base abstraites pures)
En C++, la meilleure et la plus sure des utilisations de l'héritage multiple consiste à définir des classes d'interface, c'est à dire des classes composées de rien d'autre que des fonctions virtuelles pures. En particulier, c'est l'absence de membres de données dans la classe de base qui évite les célèbres complexités de l'héritage multiple.

De façon intéressante, différents langages/modèles supportent ce type "d'héritage multiple" via des mécanismes de non-héritage. Deux exemples sont donnés par Java et COM : Java n'a que l'héritage simple, mais il admet la notion qu'une classe puisse implémenter de multiples "interfaces" dans lesquelles les interfaces sont très similaires aux ABC pures de C++. COM n'a pas d'héritage, mais de façon similaire, il admet une notion de composition d'interface, et ce modèle est similaire à une combinaison d'interfaces Java et de templates C++.

  • 2. Combiner les Modules/Librairies
Beaucoup de classes sont conçues pour être des classes de base ; c'est à dire que pour les utiliser, vous êtes sensé en hériter. Conséquence naturelle : Que faire si vous voulez écrire une classe qui prolonge deux librairies et s'il vous faut hériter d'une classe dans chacune des deux ? Puisque habituellement vous n'avez pas la possibilité de changer le code de librairie (si vous avez acheté la librairie auprès d'une tierce partie, ou si c'est un module produit par une autre équipe dans votre entreprise), l'héritage multiple est nécessaire.

  • 3. Facilité d'utilisation (polymorphe)
Il y a des exemples où permettre l'héritage multiple simplifie grandement l'utilisation du même objet de différentes façons. On trouve un bon exemple dans C++PL3 14.2.2 qui montre une conception basée sur l'héritage multiple pour des classes d'exceptions, où la classe d'exceptions la plus dérivée peut avoir une relation polymorphe IS-A avec plusieurs classes de base directes. Notez que le cas n°1 recoupe largement le cas n°3.

Finalement, n'oubliez pas que "l'héritage public polymorphe LSP IS-A" n'est pas le seul jeu auquel vous pouvez jouer ; il y a beaucoup d'autres raisons possibles pour utiliser l'héritage. Le résultat est que parfois il est non seulement nécessaire d'hériter de plusieurs classes de base, mais de le faire pour différentes raisons pour chacune d'elles. Par exemple, une classe peut avoir besoin d'hériter de façon privée d'une classe de base A pour avoir accès aux membres protégés de la classe A, mais en même temps d'hériter de façon publique d'une classe de base B pour mettre en action de façon polymorphe une fonction virtuelle de la classe B.


I38. Héritage multiple - Deuxième partie

Difficulté : 8 / 10

idea Si vous n'avez pas pu utiliser l'héritage multiple, comment l'émuler ? N'oubliez pas d'émuler une syntaxe aussi naturelle que possible pour le code du client.

I38-1. Problème

1. Considérons l'exemple suivant :

  struct A      { virtual ~A() { }
                  virtual string Name() { return "A";  } };
  struct B1 : virtual A { string Name() { return "B1"; } };
  struct B2 : virtual A { string Name() { return "B2"; } };

  struct D  : B1, B2    { string Name() { return "D";  } };
Démontrez la meilleure façon que vous puissiez trouver pour "contourner" le fait de ne pas utiliser l'héritage multiple en écrivant une classe D équivalente (ou aussi proche que possible) sans utiliser l'héritage multiple. Comment obtiendriez-vous le même effet et la même capacité d'utilisation pour D avec aussi peu de changements que possible de la syntaxe dans le code client ?

Début : Vous pouvez commencer en envisageant les cas du schéma de test suivant :

void f1( A&  x ) { cout << "f1:" << x.Name() << endl; }
void f2( B1& x ) { cout << "f2:" << x.Name() << endl; }
void f3( B2& x ) { cout << "f3:" << x.Name() << endl; }

void g1( A   x ) { cout << "g1:" << x.Name() << endl; }
void g2( B1  x ) { cout << "g2:" << x.Name() << endl; }
void g3( B2  x ) { cout << "g3:" << x.Name() << endl; }

int main() {
    D   d;
    B1* pb1 = &d;   // conversion D* -> B*
    B2* pb2 = &d;
    B1& rb1 = d;    // conversion D& -> B&
    B2& rb2 = d;

    f1( d );        // polymorphisme
    f2( d );
    f3( d );

    g1( d );        // découpage
    g2( d );
    g3( d );
                    // dynamic_cast/RTTI
    cout << ( (dynamic_cast<D*>(pb1) != 0)
            ? "ok " : "bad " );
    cout << ( (dynamic_cast<D*>(pb2) != 0)
            ? "ok " : "bad " );

    try {
        dynamic_cast<D&>(rb1);
        cout << "ok ";
    } catch(...) {
        cout << "bad ";
    }
    try {
        dynamic_cast<D&>(rb2);
        cout << "ok ";
    } catch(...) {
        cout << "bad ";
    }
}

I38-2. Solution

Démontrez la meilleure façon que vous puissiez trouver pour "contourner" le fait de ne pas utiliser l'héritage multiple en écrivant une classe D équivalente (ou aussi proche que possible) sans utiliser l'héritage multiple. Comment obtiendriez-vous le même effet et la même capacité d'utilisation pour D avec aussi peu de changements que possible de la syntaxe dans le code client ?

Il existe un peu nombre de stratégies possibles, chacune avec ses points faibles, mais en voici une qui s'approche très près du but :

    struct D : B1 {
        struct D2 : B2 {
            void   Set ( D* d ) { d_ = d; }
            string Name();
            D* d_;
        } d2_;

        D()                 { d2_.Set( this ); }

        D( const D& other ) : B1( other ), d2_( other.d2_ )
                            { d2_.Set( this ); }

        D& operator=( const D& other ) {
                              B1::operator=( other );
                              d2_ = other.d2_;
                              return *this;
                            }

        operator B2&()      { return d2_; }

        B2& AsB2()          { return d2_; }

        string Name()       { return "D"; }
    };
    string D::D2::Name()    { return d_->Name(); }
On a pour inconvénients que :

  • Fournir l'opérateur B2& fait référence de façon discutable à un traitement spécial (inconsistant) des pointeurs
  • Il vous faut appeler explicitement D::AsB2() pour utiliser D comme un B2 (dans le cadre du test, cela signifie changer "B2* pb2 = &d;" en "B2* pb2 = &d.AsB2();")
  • dynamic_cast de D* en B2* ne marche toujours pas (il est possible de contourner ce problème si vous voulez utiliser le pré-processeur pour redéfinir les appels dynamic_cast).
Il est intéressant, et vous aurez peut-être observé que la conception de l'objet D en mémoire est probablement identique à ce qu'aurait donné l'héritage multiple. C'est parce que nous essayons de simuler l'héritage multiple, simplement sans toute la simplicité syntaxique que pourrait fournir l'appui d'un langage incorporé.


I39. Héritage multiple - Troisième partie

Difficulté : 4 / 10

idea Il est facile de forcer des fonctions héritées virtuelles – tant que vous n'essayez pas de forcer une fonction virtuelle qui a la même signature dans deux classes de base. ça peut arriver même quand les classes de base ne proviennent pas de vendeurs différents !

I39-1. Problème

1. Considérons les deux classes suivantes :

  class B1 {
  public:
    virtual int ReadBuf( const char* );
    // 
  };

  class B2 {
  public:
    virtual int ReadBuf( const char* );
    // 
  };
Elles sont clairement destinées toutes les deux à être utilisées comme classes de base mais elles sont sans lien l'une avec l'autre – leurs fonctions ReadBuf sont destinées à faire des choses différentes, et les classes proviennent de différents vendeurs de librairies. Montrez comment écrire une classe D, publiquement dérivée à la fois de B1 et B2, qui force à la fois ReadBufs indépendamment à faire des choses différentes.


I39-2. Solution

Montrez comment écrire une classe D, publiquement dérivée à la fois de B1 et B2, qui force à la fois ReadBufs indépendamment à faire des choses différentes.

Voici la tentative naïve qui ne marchera pas :

  class D : public B1, public B2 {
  public:
    int ReadBuf( const char* );
        // force à la fois B1::ReadBuf et B2::ReadBuf
  };
Elle force LES DEUX fonctions avec la même implémentation, attendu que l'objet de la question était de forcer les deux fonctions à faire des choses différentes. Vous ne pouvez pas simplement "intervertir" les comportements dans ce D::ReadBuf en fonction de la façon dont il est appelé, parce qu'une fois que vous êtes à l'intérieur de D::ReadBuf, il n'y a aucun moyen de dire quelle interface de base a été utilisée (si une interface de base a effectivement été utilisée).

info Renommer les fonctions virtuelles
Si les deux fonctions héritées avaient des signatures différentes, il ne devrait pas y avoir de problème : on se contenterait de les forcer indépendamment, comme d'habitude. L'astuce, à ce moment, est de modifier d'une manière ou d'une autre la signature d'au moins une des deux fonctions héritées.

La façon de changer la signature d'une fonction de classe de base consiste à créer une classe intermédiaire qui dérive de la classe de base, à déclarer une nouvelle fonction virtuelle et à forcer la version héritée pour appeler la nouvelle fonction :

  class D1 : public B1 {
  public:
    virtual int ReadBufB1( const char* p ) = 0;
    int ReadBuf( const char* p ) // override inherited
      { return ReadBufB1( p ); } // to call new func
  };

  class D2 : public B2 {
  public:
    virtual int ReadBufB2( const char* p ) = 0;
    int ReadBuf( const char* p ) // override inherited
      { return ReadBufB2( p ); } // to call new func
  };
D1 et D2 peuvent aussi avoir besoin de dupliquer les constructeurs de B1 et B2 pour que D puisse les invoquer, mais c'est tout. D1 et D2 sont des classes abstraites, alors elles n'ont PAS BESOIN de dupliquer les autres fonctions ou opérateurs B1/B2, comme les opérateurs d'assignation.

A présent, nous pouvons simplement écrire :

  class D : public D1, public D2 {
  public:
    int ReadBufB1( const char* );
    int ReadBufB2( const char* );
  };
Les classes dérivées n'ont besoin que de savoir qu'elles ne doivent plus forcer ReadBuf lui-même.


I40. Polymorphisme contrôlé

Difficulté : 8 / 10

idea Le polymorphisme IS-A (EST-UN) est un outil très utile en conception Orientée Objet, mais parfois vous pouvez vouloir restreindre les codes qui peuvent utiliser certaines classes de façon polymorphe. Ce programme donne un exemple et montre comment obtenir l'effet désiré.

I40-1. Problème

1. Considérons le code suivant :

  class Base {
  public:
    virtual void VirtFunc();
    // ...
  };

  class Derived : public Base {
  public:
    void VirtFunc();
    // ...
  };

  void SomeFunc( const Base& );
Il y a deux autres fonctions. Le but est de permettre à f1 d'utiliser un objet dérivé de façon polymorphe là où une base est attendue, empêchant ainsi toute autre fonction (y compris f2) de le faire.

  void f1() {
    Derived d;
    SomeFunc( d ); // fonctionne, OK
  }

  void f2() {
    Derived d;
    SomeFunc( d ); // nous voulons empêcher cela
  }
Montrez comment obtenir cet effet.


I40-2. Solution

1. Considérons le code suivant :

  class Base {
  public:
    virtual void VirtFunc();
    // ...
  };

  class Derived : public Base {
  public:
    void VirtFunc();
    // ...
  };

  void SomeFunc( const Base& );
La raison pour laquelle tout code peut utiliser des objets dérivés de façon polymorphe là où une base est attendue, c'est que les objets dérivés héritent de façon publique de la base (pas de surprise).

Si au lieu de cela, les objets dérivés héritaient de façon privée de la base, alors "quasiment" aucun code ne pourrait utiliser les objets dérivés de façon polymorphe comme bases. La raison de ce "quasiment" est qu'un code avec accès aux parties privées des objets dérivés PEUT accéder aux classes de base privées des objets dérivés de façon polymorphe en lieu et place des bases. Normalement, seules les fonctions membres des objets dérivés ont ce genre d'accès. Toutefois, nous pouvons utiliser "friend" pour étendre un accès similaire à un autre code externe.

En rassemblant les pièces du puzzle, on obtient :

Il y a deux autres fonctions. Le but est de permettre à f1 d'utiliser un objet dérivé de façon polymorphe là où une base est attendue, empêchant ainsi toute autre fonction (y compris f2) de le faire.

  void f1() {
    Derived d;
    SomeFunc( d ); // fonctionne, OK
  }

  void f2() {
    Derived d;
    SomeFunc( d ); // nous voulons empêcher cela
Montrez comment obtenir cet effet.

La réponse consiste à écrire :

  class Derived : private Base {
  public:
    void VirtFunc();
    // 
    friend void f1();
  };
Cela résout le problème proprement, bien que cela donne à f1 un plus grand accès que ce qu'avait f1 dans la version originale.

 

Valid XHTML 1.1!Valid CSS!

Copyright © 2008 Herb Sutter. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.