Aller au contenu

Utilisateur:Golitan11/Brouillon/Effacement de types

Une page de Wikipédia, l'encyclopédie libre.

En programmation C++, l’effacement de types (anglais : type erasure) est un ensemble de techniques permettant « d’oublier » momentanément un type et de le retrouver (ou non) afin d’accéder aux données qu’il contient.[1]

Dans le cas de langage très fortement typé comme C++, l’effacement de types est un idiome non-standard ayant pour but de masquer toute information reliée à un type à la compilation et d’en retrouver une partie à l’exécution.[1]

Dans le cas de langage moins fortement typé comme Java, l’effacement de types est un mécanisme fondamental du langage qui permet aux génériques de fonctionner avec n’importe quel type.[2]

Contrairement aux langages orientés objet purs comme Java ou C#, les classes en C++ ne disposent d’aucune interface commune (Object en Java). Évidemment, il serait trivial de créer une telle interface et d’en dériver explicitement toutes nos classes, mais n’ayant pas de réflexivité en C++, il serait impossible de retrouver le type original de l’objet en question à l’exécution à partir de l’interface seule.[1] Cela étant dit, il existe néanmoins plusieurs cas d’utilisation où ce comportement serait souhaitable en C++.

Tout d’abord, il peut arriver que l’on souhaite créer un conteneur pouvant entreposer des objets de plusieurs types différents. La seule manière pour l’instant d’en faire autant est de créer un conteneur de pointeurs et de faire appel aux méthodes des objets sous-jacents via polymorphisme (appels virtuels). En d’autres termes, chacun des objets doivent respecter le même contrat imposé par une certaine interface, ce qui veut dire que quelques méthodes seulement peuvent être appelées.[3] Évidemment, un tel comportement n’est pas suffisant pour les besoins de la cause.

Ensuite, il peut arriver que l’on veuille passer différents types d’objet à une fonction. Deux solutions peuvent s’appliquer ici : par polymorphisme ou généricité (templates). Encore une fois, par polymorphisme on s’attend à ce que chaque objet ait une base commune, ce qui n’est pas toujours le cas. Quant à la généricité, les objets peuvent dépendre ou non d’une base commune, mais non seulement un abus de code générique peut rendre le code moins lisible, mais les temps de compilation sont affectés.[3] De plus, ces fonctions doivent généralement être définies dans les fichiers .h (dans le cas de méthodes). Il serait donc souhaitable de pouvoir passer un type d’objet quelconque sans avoir à passer par le polymorphisme ou la généricité.

Il existe plusieurs techniques permettant de retrouver un type « oublié ». Parmi ces techniques, il y en a plusieurs qui fonctionnent mais qui sont peu souhaitables. Entre autres, il est possible d'effacer n'importe quel type à l'aide du pointeur void (void*). Toutefois, cette technique n'est pas type-safe, ce qui veut dire que si un objet de mauvais type est passé à une fonction via un pointeur void, le compilateur l'ignorera et le comportement à l'exécution sera indéfini. Quant à remplacer le pointeur void par une interface commune, le comportement sera désormais défini, mais très limité.[4] Alors, plusieurs techniques plus complètes et fiables sont apparues au cours des dernières années pour remplacer les deux dernières. Les trois approches suivantes sont les plus populaires :

  • Par polymorphisme : permet l’appel de méthodes avec type de retour et paramètres ne dépendant pas du type caché, mais ne requiert pas de transtypage explicite;[3]
  • Par RTTI : permet de retrouver l’intégralité d’un objet non polymorphique, mais requiert un transtypage explicite;[5]
  • Par levée d’exception : permet de retrouver l’intégralité de n’importe quel objet, mais requiert un transtypage explicite et est plus lent que l'option précédente;[6]

À noter que les exemples de code ne sont pas nécessairement complets et ne servent qu'à illustrer les différentes techniques. 

Par polymorphisme

[modifier | modifier le code]
Diagramme de classes
Effacement de type par polymorphisme

