Apprendre à implémenter un autre type de polymorphisme

Andrzej Krzemieński

Traducteur

Mishulyna

Dans ce tutoriel, nous allons essayer de découvrir par un exemple concret à quoi sert Boost.Variant. Vous pouvez parfois trouver des exemples utilisant le type variant<int, double, string>, mais ils me semblent artificiels : je n'ai jamais eu besoin d'utiliser quelque chose étant soit un double, soit un int ; pourtant, je trouve malgré tout cette bibliothèque utile. Même si vous êtes déjà familier avec Boost.Variant et ses concepts de « garantie du jamais vide » et « visiteur statique », je me suis assuré qu'il vous restera encore quelque chose à apprendre en lisant ce tutoriel.

Commentez

- Titre : Apprendre à implémenter un autre type de polymorphisme

- Auteur : Andrzej Krzemieński

- Traducteur : Mishulyna

- Parution : 25 septembre 2016

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site et 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.

Voir également :

- Les préconditions en C++ - Partie 1

- Les préconditions en C++ - Partie 2

- Les préconditions en C++ - Partie 3

- Les préconditions en C++ - Partie 4

- Les littéraux utilisateur - Partie 1 : les littéraux préparés

- Les littéraux utilisateur - Partie 2 : les littéraux bruts

- Les littéraux utilisateur - Partie 3 : aller plus loin

Sommaire

- I. Polymorphisme basé sur la vtable

- II. Quand cela ne suffit pas

- III. Boost.Variant

- IV. Commutation de type

- V. Mach7

- VI. La garantie du jamais vide

- VII. Résumé

- VIII. Remerciements

- IX. Références

- X. Remerciements Developpez

I - Polymorphisme basé sur la vtable

Nous commençons par observer le polymorphisme classique basé sur des fonctions virtuelles.

L'objectif de tout polymorphisme est de faire en sorte qu'une routine donnée (une fonction ou une fonction template) s'exécute et fasse les choses correctement, même si nous ignorons quels types de variables nous allons utiliser effectivement. Autrement dit, il s'agit de mettre en œuvre une forme de type erasure, une sorte de ligne de démarcation derrière laquelle il pourrait y avoir différents sous-programmes en cours d'exécution à des moments différents, mais devant elle nous avons toujours le même algorithme.

Il y a plusieurs façons d'obtenir cet effet. Le polymorphisme basé sur la table de fonctions virtuelles possède quelques caractéristiques particulières :

1. Nombre fixe de fonctions. Vous choisissez d'abord une interface complète et fixe : des fonctions membres virtuelles, et décidez d'interagir avec des objets de différents types uniquement à travers cette interface. Techniquement, vous pouvez surmonter cette contrainte en utilisant un dynamic_cast, mais cela est contraire à l'idée de polymorphisme basé sur la vtable, et en fait va à l'encontre du but d'utiliser en premier lieu ce type de polymorphisme, alors nous n'en discuterons pas davantage ici.

2. Nombre illimité de types. Une fois que vous avez choisi l'interface, n'importe qui peut définir à tout moment un nouveau type qui peut être utilisé à travers l'interface, tant que ce type est conforme. Les fonctions qui opèrent sur l'interface n'ont pas besoin de savoir combien de types différents il y aura.

3. Sécurité de type. Si l'interface possède des fonctions virtuelles pures et j'oublie de mettre en œuvre l'une d'elles dans mes types, je reçois une erreur de compilation.

Cela est très utile dans certains cas. Nous pouvons par exemple utiliser le polymorphisme basé sur la vtable pour mettre en œuvre des plug-ins d'application : lorsque nous écrivons le programme principal, nous ne savons pas combien de plug-ins différents lui seront ajoutés et lesquels. Mais nous n'avons pas à nous en préoccuper, tant que nous fournissons une bonne interface.

II - Quand cela ne suffit pas

Parfois, nous avons besoin de quelque chose de différent. Lorsque ma structure de données modélise une situation de la vie réelle, il m'arrive souvent de me dire que dans un « endroit » donné j'aurais besoin de choisir parmi deux ou trois options bien définies.

