La première technique ci-dessous est en grande partie inspiré de l'article publié à l'URL suivante : http://www.artima.com/cppsource/type_erasure.html (lui-même inspiré des travaux sur le type Boost::Any).
La deuxième est inspirée de http://drdobbs.com/cpp/229401004 par Cassio Neri.
La troisième ressemble à la première, mais évite tout recours à RTTI, ce qui la rend accessibl aux milieux qui ne peuvent se permettre les coûts de ce mécanisme.
Merci à Emmanuel Thivierge, de la cohorte 04 du Diplôme de développement du jeu vidéo à l'Université de Sherbrooke, pour avoir suggéré le nom de petite_capsule pour remplacer le plus ou moins bien nommé petit_conteneur.
Il arrive qu'on souhaite déposer, dans un même conteneur, des entités de types différents, or C++ est un langage très fortement typé, quoiqu'en disent ses détracteurs, et créer un conteneur d'objets de divers types y est une tâche subtile et complexe, beaucoup plus que ne le pensent la plupart des gens.
Quelques articles subtils suivant la présente section exploitent la technique nommée effacement de types (en anglais : Type Erasure) par laquelle il est possible « d'oublier » momentanément un type pour entreposer une donnée de ce type dans un conteneur, puis des approches (reposant sur RTTI, du moins dans cet article) pour récupérer les données ainsi entreposées.
L'effacement de types est relativement simple en C++ : prenez l'adresse d'une variable, déposez-la dans un pointeur abstrait, ou void *, et le tour est joué... à ceci près qu'il faut être en mesure de récupérer l'élément pointé par la suite.
Dans certains langages, par exemple Java ou les langages .NET, l'abstraction la plus élevée possible demeure sémantiquement riche (Object ou Type, typiquement), à partir de laquelle il est possible de procéder à diverses formes de transtypage (ou encore d'avoir recours à de la réflexivité). Avec une abstraction pure telle que void *, seule la volonté des programmeuses et des programmeurs peut mener à un transtypage correct.
Nous examinerons deux techniques pour réaliser l'effacement de types. La première passera par une variante de l'idiome pImpl et permettra d'effacer tout type T, pointeur ou non, mais elle ne permettra que de récupérer le type exact qui aura été entreposé à l'origine. La seconde ne permettra d'entreposer que des pointeurs, mais permettra de respecter les relations d'héritage au moment de la récupération (p. ex. : si D dérive publiquement de B, on pourra y entreposer un D* mais récupérer un B*). Les deux techniques ont leurs avantages et leurs inconvénients.
Le programme principal de test que nous souhaiterons être capables de construire ira tel que proposé à droite. Remarquez que le type des éléments de v est peu_importe, et que nous y déposons tour à tour un entier, un flottant à simple précision et un objet contenant du texte. Nous souhaitons que les affichages soient tous conformes aux attentes, bien entendu. Il nous faudra donc faire en sorte que peu_importe puisse absorber à peu près n'importe quel type et que la fonction peu_importe_cast puisse extraire une valeur d'un peu_importe. |
|
L'idée, donc, est d'effacer les types réels pour les déposer sous une structure à type homogène, permettant leur manipulation à travers une structure typée comme le sont les conteneurs standards. Évidemment, cela entraîne naturellement le besoin d'une stratégie pour retrouver de manière sécuritaire et efficace l'information entreposée sous ce type homogène, du fait que cette information est effacée par l'imposition du type qui la chapeaute (l'effacement des types de données effectifs est le but de l'exercice, après tout).
Tout ce qui suit s'inspire de Boost::Any, mais il existe d'autres manières d'en arriver à un résultat semblable. Nous examinerons des approches alternatives dans les sections subséquentes.
Notre approche utilisera un type générique intermédiaire (petite_capsule<T>) qui sera clonable et encapsulera une donnée d'un type T arbitraire.
Dans notre exemple d'effacement de types, le conteneur encapsulant les types effectifs sera générique et clonable alors que l'abstraction encapsulant l'idée de clonable, elle, ne sera pas générique et servira, par conséquent, de base commune. La méthode deduire_type() servira, comme son nom l'indique, à identifier le type de l'objet encapsulé, mais aura le coût usuel des mécanismes RTTI. Cela nous suffira ici, mais nous chercherons à mieux faire dans d'autres sections du présent document. |
|
Pour faire disparaître les types sans perdre l'information, il nous faudra entreposer les données dans une structure dont la manipulation sera homogène. Ce type, qui fera le pont entre l'abstraction homogène clonable et la gestion générique des valeurs encapsulées, se nommera ici petite_capsule. Notez au passage l'implémentation de deduire_type(), qui repose (tel qu'annoncé plus haut) sur le recours au mécanisme RTTI, assez coûteux. Le petite_capsule<T> sera la face visible, pour notre mécanisme, du type T en cours d'effacement. Nous ne connaissons pas le comportement d'un T mais nous pouvons abstraire la manipulation et la duplication d'un petite_capsule<T> de manière uniforme. Notez que nous ne clonons pas un T mais bien son enveloppe. |
|
La mention friend class peu_importe servira à donner un accès privilégié à peu_importe (plus bas) sur la méthode deduire_type().
L'abstraction homogène clonable est trop élevée pour nos besoin de manipulation générale, et petite_capsule est un type générique, ce qui fait que pour chaque paire de types T et U distincts, les types petite_capsule<T> et petite_capsule<U> correspondants seront aussi distincts. La généricité est utile mais ne résout pas tous les maux – pour établir une abstraction commune à tous les types et utilisable comme telle dynamiquement, la généricité seule ne suffit pas.
Pour effacer les types, nous voudrons un type ayant les caractéristiques suivantes :
Le code de peu_importe va comme suit. Pour permettre d'implémenter la fonction peu_importe_cast() sans exposer p_, notre attribut clonable, de manière publique, nous offrirons deux services primitifs, soit la possibilité de tester un identifiant de type (par voie de RTTI) et celle de convertir de manière efficace mais dangereuse le clonable* vers un petite_capsule<T>* pour un type T donné, et en retourner la valeur. |
|
Notez que nous voudrions ne laisser que peu_importe_cast() manipuler ces délicats outils, mais il est impossible en C++ de qualifier friend des fonctions et des classes génériques (il serait trop facile de spécialiser ces amis pour provoquer un bris d'encapsulation). Les méthodes empty() et swap() sont évidentes et banales (mais très utiles). |
|
Les constructeurs et le destructeur sont aussi relativement simples, quoique quelque peu subtils. Notez le constructeur pour un T générique, qui permet de déposer n'importe quoi (n'importe quel T, pour tout type T) dans un même peu_importe en construisant un petite_capsule<T> pour l'entreposer et en ne conservant que son visage clonable. Ceci tient aussi pour le constructeur de copie – on ne sait jamais ce que l'on y copie! |
|
La sémantique de mouvement est banale à implémenter pour ce type. |
|
L'affectation est aisée à exprimer pour toute paire de peu_importe (on ne sait pas quel petite_capsule<T> se cache sous le clonable dupliqué!). L'affectation d'un T à un peu_importe est à la fois rendue charmante et aisée par l'idiome d'affectation sécuritaire. Ajouter une spécialisation de std::swap() sur deux instances de peu_importe est banal et très utile. Je vous invite à le faire. |
|
Nous aurions pu être plus sophistiqués et accepter une conversion vers tout type conforme au type entreposé, incluant ses parents s'il s'agit d'une classe, plutôt que vers le seul type exact entreposé, mais cela aurait compliqué le propos. Nous aurions pu nous baser sur est_convertible pour y arriver. Un exercice pour vous, si vous êtes en forme...
Reste à déterminer comment retrouver une valeur dans un peu_importe. Ceci dépend au moins en partie d'une connaissance a priori qu'aurait le code client quant aux types véritablement entreposés, du moins avec la stratégie déployée ici.
Nous définirons peu_importe_cast, un mécanisme sécuritaire en ce sens qu'il réalisera une extraction du type effectivement entreposé sous un peu_importe (la valeur de type T dans le petite_capsule<T> se glissant sous le clonable) et lèvera une exception si le type de destination ne correspond pas précisément au type T.
Le code suit, et est relativement simple.
Il a été découpé en deux étapes : l'extraction brute, qui porte le suffixe _risque puisqu'elle cause des dégâts si elle tente de convertir dans un type inadéquat, et la version utilisable qui fait d'abord un test RTTI de conformité des types (et est donc à la fois plus lente et plus sécuritaire, du fait qu'elle permet de récupérer d'une éventuelle erreur). |
|
S'il s'avère nécessaire de respecter les relations d'héritage lors de la récupération d'un pointeur dont le type a été effacé, alors la technique par polymorphisme externe n'est pas appropriée, du fait qu'elle vérifie l'égalité entre le type pointé (sur lequel une information de type est en quelque sorte conservée) et le type demandé.
Un exemple d'une telle manoeuvre est donné par le code à droite. Ici, le peu_importe_cast lèvera une exception car le type de destination du transtypage n'est pas le même que le type entreposé dans le peu_importe à l'origine. Il peut arriver qu'on souhaite réaliser un effacement de type respectant une forme de covariance – ici, qu'on souhaite traiter un pointeur effacé sur un enfant comme un pointeur effacé sur un parent, même si le T* est en fait entreposé dans une petite_capsule<T*>. Si tel est le souhait, alors il nous faut une autre technique que celle reposant sur une comparaison des valeurs retournées par typeid. |
|
On pourrait alors être tenté d'avoir recours à l'opérateur de transtypage ISO qu'est dynamic_cast. En pratique, c'est l'option la plus performante (il est fait précisément pour de telles manoeuvres, après tout!) mais il se trouve que cet opérateur ne fonctionne que sur des types polymorphiques, une restriction raisonnable pour les cas d'utilisation types de cet opérateur mais qui proscrirait les pointeurs sur des primitifs ou sur des classes concrètes, un irritant inacceptable pour nous.
Les paramètres de l'approche à l'effacement de types que nous décrirons ici sont les suivants :
Vous remarquerez que la solution proposée ici est une esquisse, opérationnelle mais perfectible, que vous pourrez enrichir en fonction de vos besoins, par les services qui vous sembleront appropriés.
Nous développerons une classe any_ptr (nom pris sans gêne à l'article fort intéressant de Cassio Neri, vers lequel vous trouverez un lien tout en haut du présent document) capable d'effacer le type de son pointé.
Tout d'abord, notons que nous ferons de notre type une classe incopiable. Sans que ce ne soit nécessaire, ceci nous libérera de la gestion de la sémantique de copie d'un any_ptr. Bien entendu, si l'envie vous prend d'attaquer ce problème (qui n'est pas insoluble), amusez-vous! |
|
Un any_ptr possèdera trois attributs, tous des pointeurs :
|
|
Touchons maintenant au secret de la manoeuvre :
La méthode de classe générique lanceur<T>() recevra un pointeur abstrait et lèvera sa conversion en T*. Ces méthodes (car il y en aura autant, en pratique, qu'il y aura de types T auxquelles lanceur<T>() s'appliquera) sont privées, donc sous le contrôle de la classe any_ptr. Nous pourrons donc garantir que chaque paramètre leur étant passé sera un pointeur vers le pointé dont le type aura été effacé. Nous verrons plus bas, dans la méthode cast_to(), comment nous nous en servirons en pratique. La méthode destructeur<T>() est construite sur le même principe. |
|
Le constructeur d'un any_ptr est générique. Ainsi, pour tout T* passé à la construction d'un any_ptr, une méthode any_ptr::any_ptr(T*) sera générée. Nous comptons d'ailleurs sur cela. En effet :
|
|
La beauté de cette manoeuvre est que, dans une instance du type concret any_ptr, on trouve maintenant un pointeur de fonction lanceur_ dont la signature est très générique (ne retourne rien, prend un void * en paramètre) mais dont l'exécution lèvera spécifiquement un T*, donc qui possède en quelque sorte la clé de la nature du type réellement pointé.
La méthode qui permettra de récupérer un pointeur typé sera cast_to<T>(). Pour appeler cette méthode, il importe d'indiquer dans quel type la conversion doit être tentée – le type T ici est local à la méthode; il ne faut pas le confondre avec les autres types T plus haut. La fonction appellera lanceur_() puis cherchera à attraper le type demandé. Si cela fonctionne, donc si le pointé est effectivement T ou encore un type U tel que U a pour ancêtre public T, alors le pointeur correctement converti est retourné. Dans tous les autres cas, un pointeur nul est retourné, comme dans le cas d'un dynamic_cast sur des pointeurs qui aurait échoué. |
|
De manière analogue, mais conceptuellement plus simple, le destructeur d'un any_ptr applique la fonction pointée par destructeur_ au pointeur abstrait p_. Sachant que l'attribut destructeur_ pointe vers destructeur<T>() dès la construction d'un any_ptr, nous savons que p_ y sera converti en T* et que T::~T() lui sera appliqué. |
|
Reste à voir comment tester le tout.
Pour les fins de notre test, nous tenterons plusieurs conversions de types sur plusieurs instances distinctes d'any_ptr. Dans le but d'alléger l'écriture, nous afficherons les noms des types impliqués à l'aide de la mécanique RTTI du langage, soit en affichant ce que retournera la méthode name() de l'objet retourné par l'opérateur statique typeid, ce qui explique que nous ayons recours à <typeinfo>. |
|
La fonction générique test_cast<T>() prendra en paramètre un any_ptr et essaiera de le convertir en T*. Si la conversion réussit, un message indiquant le type dans lequel la conversion fut réalisée est affiché à l'écran. Nous appellerons cette fonction pour plusieurs any_ptr et en fonction de plusieurs types T distincts. |
|
Pour tester nos conversions entre types liés par une hiérarchie, nous utiliserons trois classes, soit Base et ses enfants D0 et D1. Ces classes n'ont d'intérêt pour nous que de par leur relation parent/ enfant. Pour simplifier le tout, nous déterminerons que la valeur affichable d'une instance de chacun de ces types sera le nom du type en question. Il s'agit d'un choix arbitraire. |
|
Pour tester un any_ptr, nous chercherons à le convertir en plusieurs types distincts, incluant des classes avec ou sans relation hiérarchique entre elles. Remarquez que l'instance d'any_ptr est passée ici par référence, tout comme dans la fonction test_cast<T>() plus haut, du fait que nous avons défini ce type comme étant incopiable. |
|
Le programme de test est assez simple : on y crée divers any_ptr effaçant plusieurs types distincts, puis on y réalise des tentatives de conversion pour que les conversions réussies soient chaque fois affichées. Retenez que chaque any_ptr est responsable de son pointé. Conséquemment, ce programme ne provoquera pas de fuites de mémoire. |
|
Le résultat de l'exécution de ce programme de test va comme suit – notez que les noms de types tels qu'affichés peuvent changer selon les compilateurs :
Transtypage en int* --> valeur 3
-----
Transtypage en class std::basic_string<char,struct std::char_traits>char>,class
std::allocator<char> >* --> valeur yo
-----
Transtypage en struct Base* --> valeur Base
-----
Transtypage en struct Base* --> valeur Base
Transtypage en struct D0* --> valeur D0
-----
Transtypage en struct Base* --> valeur Base
Transtypage en struct D1* --> valeur D1
-----
Appuyez sur une touche pour continuer...
Manifestement, un pointeur sur une instance d'une classe parent (par exemple Base*) ne peut être automatiquement converti en pointeur sur une instance d'une classe enfant (par exemple D0* ou D1*), mais l'inverse est vrai. Notez aussi que s'il est banal de convertir un int en short, il l'est beaucoup moins de convertir un int* en short*.
Notez que, puisque j'ai affiché les noms des types selon le mécanisme RTTI qu'est typeid(T).name(), les noms de types peuvent varier d'un compilateur à l'autre et d'une version à l'autre d'un même compilateur.
Voilà, donc, une solution au problème posé. Si vous en avez envie, vous pouvez vous amuser à :
Le type any_ptr ci-dessus montre qu'il est possible de réaliser une forme d'effacement de type sans avoir recours à des mécanismes RTTI comme dynamic_cast ou typeid. Ce constat est important : RTTI est un mécanisme coûteux, surtout en espace mémoire, et certains domaines ne peuvent se permettre de payer ces coûts.
Sur cette base, revisitons le type peu_importe pour l'implémenter autrement.
Comme dans l'implémentation présentée plus haut, nous modéliserons une tentative de conversion vers le mauvais type par une levée d'exception de type vilain_peu_importe_cast. Cette nouvelle implémentation reposera sur un pointeur abstrait (un void*) et sur un pointeur de fonction, ce dernier menant vers une fonction générale Mgr<T>::operate() pour un certain type T. Cette fonction acceptera deux paramètres :
Pour l'implémentation simpliste proposée ici, les deux opérations implémentées seront la suppression du pointé et sa duplication. Un peu comme dans le cas du any_ptr, nous cacherons le type T dans la fonction (en fait, dans le type Mgr<T> à l'intérieur duquel la fonction est logée). Il n'y aura pas d'instances du type Mgr<T> dans le programme, ce qui nous permet de traiter Mgr<T>::operate comme une fonction globale. |
|
Des services primitifs simples pour vider un peu_importe et tester s'il est vide sont offerts. |
|
Nous rendons possible la Sainte-Trinité, le mouvement, et la construction à partir d'un certain type T. C'est d'ailleurs à ce moment que nous faisons en sorte que le type Mgr<T> soit créé, et que l'adresse de Mgr<T>::operate soit saisie. |
|
Comment évitons-nous de passer par RTTI lorsqu'une demande de conversion vers un certain type T survient? Le truc est de vérifier si Mgr<T>::operate pointe au même endroit que mgr_fct, ce qui signifierait en pratique que p soit, derrière l'abstration du type void*, un T*. Ce petit test rend l'implémentation toute entière beaucoup moins gourmande en ressources que la précédente. |
|
Enfin, un petit test permet de voir que le tout fonctionne bien. |
|
En espérant que le tout ait été instructif!
Pour une série d'articles par Andrzej Krzemieński sur le sujet, voir :
Texte de 2014 décrivant diverses manières de réaliser une forme ou l'autre d'effacement de types : http://talesofcpp.fusionfenix.com/post-16/episode-nine-erasing-the-concrete
En 2014, Andreas Hermann nous propose une démarche visant à réduire le recours au polymorphisme dynamique en obtenant tout de même une forme d'effacement de types : http://aherrmann.github.io/programming/2014/10/19/type-erasure-with-merged-concepts/
Approche connexe, par Tobias Becker en 2014, qui propose une technique pour implémenter des objets dynamiques, un peu comme ceux que l'on trouve dans du code JavaScript : http://thetoeb.de/2014/10/08/an-implementation-of-the-dynamic-object-in-c/
Très intéressant texte d'Arthur O'Dwyer en 2019 sur l'effacement de types, avec plusieurs approches et des comparatifs avec d'autres langages (Java et Go) : https://quuxplusone.github.io/blog/2019/03/18/what-is-type-erasure/