Cette technique est généralement utilisée lorsqu'il faut partiellement effacer le type d'un membre d'une classe. La technique va comme suit (pseudo-étapes) :

  1. Il faut définir la classe A, classe dans laquelle il faut effacer le type d'un membre;
  2. Il faut définir une classe générique B qui contient le membre à effacer en question (dépend du type générique de B);
  3. Il faut définir une interface C qui expose les différentes méthodes pouvant être appelées à partir du membre;
  4. Il faut faire hériter la classe générique B de l'interface C et implémenter les méthodes en faisant appel à celles du membre en question directement;
  5. Dans la classe d'origine A, il faut conserver un pointeur vers un objet implémentant l'interface C (i.e. instance de la classe générique B);
  6. Dans la classe d'origine A, il faut ajouter une méthode qui permet de changer le type du membre (i.e. nouvelle instance de la classe générique B).

Par exemple, une classe Image (niveau de gris) dans laquelle il faut pouvoir changer le type de donnée qui représente l'intensité de chaque pixel (e.g. valeur entière entre 0 et 255, valeur réelle entre 0 et 1, etc.). Pour ce faire, il ne faudrait pas rendre la classe Image générique, car il est bien mieux de conserver l'unicité de la classe afin de pouvoir faire du code extérieur plus propre. Voici un exemple de cette technique avec les étapes en commentaire :

#include <iostream>
#include <typeinfo>

template<typename T>
struct Canal
{
    void afficher() const
    {
        std::cout << "Je contiens des données de type " << typeid(T).name() << std::endl;
    }
};

class Image // #1
{
    struct ImageInterface // #3
    {
        virtual ~ImageInterface() = default;
        virtual void afficher() const = 0;   
    };
    
    template<typename T> // #2
    class ImageWrapper : public ImageInterface // #4
    {
        Canal<T> canal; // Membre qui dépend de T
    public:
        void afficher() const override
        {
            canal.afficher();
        }
    };
    
    ImageInterface* data; // #5
    
public:
    Image() : data{}
    {
    }
    
    template<typename T> // #6
    void setCanal()
    {
        data = new ImageWrapper<T>();
    }
    
    ~Image()
    {
        delete data;
    }
    
    void afficher() const
    {
        data->afficher();
    }
};

int main()
{
    Image image;
    image.setCanal<char>();
    image.afficher();
    image.setCanal<float>();
    image.afficher();
}

// Sortie:
// Je contiens des données de type char
// Je contiens des données de type float

Cette technique est donc utile si l'on désire masquer un membre « interfaçable », au prix de ne pas pouvoir retrouver l'intégralité de son contenu. En d'autres mots, les méthodes appelées ne doivent ni être génériques, ni dépendre du type T, étant donné que ce sont des appels virtuels et que les types doivent être connus à la compilation.

Diagramme de classes
Effacement de type par RTTI

Cette technique est utilisée lorsqu'il faut masquer et retrouver l'intégralité d'un objet non polymorphique. L'acronyme RTTI fait référence aux outils en C++ qui permettent d'obtenir de l'information sur les types à l'exécution (e.g. dynamic_cast et typeid). La technique va comme suit (pseudo-étapes) :

  1. Il faut définir la classe A, classe dans laquelle un objet de type inconnu est entreposé; 
  2. Il faut définir une classe générique B qui contient un membre de type T (objet inconnu en question);
  3. Il faut définir une interface C qui contient une méthode de transtypage ainsi qu'une méthode servant à obtenir de l'information sur un type quelconque à l'exécution;
  4. Il faut faire hériter la classe générique B de l'interface C et implémenter la méthode qui permet d'obtenir de l'information sur un type à partir du membre en question;
  5. Dans la classe d'origine A, il faut conserver un pointeur vers un objet implémentant l'interface C (i.e. instance de la classe générique B);
  6. Dans la classe d'origine A, il faut ajouter une méthode qui permet de changer la valeur et ainsi le type du membre (i.e. nouvelle instance de la classe générique B);
  7. Dans la classe d'origine A, il faut ajouter une méthode qui permet d'obtenir le membre caché en question en spécifiant son type explicitement.

Généralement, cette technique est utilisée dans l'implémentation de Any (e.g. Boost.Any). Voici un exemple de cette technique avec les étapes en commentaire :