Laissez-moi vous donner un exemple. Supposons que nous voulons représenter une séquence de conteneurs physiques (boîtes) qui seront chargés dans une cale. Chaque boîte pourrait être :

1. Un conteneur régulier — vous pouvez y mettre une certaine quantité de cargaison qui tient à l'intérieur d'un volume donné.

2. Un conteneur multiple — il se compose d'une séquence de conteneurs plus petits : chacun d'entre eux peut être chargé comme un conteneur régulier, mais ensemble ils constituent une boîte qui sera placée dans un endroit de la cale. (Ces récipients plus petits ne peuvent pas être subdivisés.)

3. Du lest — vu de l'extérieur, cela ressemble à un conteneur, et peut être situé dans la cale comme un conteneur régulier, mais rien ne peut être chargé à l'intérieur, et son utilité est légèrement différente.

Je peux représenter chacun de ces récipients par un type distinct, chacun contenant un ensemble différent de données :

1. struct Conteneur

2. {

3. Volume allocation_volume;

4. Poids allocation_poids;

5. bool est_climatise;

6. Id id;

7. // plus de code ...

8. };

10. struct ConteneurMultiple

11. {

12. vector<Conteneur> conteneurs;

13. Id id;

14. // plus de code ...

15. };

17. struct Lest

18. {

19. LestType type;

20. Poids poids;

21. // plus de code ...

22. };

La question est de savoir comment je devrais dire que, à un endroit donné, j'attends l'un de ces trois types de données. Ceci est bien sûr réalisable avec un polymorphisme basé sur la vtable : je dois définir une classe d'interface, puis forcer mes types à se conformer à l'interface. Si je ne peux pas les forcer (parce que je ne contrôle pas leurs définitions), je dois définir des types d'enveloppes, qui contiendront le type d'origine et se conformeront à l'interface. Mais cela pose deux problèmes :

1. Cela n'exprime pas mon idée d'avoir uniquement l'un des trois types, sans aucune autre implémentation d'interface attendue ou permise.

2. Les trois types n'ont vraiment rien en commun (sauf le fait que je les veux au même endroit). Quelles fonctions dois-je mettre dans l'interface ?

En ce qui concerne le premier problème, vous pouvez le rejeter en disant : « Et alors ? Nous allons accepter tout simplement que quelqu'un puisse ajouter une autre implémentation de l'interface ». Cela ne correspond plus à notre intention, et nous pourrions rater des opportunités d'optimisation ; mais cela pourrait encore représenter une solution acceptable.

Le second problème est plus sérieux. Les trois types ne disposent pas d'une interface commune. Certes, à un moment donné dans le programme j'aurai besoin de les traiter de manière uniforme. Mais au moment de la conception de mes structures de données, je ne sais pas à l'avance combien d'opérations différentes je devrai effectuer sur les types. Je suppose que je pourrais vouloir :

- obtenir le poids total alloué ;

- obtenir le volume total alloué ;

- vérifier si une partie d'un conteneur est climatisée ;

- vérifier si tout le conteneur est climatisé ;

- vérifier si le chargement est possible ;

- vérifier si c'est du lest ;

- vérifier si le conteneur est divisé en sous-conteneurs ;

- vérifier si le conteneur correspond à un volume donné ;

- vérifier si le conteneur a un identifiant ;

- obtenir l'identifiant ;

- obtenir la liste des identifiants ;

- que sais-je encore…

C'est exactement le contraire de ce qu'une interface est censée être. Une interface doit être constituée d'un petit nombre de fonctions faciles à comprendre et reflétant la nature commune des objets avec lesquels nous travaillons. Mon interface, en revanche, en plus d'être énorme va continuer de croître chaque fois où j'écris une nouvelle fonction parce que je trouve l'ensemble actuel des fonctions insuffisant.

Au lieu de cela, je pourrais ajouter une interface triviale avec une seule fonction virtuelle — le destructeur — et utiliser la conversion dynamique de type (dynamic_cast) partout où je veux utiliser les types.

