Effacement de types

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 seconde est inspirée de http://drdobbs.com/cpp/229401004 par Cassio Neri.

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.

Effacement de type par polymorphisme externe

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.

// autres inclusions
#include <vector>
#include <string>
#include <iostream>
int main() {
   using namespace std;
   vector<peu_importe> v;
   v.push_back(peu_importe{3});
   v.push_back(peu_importe{3.14159f});
   v.push_back(peu_importe{string{"patate"}});
   cout << peu_importe_cast<float>(v[1]);
   v[1] = string("yo");
   cout << peu_importe_cast<string>(v[1]);
}

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.

Clonage conscient des types

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.

#include <typeinfo>
//
// duplication subjective
//
struct clonable {
   virtual clonable* cloner() const = 0;
   virtual ~clonable() = default;
   virtual const std::type_info&
      deduire_type() const noexcept = 0;
protected:
   clonable() = default;
   clonable(const clonable&) = default;
};

Encapsulation des types

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.

template <class T>
   class petite_capsule
      : public clonable
   {
   public:
      using value_type = T;
      friend class peu_importe;
   private:
      value_type val_;
      const std::type_info& deduire_type() const noexcept
         { return typeid(value_type); }
   public:
      petite_capsule(const value_type &val = {})
         : val_{val}
      {
      }
      petite_capsule<T>* cloner() const {
         return new petite_capsule<T>(*this);
      }
      const value_type& valeur() const {
         return val_;
      }
      value_type& valeur() noexcept {
         return val_;
      }
   };

La mention friend class peu_importe servira à donner un accès privilégié à peu_importe (plus bas) sur la méthode deduire_type().

Seuil utilisable d'abstraction

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.

#include <utility>
class peu_importe {
   clonable *p_;
public:
   template<class T>
      T& unsafe_downcast() noexcept {
         return static_cast<petite_capsule<T>*>(p_)->valeur();
      }
   template<class T>
      const T& unsafe_downcast() const noexcept {
         return static_cast<petite_capsule<T>*>(p_)->valeur();
      }
   template <class T>
      bool is_a() const noexcept {
         return p_->deduire_type()==typeid(T);
      }

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).

// ...
   bool empty() const noexcept {
      return !p_;
   }
   void swap(peu_importe &p) noexcept {
      using std::swap;
      swap(p_, p.p_);
   }

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!

// ...
   constexpr peu_importe() noexcept
      : p_{}
   {
   }
   template <class T>
      peu_importe(const T &val)
         : p_{new petite_capsule<T>{val}}
      {
      }
   peu_importe(const peu_importe &p)
      : p_{p.empty()? nullptr : p.p_->cloner()}
   {
   }
   ~peu_importe() {
      delete p_;
   }

La sémantique de mouvement est banale à implémenter pour ce type.

// ...
   peu_importe(peu_importe &&p)
      : p_{std::move(p.p_)}
   {
      p.p_ = {};
   }
   peu_importe& operator=(peu_importe &&p) {
      swap(p);
      return *this;
   }

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.

// ...
   peu_importe& operator=(const peu_importe &p) {
      peu_importe{p}.swap(*this);
      return *this;
   }
   template<class T>
      peu_importe& operator=(const T &val) {
         peu_importe{val}.swap(*this);
         return *this;
      }
};

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).

class mechant_peu_importe_cast {};
template<class Dest>
   Dest peu_importe_cast_risque(const peu_importe &p) {
      return p.unsafe_downcast<Dest>();
   }
template<class Dest>
   Dest peu_importe_cast(const peu_importe &p) {
      if (!p.is_a<Dest>())
         throw mechant_peu_importe_cast();
      return peu_importe_cast_risque<Dest>(p);
   }

Effacement de type par levée d'exceptions

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.

class B {};
struct D : B {};
int main() {
   peu_importe p = new B;
   //
   // ceci lèvera une exception (légitime!)
   // du fait que typeid(B*)!=typeid(D*)
   //
   D *d = peu_importe_cast<D*>(p);
}

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.

La démarche

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!

class any_ptr {
public:
   any_ptr(const any_ptr&) = delete;
   any_ptr& operator=(const any_ptr&) = delete;

Un any_ptr possèdera trois attributs, tous des pointeurs :

  • Celui que nous nommerons p_ et qui mènera vers le pointé. C'est par ce pointeur que passera l'effacement de type à proprement dit
  • Celui que nous nommerons lanceur_ et qui nous servira à réaliser les tests de conversion lorsque nous chercherons à retrouver un pointé convenablement type. Remarquez qu'il s'agit d'un pointeur de fonction, donc de quelque chose qui pourra mener vers une méthode de classe ou vers une fonction globale, et
  • Celui que nous nommerons destructeur_ et qui nous permettra de détruire convenablement le pointé
private:
   void *p_;
   void (*lanceur_)(void *);
   void (*destructeur_)(void *);

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.

// ...
   template <class T>
      static void lanceur(void *p) {
         throw static_cast<T*>(p);
      }
   template <class T>
      static void destructeur(void *p) {
         delete static_cast<T*>(p);
      }

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 :

  • L'attribut p_ contiendra un pointeur vers le T* mais dont le type aura été effacé, et
  • Les pointeurs de fonctions lanceur_ et destructeur_ pointeront vers les méthodes de classe any_ptr::lanceur<T>() et any_ptr::destructeur<T>(), toutes deux générées expressément en fonction du type T en question.
public:
   template <class T>
      any_ptr(T *p)
         : p_{p},
           lanceur_{lanceur<T>},
           destructeur_{destructeur<T>}
      {
      }

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é.

      template <class T>
         T* cast_to() const {
            try {
               lanceur_(p_);
            } catch (T *p) {
               return p;
            } catch(...) {
            }
            return nullptr;
         }

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é.

      ~any_ptr() {
         destructeur_(p_);
      }
   };

Tester le tout

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>.

#include <iostream>
#include <typeinfo>
#include <string>
using namespace std;

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.

template <class T>
   void test_cast(any_ptr &p) {
      T *ptr = p.cast_to<T>();
      if (ptr)
         cout << "Transtypage en " << typeid(T).name()
              << "* --> valeur " << *ptr << endl;
   }

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.

struct Base {};
struct D0 : Base {};
struct D1 : Base {};
std::ostream& operator<<(std::ostream &os, Base)
   { return os << "Base"; }
std::ostream& operator<<(std::ostream &os, D0)
   { return os << "D0"; }
std::ostream& operator<<(std::ostream &os, D1)
   { return os << "D1"; }

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.

void test_any_ptr(any_ptr &p) {
   test_cast<short>(p);
   test_cast<int>(p);
   test_cast<char>(p);
   test_cast<float>(p);
   test_cast<string>(p);
   test_cast<Base>(p);
   test_cast<D0>(p);
   test_cast<D1>(p);
}

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.

int main() {
   any_ptr p = new int(3);
   test_any_ptr(p);
   cout << "-----" << endl;
   any_ptr q = new string("yo");
   test_any_ptr(q);
   cout << "-----" << endl;
   any_ptr base = new Base;
   any_ptr d0 = new D0;
   any_ptr d1 = new D1;
   test_any_ptr(base);
   cout << "-----" << endl;
   test_any_ptr(d0);
   cout << "-----" << endl;
   test_any_ptr(d1);
   cout << "-----" << endl;
}

Le résultat de l'exécution de ce programme de test avec le compilateur accompagnant Visual Studio 2010 est :

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 à :

Lectures complémentaires

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/


Valid XHTML 1.0 Transitional

CSS Valide !