#include <iostream>
#include <string>
#include <typeinfo>

class MauvaisCast{};

class Any // #1
{
    struct AnyInterface // #3
    {
        template<class T>
        T downcastDangereux() const
        {
            return static_cast<const AnyWrapper<T>*>(this)->getValeur();
        }
        
        virtual ~AnyInterface() = default;
        virtual const std::type_info& getTypeInfo() const = 0;
    };
    
    template<class T> // #2
    class AnyWrapper : public AnyInterface // #4
    {
        T valeur; // Membre de type inconnu T
    public:
        AnyWrapper(const T& valeur) : valeur(valeur)
        {
        }
      
        const T& getValeur() const
        {
            return valeur;
        }
        
        const std::type_info& getTypeInfo() const override // #4
        {
            return typeid(T);
        }
    };
    
    AnyInterface* p; // #5
    
public:
    template<class T> // #6
    Any(T valeur) : p(new AnyWrapper<T>(valeur))
    {
    }
    
    ~Any()
    {
        delete p;
    }
    
    template<class T> // #7
    T getValeur() const
    {
        if (p && p->getTypeInfo() == typeid(T))
            return getValeurDangereux<T>();
        throw MauvaisCast{};
    }

    template<class T> // #7
    T getValeurDangereux() const
    {
        return p->downcastDangereux<T>();
    }
};

int main()
{
    Any v[3] =
    {
        int{ 5 },
        double{ 3.14159 },
        std::string{ "J'aime mon prof!" }
    };
    
    try
    {
        std::cout << v[1].getValeur<double>() << std::endl;
        std::cout << v[1].getValeur<int>() << std::endl;
    }
    catch (MauvaisCast)
    {
        std::cerr << "Mauvais type!" << std::endl;
    }
}

// Sortie:
// 3.14159
// Mauvais type!

Note : même s'il est possible d'utiliser cette technique pour des objets polymorphiques en utilisant dynamic_cast au lieu de l'opérateur typeid, cette technique est souvent utilisée pour retrouver l'objet masqué lui-même, et non un pointeur vers l'objet. Dans ce cas, il est préférable de restreindre les transtypages possibles au type original de l'objet seulement.[7]

Par levée d'exception

[modifier | modifier le code]
Diagramme de classes
Effacement de type par levée d'exception

Cette technique est utilisée lorsqu'il faut masquer et retrouver l'intégralité d'un objet polymorphique. Étant donné que cette technique est non triviale, voici un exemple suivi d'une explication détaillée :

#include <iostream>

class AnyPtr
{
    void* p;
    void (*lanceur)(void*);
    void (*destructeur)(void*);
    
    template<class T>
    static void lancer(void* p)
    {
        throw static_cast<T*>(p);
    }

    template<class T>
    static void detruire(void* p)
    {
        delete static_cast<T*>(p);
    }
    
public:
    template<class T>
    AnyPtr(T* ptr) : p(ptr), lanceur(lancer<T>), destructeur(detruire<T>)
    {
    }
    
    AnyPtr(const AnyPtr&) = delete;
    AnyPtr& operator=(const AnyPtr&) = delete;
    
    ~AnyPtr()
    {
        destructeur(p);
    }
   
    template<class T>
    T* getPointeur() const
    {
        try
        {
            lanceur(p);
        }
        catch (T* q)
        {
            return q;
        }
        catch (...)
        {
            std::cerr << "Mauvais type!" << std::endl;
        }
        
        return nullptr;
    }
};

int main()
{
    class Animal{};
    class Poulet : public Animal {};
    class Chien : public Animal {};

    AnyPtr p(new Poulet());

    Animal* p1 = p.getPointeur<Animal>(); // OK
    Poulet* p2 = p.getPointeur<Poulet>(); // OK
    Chien* p3 = p.getPointeur<Chien>(); // nullptr
}