Cela, bien sûr, plombe complètement l'idée d'utiliser une interface basée sur la vtable. Nous allons contourner en permanence l'interface. Même si c'est laid, cela fera l'affaire, mais il y a un problème de sécurité. Lorsqu'un jour il sera nécessaire d'ajouter un quatrième type de cargaison, que je ne peux pas connaître à l'avance, les dynamic_cast existants ne le prendront pas en compte par défaut ; et si j'oublie de modifier le code dans tous les endroits où cela est nécessaire, j'obtiendrai un bogue dans la logique de mon programme : certaines parties du programme ne seront pas préparées à gérer ce nouveau type de données, même si elles compileront.

III - Boost.Variant

Ce qui précède est un genre de problème que Boost.Variant est destiné à résoudre. Avec Boost.Variant, vous déclarez votre type « l'un des trois » comme :

1. using Cargaison =

2. boost::variant<Lest, Conteneur, ConteneurMultiple>;

Maintenant, le type Cargaison est soit un Lest, soit un Conteneur, soit un ConteneurMultiple, et rien d'autre.

Cargaison est un type régulier : il est constructible par défaut (si Lest l'est), il est copiable et mobile (à condition que les trois types le soient). Il est même comparable en termes d'égalité (à condition que les trois types le soient).

Cargaison a tous ses sous-types codés dans son type. Cela laisse la place à un certain nombre de calculs utiles réalisés à la compilation. Par exemple, le compilateur peut calculer le sizeof requis par Cargaison : c'est la taille du plus grand des trois sous-types + un byte pour le discriminant — qui indique le sous-type exact auquel appartient un objet donné. De cette façon, nous ne demandons pas à allouer de la mémoire sur le tas.

(Bon, parfois l'affirmation ci-dessus concernant sizeof et l'allocation sur le tas n'est pas vraie, comme nous le verrons plus loin.)

Boost.Variant garantit que les objets de type Cargaison contiennent toujours une instance valide de l'un des trois sous-types : ils ne sont jamais « vides ». Cela est appelé une garantie du jamais vide.

IV - Commutation de type

La représentation d'un « choix » de types est la partie facile. La question intéressante est de savoir comment utiliser une telle chose. Boost.Variant vous permet de demander quel type est couramment/actuellement stocké dans l'objet, mais nous ne voulons pas le faire. Ce dont nous avons besoin est un mécanisme de plus haut niveau de manipulation polymorphique du variant.

En quelque sorte, nous voulons traiter de façon uniforme n'importe quel sous-type de notre Cargaison. Nous voulons quelque chose qui ressemble à un appel de fonction virtuelle : nous lui passons une référence à Cargaison et elle devrait savoir que faire avec chacun de ses sous-types.

C'est ici que nous pouvons voir toute la puissance de Boost.Variant. Le code suivant montre comment nous pouvons utiliser le concept d'un visiteur statique pour calculer le poids alloué à n'importe quel Conteneur, ConteneurMultiple ou Lest.

1. struct Allocation_poids : boost::static_visitor<Poids>

2. {

3. Poids operator()(const Conteneur& conteneur) const

4. {

5. return conteneur.allocation_poids;

6. }

8. Poids operator()(const ConteneurMultiple& multi) const

9. {

10. Poids poids (0);

11. for (const Conteneur& conteneur : multi.conteneurs)

12. poids += conteneur.allocation_poids ;

13. return poids;

14. }

16. Poids operator()(const Lest&) const

17. {

18. return Poids(0);

19. }

20. };

Le fait de dériver de boost::static_visitor<Poids> indique que cet objet fonction peut être utilisé pour « déballer » un variant. Son type de retour est Poids. Ensuite, nous donnons des indications pour calculer le poids de chaque sous-type possible.

Nous invoquons cette « fonction polymorphe » comme ceci :

1. Cargaison c = /* ... */;

2. Poids p = boost::apply_visitor(Allocation_poids(), b);

La fonction supplémentaire apply_visitor inspecte l'état actuel du type variant et appelle la routine correspondante. En outre, elle effectue un calcul à la compilation pour vérifier si tous les cas de sous-types du variant ont été envisagés : si nous en avons omis un, nous obtenons une erreur de compilation. Cela est particulièrement utile lorsqu'à un moment donné vous avez besoin d'ajouter un nouveau sous-type au variant. Les erreurs de compilation (si laides qu'elles soient) vous indiquent immédiatement tous les endroits dans le code qui doivent être reconsidérés.

Vous pouvez voir ici un exemple complet d'utilisation de Boost.Variant avec des visiteurs statiques.

Si vous regardez la structure de la définition du visiteur (Allocation_poids ci-dessus), elle ressemble dans un sens à une instruction switch. Dans un langage différent, nous pourrions l'exprimer ainsi :

1. // PAS EN C++

2. Poids Allocation_poids(const Cargaison& c)

3. {

4. type switch (b)

5. {

6. case (const Conteneur& cont):

7. return cont.allocation_poids;

9. case (const ConteneurMultiple& multi):

10. Poids poids (0);

11. for (const Conteneur& cont : multi.conteneurs)

12. poids += cont.allocation_poids;

13. return poids;

15. case (const Lest&):

16. return Poids(0);

17. }

18. }

Pour poursuivre l'analogie avec l'instruction switch, vous pouvez utiliser un équivalent de l'étiquette default lors de la définition d'un visiteur. Prenons l'exemple suivant :

1. struct Is_lest : boost::static_visitor<bool>

2. {

3. bool operator()(const Lest&) const { return true; }

5. template <typename T>

6. bool operator()(const T&) const { return false; }

7. };

Cela signifie « pour Lest retourne vrai, pour toute autre chose retourne faux ». Cependant, je ne vous conseillerais pas de le faire. Vous ne savez pas ce que « toute autre chose » pourrait être à l'avenir, quand un nouveau sous-type sera ajouté au type variant. Ou vous pourriez invoquer par erreur le visiteur sur un autre variant, comme variant<int, long, float>, car un visiteur n'est pas lié à un type précis de variant. En utilisant un cas inspecte-tout, vous perdez la fonctionnalité de sécurité statique.

La véritable puissance du visiteur statique devient visible lorsque vous devez traiter simultanément deux types de variants.

Supposons que nous ayons une liste de contenus que nous voulons distribuer dans des conteneurs. Chaque contenu peut être soit un seul paquet, soit un ensemble de paquets, qui pour certaines raisons sont traités comme un seul paquet, mais pour les besoins du transport peuvent être séparés et répartis dans un certain nombre de conteneurs. Ceci est représenté par les types suivants :

1. struct Paquet

2. {

3. Poids poids;

4. Volume volume;

5. Id id;

6. bool necessite_air_conditionne;

7. // plus de code ...

8. };

10. struct MultiPaquet

11. {

12. Id paquet_id;

13. vector<Paquet> sous_paquets;

14. // plus de code ...

15. };

17. using UniteCharge = boost::variant<Paquet, MultiPaquet>;

Maintenant, nous voulons écrire une fonction binaire qui inspecte si une UniteCharge donnée peut être incluse dans une Cargaison donnée. Voilà comment nous le faisons avec Boost.Variant :

1. struct Tient : boost::static_visitor<bool>

2. {

3. bool operator()(const Conteneur& c, const Paquet& p) const

4. {

5. return c.allocation_poids >= p.poids

6. && c.allocation_volume >= p.volume;

7. }

9. bool operator()(const Conteneur& c,

10. const ConteneurMultiple& cm) const

11. {

12. return bool("la totalité des paquets tient dans c");

13. }

15. bool operator()(const ConteneurMultiple& cm,

16. const Paquet& p) const

17. {

18. for (const Conteneur& c : cm.conteneurs)

19. if (Tient{}(c, p)) // auto-appel

20. return true;

21. return false;

22. }

24. bool operator()(const ConteneurMultiple& cm,

25. const PaquetMultiple& pm) const

26. {

27. return bool("la totalité des paquets tient dans l'ensemble de sous-conteneurs");

28. }

30. template <typename T>

31. bool operator()(const Lest&, const T&) const

32. {

33. return false;

34. }

35. };

Je n'ai pas de place pour mettre en œuvre décemment tous les cas de figure, mais vous pouvez saisir l'idée. Je peux spécifier une procédure pour chaque paire de sous-types en deux variantes.

À noter également le dernier cas : il est interprété, « si nous avons Lest du côté gauche et toute autre chose du côté droit ».

V - Mach7

J'ai vérifié comment le même problème d'appliquer une « fonction polymorphe » à un variant peut être résolu avec la bibliothèque Mach7. Mach7 est une bibliothèque expérimentale pour la correspondance de motifs, et notre problème de correspondance des sous-types du variant rentre dans son champ d'application. Mach7 ne nous demande aucune inversion de contrôle. Avec un certain nombre de macros astucieuses, elle nous permet d'écrire une instruction de type switch. Notre exemple ci-dessus avec la vérification de l'allocation de poids dans un conteneur donné ressemble à ceci :

1. Poids allocation_poids(const Cargaison& cargaison)

2. {

3. Match (cargaison)

4. {

5. Case (C<Conteneur>())

6. return match0.allocation_poids ;

8. Case (C<ConteneurMultiple>())

9. Poids poids(0);

10. for (const Conteneur& cont : match0.conteneurs)

11. poids += cont.allocation_poids;

12. return poids;

14. Case (C<Lest>())

15. return Poids(0);

16. }

17. EndMatch

18. }

Nous pouvons voir que cela se rapproche vraiment d'une fonctionnalité intégrée dans le langage. En fait, Mach7 est une expérience qui a l'intention d'ouvrir la voie à une future extension du langage C++.

Une chose à observer est que nous ne choisissons pas le nom de la référence à Conteneur ou ConteneurMultiple. Le nom est choisi par la bibliothèque : match0. Il représente une correspondance à la variable à l'indice 0. On peut obtenir plus d'un indice si nous faisons un dispatch multiple, comme dans le cas de notre fonction qui vérifie si une charge donnée tient dans un conteneur donné. En Mach7, cette fonction ressemble à ceci :

1. bool tient(const Cargaison& cargaison, const UniteCharge& charge)

2. {

3. Match (cargaison, charge)

4. {

5. Case (C<Conteneur>(), C<Paquet>())

6. return match0.allocation_poids >= match1.poids

7. && match0.allocation_volume >= match1.volume;

9. Case (C<Conteneur>(), C<PaquetMultiple>())

10. return bool("la totalité des paquets tient dans c");

12. Case (C<ConteneurMultiple>(), C<Paquet>())

13. for (const Conteneur& c : match0.conteneurs)

14. if (tient(c, match1)) // appel recursif

15. return true;

16. return false;

18. Case (C<ConteneurMultiple>(), C<PaquetMultiple>())

19. return bool("la totalité des paquets tient dans l'ensemble de sous-conteneurs");

21. Case (C<Lest>(), C<Paquet>())

22. return false;

24. Case (C<Lest>(), C<PaquetMultiple>())

25. return false;

26. }

27. EndMatch

28. }

Ici, match0 représente une référence à un sous-type du variant Cargaison et match1 représente une référence à un sous-type du variant UniteCharge.

Une chose à retenir est que la construction de l'instruction switch étant extensible par sa nature, elle ne vérifie pas si nous avons couvert toutes les possibilités dans les branches de case, alors il pourrait être nécessaire d'y ajouter l'équivalent du cas par défaut pour couvrir un cas futur, lorsque nous ajouterons un nouveau sous-type à Cargaison. Dans Mach7, cela est orthographié Otherwise (« Sinon ») et notre premier exemple pourrait être réécrit ainsi :

1. Poids allocation_poids(const Cargaison& cargaison)

2. {

3. Match (cargaison)

4. {

5. Case (C<Conteneur>())

6. return match0.allocation_poids;

8. Case (C<ConteneurMultiple>())

9. return Poids(("calculer la réponse correcte...", 1));

11. Case (C<Lest>())

12. return Poids(0);

14. Otherwise()       // lorsque rien d'autre ne correspond

15. assert(false);  // ou selon le besoin

16. }

17. EndMatch

18. }

Vous pouvez voir ici un exemple complet d'utilisation de Boost.Variant avec Mach7.

J'ai exécuté un petit benchmark pour comparer la vitesse de Mach7 par rapport à celle des visiteurs Boost.Variant. Vous pouvez trouver le code d'essai ici. Les résultats montrent que le visiteur statique Boost.Variant est plus rapide que Mach7 : 1,65 fois sur GCC 4.9.2, et 2,93 fois sur MSVC 14.0. (Les deux tests ont été effectués sur Windows, avec les optimisations maximales activées).

VI - La garantie du jamais vide

Garantir que variant<A, B> stocke toujours un objet de type A ou B, en présence des exceptions, est plus difficile que l'on pourrait s'y attendre. Imaginez comment vous pourriez mettre en œuvre une affectation « mixte » comme ceci :

1. variant<A, B> va = A{};

2. variant<A, B> vb = B{};

3. va = vb;

Que signifie attribuer un B à un A ? Et comment mettre ça en œuvre s'il est possible qu'un constructeur de B lance une exception ? Pour une discussion détaillée à ce sujet, consultez la section « Vue d'ensemble de la conception » de la documentation de Boost.Variant. En bref, dans certains cas, l'affectation par copie du variant effectue une allocation sur le tas.

Une conception alternative du variant, proposée pour normalisation, n'alloue pas de la mémoire sur le tas au risque de compromettre dans une certaine mesure la garantie du jamais vide. Pour plus de détails, voir ici et ici. En bref, bien que vous ne puissiez pas créer un variant qui ne contienne ni un A, ni un B, vous pouvez vous retrouver dans un tel état si, lors d'une copie « mixte », une exception a été lancée. Une tentative de « visite » d'un tel variant aura pour résultat une exception.

VII - Résumé

Le point clé de cette introduction est que Boost.Variant, avec son concept de visiteur statique, offre un autre type de polymorphisme qui fait des compromis différents et vise des cas d'utilisation différents par rapport aux solutions basées sur la vtable (comme l'héritage de classe ou le type erasure à sémantique de valeur). Le tableau suivant résume les différences.

