Introduction aux classes de traits
Posted by Alp Mestanogullari in Développement, tags: Cpp, Templates
Pour mon premier post ici, je vais présenter les classes de traits, technique de base liée aux templates du langage C++. En utilisant une classe de traits dans du code template, on délègue une partie du travail à cette dernière.
Une classe de traits est une classe (ou structure) template qui associe à un type donné d’autres types (grâce à des typedefs) ainsi que des fonctions membres statiques. La puissance des traits est due au fait que cela ajoute un niveau d’abstraction : on peut donner des définitions génériques, puis spécialiser la structure template pour certains types. On gère ainsi de manière transparente des cas spécifiques, sans s’en soucier dans le code client en utilisant des types calculés à la compilation, des constantes ou des fonctions statiques. Ainsi, les types, constantes ou fonctions statiques de structure/classe utilisés dans une fonction template par exemple pourront être différents selon le type template, sans que la fonction elle-même ait à le savoir. Pour imager un peu cette notion, regardons le code suivant.
template <typename T>
struct type_descriptor
{
typedef T type;
typedef T* pointer;
typedef T& reference;
typedef const T const_type;
// ...
};
// Plus loin dans le code
int i = 42;
std::string s = "Hello world";
type_descriptor<int>::pointer pi = &i;
*p = 24;
type_descriptor<std::string>::pointer ps = &s;
*s = "Bonjour, le monde";
De par la nature des templates, l’écriture de type_descriptor<type> va générer une structure identique à type_descriptor, à l’exception qu’elle ne sera pas template, c’est à dire que tous les T qui apparaissent dans type_descriptor seront remplacés par le type donné en paramètre de type_descriptor. Ainsi, lorsque l’on accèdera à type_descriptor<int>::pointer, on obtiendra int*. Il en va de même pour tous les autres types définis.
Bien qu’apparemment solide, la classe de traits type_descriptor ne gère pas des cas problématiques. L’un d’entre eux est celui où l’on essaye d’utiliser type_descriptor<T>::reference avec un type T qui est déjà une référence, par exemple pour T étant int&.
int i = 42; int& j = i; type_descriptor<int&>::reference k = j; // erreur, le type 'reference' serait int&& ! // jusqu'à C++0x, cela n'a pas de sens // cela désignera les 'rvalue references'
Il faut donc éviter cette situation. C’est là que la spécialisation de structure template entre en jeu. On va simplement spécialiser partiellement la structure type_descriptor pour tous les types ‘référence’, c’est à dire pour tous les types de la forme T&, comme montré dans le code suivant.
// la spécialisation de type_descriptor pour les références
template <typename T>
struct type_descriptor<T&>
{
// le changement important
typedef T& reference; // au lieu de T&&
// ...
};
Bien qu’utilisées majoritairement pour l’association de types, les classes de traits servent également à associer des fonctions statiques ainsi que des constantes. L’exemple trivial suivant illustre cela :
template <typename T>
struct informations;
template <>
struct informations<int>
{
static const char* description() { return "integer"; }
enum { id = 0 };
};
template <>
struct informations<char>
{
static const char* description() { return "character"; }
enum { id = 1 };
};
// ...
Par ailleurs, grâce à la spécialisation partielle on peut obtenir une description textuelle d’un type, comme le montre l’exemple complété qui suit.
template <typename T>
struct informations;
template <typename T>
struct informations<T*>
{
static std::string description()
{
return std::string("pointer to ") + informations<T>::description();
}
enum { id = informations<T>::id };
};
template <typename T>
struct informations<T&>
{
static std::string description()
{
return std::string("reference to ") + informations<T>::description();
}
enum { id = informations<T>::id };
};
template <>
struct informations<int>
{
static std::string description() { return "integer"; }
enum { id = 0 };
};
template <>
struct informations<char>
{
static std::string description() { return "character"; }
enum { id = 1 };
};
// ...
std::cout << informations<char**>::description() << std::endl;
// affiche "pointer to pointer to character"
// on peut utiliser les id pour avoir ensuite un identifiant pour les types
Maintenant, vous pouvez vous demander qui utilise vraiment les traits dans la vraie vie, dans le vrai monde C++. Pour commencer, vous en avez dans la bibliothèque standard, avec les classes de traits char_traits (dans l’entête <string>) et iterator_traits (dans l’entête <iterator>). De plus, vous qui connaissez surement Boost (et qui attendez surement l’article de fireboot sur boost.thread avec impatience), avez peut-être déjà entendu parler de boost.type_traits, qui définit une quantité impressionnante de classes de traits pour obtenir des informations sur des types, et d’éventuelles relations entre des types (héritage, par exemple). D’autres bibliothèques de Boost utilisent les traits, comme boost.graph, et de manière globale c’est une technique qui est utilisée à chaque fois que l’on veut abstraire des algorithmes vis à vis des types sur lesquels ils opèrent. Ainsi, une classe de traits va permettre d’avoir l’interface minimale pour l’implémentation d’un algorithme, et tous les types sur lesquels on voudra faire fonctionner l’algorithme en question devront soit obéir à la classe de traits par défaut, soit spécialiser cette classe de traits.
Ok, ok, des exemples. Alors, vous vous êtes déjà surement demandés, lorsque vous écriviez une fonction qui ne modifie pas son argument, si vous devriez passer l’argument par copie ou référence constante. Voilà une question que l’on peut déléguer à une classe de traits ! Nous allons donner un type T en paramètre à une classe de traits et si la taille (via sizeof) du type est supérieure à 8 bytes la classe de traits retournera le type const T&, sinon elle retournera T. Nous allons passer par une structure imbriquée auxiliaire pour obtenir le résultat souhaité.
template <typename T>
struct func_arg_traits
{
template <typename U, bool sizeBiggerThanEight> struct aux;
template <typename U>
struct aux<U, true>
{
typedef U const& type;
};
struct aux<U, false>
{
typedef U type;
};
typedef aux<T, sizeof(T) > 8>::type type;
}
Et un exemple de définition de fonction qui utilise notre classe de traits :
template <typename T>
T add(typename func_arg_traits<T>::type t1, typename func_arg_traits<T>::type t2)
{
return t1+t2;
}
struct Foo
{
double d[2];
};
Foo operator+(const Foo& f1, const Foo& f2)
{
Foo f3;
f3.d[0] = f1.d[0] + f2.d[0];
f3.d[1] = f1.d[1] + f2.d[1];
return f3;
}
Foo f1; f1.d[0] = 0.4; f1.d[1] = 4.2;
Foo f2; f2.d[0] = 0.6; f1.d[1] = -3.2;
Foo f3 = add(f1, f2); // f1 et f2 pris par référence constante, car sizeof(Foo) > 8
int i1 = 40;
int i2 = 2;
int i3 = add (i1, i2); // i1 et i2 pris par valeur car sizeof(int) <= 8
Voyons enfin un deuxième exemple qui colle plus à la vision "abstraction des types sur lesquels opèrent les algorithmes". Il est tout d'abord important de remarquer que quand on écrit une classe de traits pour associer des informations à des types, on ne modifie pas les classes/structures/autres qu’on passe à notre classe de traits.
Ensuite, comme je l’ai dit plus haut, les traits peuvent fournir des fonctions statiques. Alors imaginons qu’on ait une bibliothèque “à la Java” qui fournit une classe A avec une fonction membre bool equals(const A& other), mais pas d’opérateur ==. Et que d’un autre côté, on ait une classe B qui elle définit bool operator==(const B&, const B&). Oh et puis, tiens, en fait, j’ai même une infinité de classe dans le même cas que B. Maintenant, j’ai besoin d’utiliser ces deux classes dans mon projet, et en particulier j’aimerais pouvoir avoir une fonction template qui permet de comparer des A ensemble, des B ensemble, etc. Et je ne peux modifier ni A, ni B ni les autres. Donc on ne peut pas les faire hériter d’une classe commune, etc. Mais on a quand même besoin de cette forme de polymorphisme qui permet de traiter indifféremment des A, des B, etc. Et bien c’est justement un travail pour les traits !
// le cas général : c'est celui de B et toutes les autres... mais pas A.
template <typename T>
struct compare_traits
{
static bool eq(const T& t1, const T& t2)
{
return t1==t2;
}
};
// le cas spécial de A, que l'on pourra appliquer à toute autre
// classe qui ne définit pas d'opérateur ==
template <>
struct compare_traits<A>
{
static bool eq(const A& a1, const A& a2)
{
return a1.equals(a2);
}
};
// utilisation de notre traits pour définir notre fonction 'compare'
// super générique
template <typename T>
bool compare(const T& t1, const T& t2)
{
return compare_traits<T>::compare(t1, t2);
}
Voilà c’est tout pour ce premier billet, j’espère qu’il vous a plu et à bientôt pour de nouvelles aventures avec C++ ! Ici cppland, à vous les studios.
Entries (RSS)