Cette technique s'appuie sur le mécanisme d'exceptions de C++ pour permettre le passage de types. En effet, les données de l'objet caché en question sont représentées par un pointeur void, ce qui veut dire qu'il n'y a plus aucune trace du type de l'objet original. Toutefois, deux pointeurs de fonction permettent de faire le lien entre le type original de l'objet et ses données. En effet, dès la construction de la classe, les deux pointeurs sont initialisés à partir de deux méthodes génériques prenant en entrée le type de l'objet original. Ces deux méthodes s'assurent ainsi de transtyper les données de l'objet (pointeur void) vers sa représentation originale. Une de ces méthodes sert à détruire l'objet correctement. L'autre est celle qui fait usage du mécanisme d'exceptions. En effet, lorsqu'il faut retrouver le type original de l'objet, cette fonction est appelée et lance le type de l'objet original sous forme d'exception. Si le type explicitement fourni en entrée est bel et bien le bon, l'exception est attrapée et l'objet est retourné. Sinon, un pointeur nul est retourné.

La raison pour laquelle ce tour de passe-passe fonctionne est parce qu'il est possible de lancer n'importe quelle classe comme exception en C++. Évidemment, cette technique a un coût, car attraper une exception est généralement une opération très lente. En effet, étant donné qu'une exception est sensée être exceptionnelle, le standard C++ a décidé d'adopter un modèle zero-cost, ce qui veut dire que lorsque aucune exception n'est lancée, les performances ne sont pas affectées. Par contre, lorsqu'une exception est lancée, les performances sont affectées de manière importante.[8] Cette technique est donc intéressante, mais à éviter si les deux techniques précédentes sont applicables dans le contexte du problème.

En Java, la notion d'effacement de types est tout autre qu'en C++. En effet, c'est un concept qui est utilisé à même le langage pour permettre à ses génériques de bien fonctionner.

Contrairement aux templates de C++, les génériques de Java ne se retrouvent pas dans le bytecode généré, ce qui veut dire que toute l'information sur les types est perdue. En C++, même s'il n'existe pas de bytecode, les types originaux sont conservés, ce qui veut dire que chaque instance de classe générique est bien unique.[9] En Java, chaque type générique est par défaut remplacé par Object, ce qui veut dire que deux instances de classe générique différentes portent exactement la même signature. Ce procédé s'appelle l'effacement de types.[10]

Voici un exemple de code avant l'effacement de type :

public class Noeud<T> {
    private T data;
    private Noeud<T> suivant;

    public Noeud(T data, Noeud<T> suivant) {
        this.data = data;
        this.suivant = suivant;
    }
}

Voici le même code après l'effacement de type :

public class Noeud {
    private Object data;
    private Noeud suivant;

    public Noeud(Object data, Noeud suivant) {
        this.data = data;
        this.suivant = suivant;
    }
}

Démonstration :

Noeud<Integer> n1 = ... ; // Noeud de type Integer
Noeud<String> n2 = ... ;  // Noeud de type String
boolean b = n1.getClass() == n2.getClass() ; // b est vrai!

Références

[modifier | modifier le code]
  1. a b et c Patrice Roy, « Effacement de types », sur h-deb.clg.qc.ca (consulté le )
  2. (en) Stephen Morley, « Java generics and type erasure », sur code.stephenmorley.org (consulté le )
  3. a b et c (en) Dave Kilian, « C++ 'Type Erasure' Explained », sur davekilian.com (consulté le )
  4. (en) Andrzej Krzemieński, « Type erasure — Part I », Andrzej's C++ blog,‎ (lire en ligne, consulté le )
  5. (en) Thomas Becker, « On the Tension Between Object-Oriented and Generic Programming in C++ », sur www.artima.com, (consulté le )
  6. (en) Cassio Neri, « Twisting the RTTI System for Safe Dynamic Casts of void* in C++ », Dr. Dobb's,‎ (lire en ligne, consulté le )
  7. (en) Agustín Bergé, « Tales of C++ - Episode Nine: Erasing the Concrete », sur Tales of C++, (consulté le )
  8. (en) Edaqa Mortoray, « The true cost of zero cost exceptions », sur Musing Mortoray, (consulté le )
  9. José Paumard, « Implémentation des génériques », sur blog.paumard.org (consulté le )
  10. (en) « Erasure of Generic Types », sur docs.oracle.com (consulté le )