polymorphisme

nombre de fonctions

nombre de types

sécurité de type

basé sur la vtable

fixe

illimité

oui

variant

illimité

fixe

oui

Lorsque votre cas d'utilisation correspond au « profil » de Boost.Variant, il vaut le coup de choisir cette bibliothèque à la place du polymorphisme classique basé sur la vtable. Elle est optimisée pour ce cas d'utilisation et plus performante que les autres solutions possibles ; elle est un peu plus facile à utiliser. Mais le plus important : elle reflète ce que vous modélisez.

VIII - Remerciements

Je suis reconnaissant à Yuriy Solodkyy pour m'aider à comprendre la mécanique et l'utilisation de la bibliothèque Mach7.

IX - Références

1. Eric Friedman, Itay Maman, La bibliothèque Boost.Variant.

2. David Sankel, “C++ Language Support for Pattern Matching and Variants”.

3. David Sankel, “A variant for the everyday Joe”.

4. Anthony Williams, “Standardizing Variant: Difficult Decisions”.

5. Axel Naumann, “Variant: a type-safe union (v4)”.

6. Yuriy Solodkyy, La bibliothèque Mach7.

7. Yuriy Solodkyy, Gabriel Dos Reis, Bjarne Stroustrup, “Open and Efficient Type Switch for C++”.

8. Vicente J. Botet Escriba, “C++ generic match function”.

X - Remerciements Developpez

Toute l'équipe de Developpez.com remercie sincèrement Andrzej Krzemieński qui nous a aimablement permis de traduire et de publier son tutoriel sur notre site. Nous tenons également à remercier Mishulyna pour la traduction, Luc Hermitte pour la relecture technique et f-leb pour la relecture orthographique.