Etude du C++ Technical Report 1 - type_traits | 3 vote(s)
Par Emmanuel Deloget, jeudi 21 décembre 2006 à 18:00 :: C++ :: permalien #45
Cette série d'article étudie le C++ Technical Report 1 (TR1) d'une part du point de vue de son utilisation, et d'autre part tente de faire la lumière sur la manière dont sont implémentées les fonctionnalités qu'il offre. En particulier, cette série étudie la librairie <type_traits>.
Il doit être noté qu'à l'heure actuelle, il est difficile de tester les fonctionnalités du TR1 - pour la bonne raison que certains compilateurs ne supportent pas cette extension. Il est toutefois possible d'acheter une licence de cette librairie chez Dinkumware Ltd., ou tout simplement d'utiliser boost (qui propose une certain nombre de classes du TR1, mais qui ne l'implémente pas complètement). Les dernières versions de GCC implémentent une partie du TR1. Visual C++ (même la version .NET 2005) ne propose les nouvelles interfaces définies. Quant aux compilateurs Borland (en particulier les versions Turbo Explorer gratuites), elles sont accompagnées de la suite Dinkumware.
Ah oui, dernière chose : inutile d'essayer de compiler ce code avec Visual C++ 6. Lorsque je parle de compilateurs, je parle bien évidemment de compilateurs C++, et VC6 n'en est pas un.
L’interface type_traits
Nous allons laisser de coté l’étude de tr1::reference_wrapper<> pendant un court laps de temps, pour aborder un sujet plus simple et qui nous sera utile par la suite : la caractérisation des types, ou, en anglais, type traits.
Motivation
A quoi peut bien servir la caractérisation d’un type ? Bien évidemment, le but avoué de cette portion de la librairie TR1 n’est pas de permettre l’écriture d’un code qui ne respecterait pas l'OCP donc il a déjà été question à de nombreuses reprises ici. La caractérisation d’un type s’attache a extraire certaines constantes attachées à une type de données, dans le but d’implémenter une spécialisation d’algorithme ou de s’assurer qu’un algorithme générique travaille bien avec des données qu’il peut traiter.
Il est évident que les type traits n’ont que peu d’intérêt dès lors qu’on les utilise à l’extérieur d’une classe paramétrée. En effet, à quoi peut servir l’extraction des caractéristiques d’un type dont le programmeur sait déjà tout ? Il n’a qu’à déterminer les actions à exécuter en fonction du type qu’il manipule.
L’intérêt de ces interfaces est par contre plus évident dès lors qu’on s’essaie à la programmation générique : prenons par exemple le cas simple de la fonction std::copy(FirstIn, LastIn, FirstOut). Cette fonction a pour but de copier des données d’une source définie par deux itérateurs (FirstIn, LastIn) vers une destination (FirstOut ; LastOut n’est pas utilisé puisque par définition LastIn - FirstIn sont copiés). Le cas le plus simple est de copier objet par objet, en se servant de l’opérateur d’assignation. Bien évidemment, c’est aussi le cas le plus lent, puisque chaque membre est copié l’un après l’autre. Dans certains cas particuliers, il serait donc souhaitable de pouvoir copier plusieurs objets en une seule opération, de manière optimisée.
C’est là où les caractéristiques de type interviennent. Se basant sur des prédicats simples, elles permettent de choisir un algorithme particulier en se basant sur des informations de type qui sont connues au moment de la compilation.
Une autre possibilité offerte par ce concept concerne les assertions statiques (évaluées au moment de la compilation). Grace à elles, il est possible de déterminer dans quels cas une classe template peut être instanciée, et dans quel cas une telle instanciation est impossible - l'assertion statique provoquant alors une erreur de compilation. Bien évidemment, si une classe template n’admet pas une classe A comme paramètre, une telle assertion n’est pas nécessaire – puisqu’il est plus que probable qu’une erreur de compilation soit générée. Mais cette erreur peut être difficile à déchiffrer et peut induire le programmeur en erreur (il y a plus de chances pour qu’il pense avoir oublié de définir une méthode plutôt qu’il considère que son utilisation de la classe template n’est pas possible), tandis que l’assertion statique sera moins équivoque, puisqu’elle décrira précisément que la classe template ne doit pas être utilisée avec cette classe paramètre.
Présentation
La librairie type_traits se présente sous la forme d’un grand nombre de petites classes utilitaires. Pour une bonne partie, ces classes sont des prédicats qui dérivent d’une classe unique, tr1::integral_constant. Un prédicat répond à une question simple - par exemple, "est-ce que cette classe a un destructeur trivial ?".
D’autres classes ont été définies pour modifier les types - en supprimant par exemple les qualifications cv[1] ou en transformant un type T en un type T& (via tr1::add_reference).
Le fichier d’entête <type_traits> défini les prédicats suivants :
template <class T> struct is_void; template <class T> struct is_integral; template <class T> struct is_floating_point; template <class T> struct is_array; template <class T> struct is_pointer; template <class T> struct is_reference; template <class T> struct is_member_object_pointer; template <class T> struct is_member_function_pointer; template <class T> struct is_enum; template <class T> struct is_union; template <class T> struct is_class; template <class T> struct is_function;
template <class T> struct is_arithmetic; template <class T> struct is_fundamental; template <class T> struct is_object; template <class T> struct is_scalar; template <class T> struct is_coumpound; template <class T> struct is_member_pointer;
template <class T> struct is_const; template <class T> struct is_volatile; template <class T> struct is_pod; template <class T> struct is_empty; template <class T> struct is_polymorphic; template <class T> struct is_abstract; template <class T> struct has_trivial_constructor; template <class T> struct has_trivial_copy; template <class T> struct has_trivial_assign; template <class T> struct has_trivial_destructor; template <class T> struct has_nothrow_constructor; template <class T> struct has_nothrow_copy; template <class T> struct has_nothrow_assign; template <class T> struct has_virtual_destructor; template <class T> struct is_signed; template <class T> struct is_unsigned; template <class T> struct alignement_of; template <class T> struct rank; template <class T, unsigned I = 0> struct extent;
template <class T, class U> struct is_same; template <class Base, class Derived> struct is_base_of; template <class From, class To> struct is_convertible;
Outre cette liste déjà impressionnante, un certain nombre de classes de transformation sont aussi définies :
template <class T> struct remove_const; template <class T> struct remove_volatile; template <class T> struct remove_cv; template <class T> struct add_const; template <class T> struct add_volatile; template <class T> struct add_cv;
template <class T> struct remove_reference; template <class T> struct add_reference;
template <class T> struct remove_extent; template <class T> struct remove_all_extent;
template <class T> struct remove_pointer; template <class T> struct add_pointer;
template <std::size_t Len, std::size_t Align> struct aligned_storage;
Les prédicats héritent tous d’une classe utilitaire tr1::integral_constant<class T,T v> qui contient les membres suivants :
const T value = v;typedef T value_type;typedef integral_constant<T,v> type;
Deux types sont definis à partir de cette classe :
true_type, qui est un alias surtr1::integral_constant<boo , true>false_type, qui est un alias surtr1::integral_constant<bool, false>
Lorsque le prédicat sélectionné est vérifié pour sa liste de paramètres, il dérive de true_type. Lorsqu’il n’est pas vérifié, il dérive de false_type. Pour les rares cas ou le compilateur n’est pas capable de vérifier le prédicat (par exemple si le compilateur est limité dans son implémentation du standard C++), le TR1 spécifie qu’il doit alors dériver de false_type (tr1::has_virtual_destructor<>) ou ne dériver d’aucune classe et n’implémenter aucun membre (tr1::is_class<>, tr1::is_union<>, tr1::is_polymorphic<>, tr1::is_abstract<>).
Utilisation
Au vu de leur simplicité, l’utilisation de ces classes est relativement simple - bien qu’elle puisse induire des comportements complexes au final.
Le premier point à remarquer est que les type traits ont étés conçus dans une optique de méta-programmation. Il est donc logique de retrouver les utilisations les plus judicieuses dans ce domaine. Ainsi, prenons par exemple une fonction similaire à std::copy() et dont le but serait de copier un vecteur de valeurs vers un autre vecteur de valeurs. Nous souhaitons utiliser le fait que le type copié possède un operator=() (qui, si il est définit, peut effectuer une copie profonde (deep copy) ; si il n’est pas défini, une assignation a un résultat identique à une copie de mémoire, à partir du moment où le type source est identique au type destination).
On écrira alors :
template <class InIt, class OutIt, bool v>
void copy_internal(InIt FirstIn, InIt LastIn, OutIt FirstOut)
{
while (FirstIn != LastIn)
{
*FirstOut = *FirstIn;
++FirstIn;
}
}
template <class It>
void copy_internal<It*,It*,true>(It* FirstIn, It* LastIn, It* FirstOut)
{
std::memcpy(FirstOut, FirstIn, (LastIn-FirstIn)*sizeof(*It));
}
template <class InIt, class OutIt>
void copy(InIt FirstIn, InIt LastIn, OutIt FirstOut)
{
typedef typename std::iterator_traits<OutIt >::value_type value_type;
copy_internal<InIt,
OutIt,
tr1::has_trivial_assign<value_type>::value>
(FirstIn, LastIn, FirstOut);
}
Le code ci-dessus effectue un copie profonde si IntIt et OutIt sont des types différents et/ou si OutIt possède un opérateur d’assignation non trivial. Dans le cas contraire, une version optimisée utilisant std::memcpy() sera utilisée. L’important est que l’utilisateur utilise la fonction copy() sans rien savoir de son implémentation réelle.
Informations sur l’implémentation
Globalement, l’implémentation de cette partie de la librairie reste relativement simple – avec quelques points plus ardus, qui seront détaillés par la suite.
Bien évidemment, l’implémentation des type traits elle-même utilise des techniques de méta-programmation. Pour la plupart des prédicats, la technique préférée est celle de la spécialisation. Ainsi, on définit le prédicat is_void ainsi :
template <class T> struct is_void : public false_type { } ;
template <> struct is_void<void> : public true_type { };
template <> struct is_void<void const> : public true_type { };
template <> struct is_void<void volatile> : public true_type { };
template <> struct is_void<void const volatile > : public true_type { };
Une technique plus évoluée de specialization partielle permet aussi de definer certains prédicats – un exemple simple, is_pointer :
template <class T> struct is_pointer : public false_type { } ;
template <class T> struct is_pointer<T*> : public true_type { };
// cv-qualified types omitted
Parmi les prédicats restant, certains sont classes comme étant des prédicats composes car ils se basent sur d’autres prédicats déjà définis. Ainsi, is_arithmetic<T> dérive de true_type si T est un type arithmétique (flottant, entier, booléen). Il est donc défini ainsi :
template <class T> struct is_arithmetic :
public integral_constant<
bool,
(is_integral<T>::value || is_floating_point<T>::value)>
{ };
Pour certain prédicats, on est par contre obligé d’utiliser des techniques plus sophistiquées, telle que SFINAE. Par exemple, comment déterminer si un type est un type polymorphique (c'est-à-dire définissant au moins une fonction virtuelle ?) Il n’y a pas de définition exacte donnée par le standard C++, puisque l’implémentation de la gestion des fonctions virtuelle est laissée aux bons soins du vendeur de compilateur. Toutefois, la plupart des compilateurs implémentent une table de fonction virtuelle et celle-ci est généralement un membre caché (non accessible) de l’instance de la classe.
Si un type est polymorphique, alors cette table des fonctions virtuelle existe déjà. Si le type n’est pas polymorphique, elle n’existe pas. En tout état de cause, à liste de variables identiques, un type polymorphique a une taille plus importante qu’un type non polymorphique (dans le cas où la table des fonctions virtuelles est intégrée à l’objet[2]).
On ne peut bien évidemment pas tester la présence de la table de fonction virtuelle directement - mais on peut le faire de manière indirecte, en utilisant une constante du langage : si une classe Derived hérite de Base sans ajouter de membre, la taille de Derived sera égale à la taille de Base. Si on fait de Derived un type polymorphique et que Base ne l’est pas, alors on ajoute une table de fonction virtuelle à Derived et la taille de la classe change. Si Base est un type polymorphique alors l’ajout d’une fonction virtuelle dans Derived ne modifie pas la taille de l’objet puisque la table des fonctions virtuelle existe déjà. On peut dès lors implémenter cette technique ainsi :
template <class T> class __is_polymorphic_helper
{
template <class U> struct first : U { };
template <class U> struct second : U
{
virtual void dummy();
};
public:
static const bool value = (sizeof(first<T>) == sizeof(second<T>));
};
Si T est une classe, __is_polymorphic_helper<T>::value pourra alors être utilisé pour définir tr1::is_polymorphic<T>.
Encore plus pointu : comment faire pour déterminer si une classe T est abstraite ou non ? Si elle l’est, je n’ai pas le droit de l’instancier – la vérification ne peut donc pas se faire en provoquant une instanciation de la classe.
La encore SFINAE est un atout précieux. Le standard C++[3] décrit dans sa section 14.8.2 les différentes substitutions possibles dans un template, ainsi que les possibles cas d’échecs qui ne doivent pas être considérées comme une erreur. Dans le paragraphe 2 se cache la phrase suivante, à la base de la technique SFINAE :
Si la substitution dans un paramètre template ou dans le type de fonction de la fonction template résulte en un type invalide, la substitution échoue.
L’idée est donc de créer une fonction template dont l’argument est un type invalide. Mais attention : un classe abstraite est un type valide, il faut donc trouver un moyen de se servir de la classe abstraite dans la définition de ce type. Le salut nous vient d’un défaut du standard : en 2002 il a été admis qu’un oubli avait été fait dans la définition de ce qui pourrait être un type de tableau valide. Le standard publié affirme :
La déduction de type peut échouer pour les raisons suivantes : une tentative pour créer un tableau qui est du type void, pointeur sur fonction, ou d’un type référence ou une tentative pour créer un tableau dont la taille est nulle ou négative.
Il est dors et déjà acquis que la prochaine version du standard modifiera ce texte pour incorporer la notion de type abstrait, telle que l’indique la résolution du problème 337. De fait, la majorité des compilateurs du marché implémentent déjà cette résolution (c’est au moins le cas de VC++ .NET 2005, g++ 4.x et les versions disponibles du compilateur Comeau). Puisque je ne peux pas instancier la classe mais que j’ai besoin d’utiliser un notation de type tableau, la seule option qui me reste est de créer une fonction template qui prend en paramètre un pointeur sur un tableau[4].
template <class T> __is_abstract_helper
{
typedef char one[1];
typedef char two[2];
template <class U> static one& test(...);
template <class U> static two& test(U(*)[1]);
public:
static const bool value = sizeof(test<T>(0)) == sizeof(__one);
};
Si ces quelques cas peuvent être délicats, certains sont en revanche impossibles à implémenter sur certains compilateurs, même récents. Ainsi, g++ ne permet pas de distinguer une classe d’une union. Visual C++ .NET 2005 le permet, mais à condition d’utiliser des extensions non standard (dans ce cas, __is_class()). Le même problème se reproduit avec différents prédicats (par exemple tr1::has_virtual_destructor<>, tr1::has_trivial_assign<>, etc.) Dans ce cas, il est préférable d’utiliser les extensions fournies par le compilateur si elles sont présentes (). Une telle utilisation devra nécessairement être entourée des précautions d’usage - une protection via les mécanismes du pré-compilateur est souhaitée.
Le code source de <type_traits> qui accompagne ce billet utilise ces extensions quand elles sont présentes et qu’il n’existe pas d’autres alternatives permettant de découvrir le résultat souhaité. Le recours à cet extrême est protégés par l’utilisation de blocs #ifdef/#else/#endif.
Ainsi, tr1::is_base_of<> pourrait être implémenté en utilisant __is_base_of() fournit par le compilateur de Microsoft. Il est cependant plus judicieux de l’implémenter en utilisant les ressources fournies par le standard C++, par exemple comme ceci :
template <class B, class D> class __is_base_of_helper
{
typedef char one[1];
typedef char two[2];
static one& test(B*);
static two& test(...);
public:
static const bool value = sizeof(test((D*)0) == sizeof(one);
};
template <class B, class D> struct __is_base_of_value
{
static const bool value = __is_base_of_helper<remove_cv<B>, remove_cv<D> >::value;
};
template <class B, class D> struct is_base_of :
public integral_constant<bool, __is_base_of_value<B,D>::value>
{ };
Un exemple similaire avait déjà été donné dans le billet étudiant tr1::reference_wrapper<> (ici), le seule changement concernant la suppression des qualifications cv qui peuvent poser problème dans ce cas.
Avant de conclure, un mot sur les classes de transformation : celles-ci sont de deux types. Le premier transforme un type en un autre type (en enlevant ou en ajoutant une caractéristique au type source : tr1::remove_cv<> utilisé ci-dessus enlève les qualifications cv si elles sont présentes) ; l’autre type de classe de transformation ne transforme rien, malgré son nom. Elle est utilisée pour aligner un une zone mémoire sur un alignement connu et maîtrisé. tr1::aligned_storage<> est définie ainsi par le TR1 :
template <class Len, class Align>
{
union type
{
unsigned char __data[Len];
Aligner __align;
};
};
Etant donné que le standard C++ ne définit aucun mode d’alignement, Aligner est nécessairement dépendant de l’implémentation, et plus spécifiquement du compilateur choisi. g++ utilisera ainsi attribute((aligned(Align))) en suffixe de __align, tandis que VC++ utilisera __declspec(align(Align)) en préfixe de __align.
Conclusion
Il reste bien des choses dont je n’ai pas parlé concernant cette librairie. Je vous laisse les découvrir, et essayer de deviser une solution pour les cas les moins triviaux.
On voit ici deux points importants concernant l’implémentation du TR1 : premièrement, elle peut être problématique et demander de faire preuve de subtilité, notamment lorsque des points très précis du standard C++ sont abordés. En second, les limitations des différents compilateurs du marché peuvent aussi être un obstacle à une implémentation correcte du TR1, même si certains compilateurs implémentent des extensions permettant de passer outre ces limitations. Au final, on s’aperçoit que puisqu’il faut sans cesse jouer avec les forces et les faiblesses des compilateurs, il n’est pas possible d’écrire une implémentation générique de cette librairie. Alors, lorsque c’est nécessaire, n’hésitons pas !
Note : Un billet futur annoncera la disponibilité à venir du code source correspondant à ce billet.
Notes
[1] dans la norme C++, les qualifications cv regroupent la modification d’un type via l’utilisation des mots-clefs const et volatile
[2] c’est le cas pour la plupart des compilateurs du marché : VC++, g++, etc.
[3] ISO/IEC 14882, Programming Languages – C++
[4] La notation U(*)[1] utilisée ici définit une variable non nommée du type "pointeur sur un tableau de U".
Commentaires
Aucun commentaire pour le moment.
:: Fil rss des commentaires de ce billet ::
Ajouter un commentaire