08 fév. 2010

Le futur standard C++ : les templates variadiques

Oui, je sais, variadique n'est pas un mot très franchouillard (et franchouillard n'est pas trouvé par le dictionnaire de Firefox, qui me propose antibrouillard à la place). Il faut toutefois bien trouver quelque chose. Les fonctions à un argument sont des fonctions monadiques. Une fonction à plusieurs arguments est dite polyadique. Alors utiliser le néologisme "variadique" pour parler d'un nombre d'arguments variable ne me choque pas vraiment - d'autant plus que ce néologisme est très utilisé dans la communauté C / C++.

Outre cette digression, reste à expliciter le but de cet article. Comme les articles précédents de la série (et certains commencent à être ancien), cette saillie technique va tenter de vous donner quelques informations d'importance concernant cette nouveauté du futur standard C++ - l'une des plus importante, si vous voulez mon avis.

L'Existant

Afin de bien saisir toute la puissance de ce nouvel outil, il convient de faire le tour des solutions existantes. A l'heure actuelle, notre bon vieux standard datant de 1998 et amendé en 2003 se contente de définir une notation simple pour gérer tout ce qui est fonction et macros variadiques : les célèbres "trois petits points" (...) qu'on retrouve par exemple dans la définition de std::printf :

namespace std {
  int printf(const char* format, ...);
}

Cette déclaration signifie que la fonction prends au moins un argument (la chaîne de formatage) mais qu'on peut lui en spécifier d'autres. Le problème principal est qu'il est impossible de connaître le type de chaque argument. Pour pouvoir les traiter correctement, on doit spécifier un certain nombre de codes dans la chaine de formatage. C'est dangereux, et loin d'être résistant à l'erreur, ainsi que le code suivant le montre :

int i = 0;
std::printf("%s est en fait du type int", i);
// crash, car printf() va tenter de transformer 0 en un pointeur vers une 
// chaîne de caractère ; or 0 est le pointeur null...

Ce code ne peut pas être vérifier par le compilateur, qui au maximum pourra se contenter de lever un warning - mais pas une erreur, tant votre légendaire sagacité lui est précieuse - après tout, t peut très bien contenir une adresse valide pour une chaine de caractère - bien que cela soit improbable et certainement en dehors de la norme C++ (qui ne spécifie pas de conversion possible entre un type intégral et un pointeur).

Bref, ce n'est pas génial, mais ça tombe en marche suffisamment souvent pour qu'on le considère avec bienveillance.

Au niveau des arguments de templates, rien n'existe - donc le problème est à priori réglé. A priori seulement, car le TR1 définit de nombreux types template qui peuvent prendre un nombre quelconque d'arguments : std::tr1::tuple<> est un excellent exemple. Comment écrire le code permettant de créer un tuple quelconque alors que le langage impose que le nombre d'arguments du type template soit connu ? Les concepteurs de boost et de la librairie standard GNU sont ingénieux : un savant mélange de macros, une bonne dose d'utilisation du préprocesseur et hop, le tour est joué... pour un nombre maximum d'arguments. Pour simplifier, le code ressemble à :

#define N__ARGUMENT_COUNT 10
#include "definition_with_N_args.h"
#define N__ARGUMENT_COUNT 9 #include "definition_with_N_args.h"
...

Le fait est qu'une fois passé par le précompilateur, l'unité de compilation obtenue est une horreur sans nom. Elle comptera des milliers de lignes, et par conséquent sera extrêmement longue à compiler. En fait, pour certains cas particuliers dans le TR1, je soupçonne que le temps de compilation des fichiers headers n'est plus (et de loin) une fonction linéaire du nombre d'arguments qu'il est possible d'utiliser. Et puis on abeau dire : 10 arguments, c'est peut être assez pour régler un problème particulier. Pour en régler un autre, il en faudra peut-être 20, ou 30.

temps du compilation du TR1 GNU en fonction du nombre d'argument des templates

Ce problème est l'une des motivation principale de Douglas Gregor, la personne qui a formalisé la proposition des templates variadiques pour le C++ - et dont j'ai honteusement volé le diagramme ci-dessus.

Bref, cet court état des lieux démontre assez clairement que les solutions existantes sont loin d'être optimales. Il était temps de faire quelque chose...

Variadic templates : la proposition N1603 (pfd)

La toute première proposition de Douglas Gregor, Jaakko Järvi et Gary Powell décrivant les templates variadiques a été publiée en Février 2004 sous le numéro N1603. Elle présente une évolution majeure du coeur du langage en décrivant une syntaxe (encore approximative et remarquablement loin de ce qui a été adopté par le comité de normalisation) permettant l'utilisation d'un nombre de paramètres variable dans la définition d'une classe ou d'une fonction template. Il faut attendre septembre 2006 (proposition N2080) pour commencer à voir ce qui deviendra la syntaxe officielle. Une fois les bases solidement établies, les propositions s'intéressent à la formulation de cette nouvelle fonctionnalité de manière à l'intégrer à la norme C++. Se suivent les proposition N2152, N2192 et N2242 en avril 2007. Cette dernière version sera à peine remaniée avant d'être intégrée au draft du standard C++ en le mois suivant (voir N2283: Editor's Note. Depuis cette date, peu de chose ont véritablement changé, si ce n'est la syntaxe de l'opérateur sizeof...() associé.

Nous allons étudier ce qui est présenté dans la dernière version du draft du standard - N3000.

La concept maître des templates variadiques est la notion de paquet de paramètres (parameters pack dans le texte du standard ; nous allons simplement utiliser l'acronyme PP pour parler d'eux). Les PP sont intimement liés à une syntaxe particluière mettant en oeuvre les trois caractères ... déjà utilisé pour signifier un nombre variable d'arguments dans la déclaration d'une fonction C.

Prenons les déclarations suivantes :

// déclaration d'une classe template prenant un nombre variable de types en argument
template <class... Args>
class C { };

Dans cette déclaration, on crée un PP de nom de Args. Ce PP, on peut en obtenir la taille grâce à l'opérateur %%sizeof...()%% qui prends en paramètre un PP et renvoie un std::size_t qui correspond au nombre d'entités qu'il contient. Si on déclare C<int,double,long>, on a la relation sizeof...(Args) == 3 dans la portée de C.

Un PP peut être "dépaqueté" grâce à ... qui agit dans ce cas comme un méta-opérateur recréant la liste d'argument.

template <class... Args>
struct C { 
  static const std::size_t size = sizeof...(Args); 
};
template <class... Args> struct D { static const bool has_two_args = C<Args...>::size == 2; };

Dans ce code, l'expression Args... reconstruit la liste d'arguments complète de manière à la passer à la définition de C.

On peut se servir de cette propriété pour récursivement vider le PP. Par exemple :

// pré-déclaration de C
template <class... Args> struct C;
// plus de paramètre : fin de la récursion template <> struct C<> { }; // récursion : C<T,Args...> hérite de C<Args...> template <class T, class... Args> struct C<T, Args...> : public C<Args...> { T value; };

Si on analyse ce code et ce qui se passe lorsqu'on instancie C avec les paramètres <int,float,long> :

  • C<int,float,long> définit une variable value de type int et hérite de C<float,long>
  • C<float,long> définit une variable value de type float et hérite de C<long>
  • C<long> définit une variable value de type long et hérite de C<>.
  • C<> stoppe la récursion.

Au final, On se retrouve avec un type définissant 3 variables, une de chaque type passé en paramètre.

Lorsqu'on défini une fonction prenant un nombre variable d'arguments, on joue en fait avec un ou plusieurs PP.

template <class... Types>
void function(Types... values) { }

Cette définition fait intervenir deux PP : Types (une liste de type) et values (une liste de valeurs). values et Types sont liés, puisque le type de chacun des paramètres de la fonction va se retrouver dans le PP Types.

Grâce à cette notion de PP et grâce à la syntaxe relativement simple des templates variadiques, on peut réécrire une version type-safe de la fonction printf() :

void printf(const char* format)
{
  while (*format) {
    if (*format == '%') throw std::exception("not enough arguments provided");
    std::cout << *format;
    ++format;
  }
}
template <class T, class... Args> void printf(const char* format, const T& value, const Args&... args) { while (*format) { if (*format == '%') { // on peut passer le caractère donnant le type de la valeur // puisque celui-ci est automatiquement déduit pas std::cout. ++format; std::cout << value; printf(format, args...); } else { std::cout << *format; } ++format; } throw std::exception("too many arguments provided"); }

Encore une fois, la notation ... nous a permis de dépaqueter les PP args et Args ; encore une fois, nous avons utilisé la récursion pour dépiler progressivement les différents arguments des deux PP. Une petite nouveauté toutefois : dans la définition de la version variadique de printf(), on voit qu'on a utilisé la notation const Args&... args. Cette notation permet bien évidemment à dire que chaque argument du PP args est une référence constante vers un type de du PP Args.

Conclusion

Bien entendu, la librairie standard va profiter de cette nouvelle notation - notamment ceux des types définis dans le TR1 qui utilisent un nombre variable d'arguments (std::tr1::tuple<>, std::tr1::function<>...). Dès que vous allez devoir travailler avec un ensemble variable d'arguments, vous allez pouvoir utiliser les templates variadiques de manière à vous assurer que vous contrôlez correctement le type des paramètres utilisés. Cette solution simple à mettre en oevre va limiter les problèmes liés aux fonctions variadiques définies dans le standard C et reprises dans le standard C++ de 1998.

A noter que le support des templates variadiques commence à faire son apparition dans les compilateurs du marché. Si Visual Studio .Net 2010 beta 2 ne semble pas les supporter, les dernières versions de GCC (notamment la version en cours de développement gcc 4.5) proposent un support expérimental - activé par l'option --std=c++0x en ligne de commande. Vous pouvez donc vous amuser à en tester les finesses.

Commentaires

1. Le mardi, février 9 2010, 14:39 par 3DArchi

Salut,
J'aime beaucoup cette évolution et je pense aussi qu'elle fait partie des évolutions 'majeures'. Elle va simplifier pas mal de code qui jusqu'à présent pouvait au mieux se baser sur des choses comme Boost.Pre Processor.
L'idée de base est souvent de penser 'récursivement', pratique que, par le passé, j'avais tendance à mettre de côté dans le code exécuté pour tous les risques que ça pose.
Je me demande si les variadics template ne vont pas contribuer à renforcer l'approche fonctionnelle de plus en plus en vogue et portée en C++ par l'approche générique.
Sinon, les variadics template sont déjà implémentés sur gcc 4.4.1 (pas besoin d'attendre la 4.5).
Mais il reste encore des explorations à faire, en particulier je pense à la façon dont ça va s'articuler avec les listes d'initialisations (qui sont certes monotype). Normalement, on ne devrait plus voir de '...' dans du code C++0x ;)

2. Le lundi, juillet 5 2010, 15:16 par Ekinox

Oh, non, on a déjà reperdu les images !
Sinon, une petite perte de précision que cette fonction printf : on ne peut plus mettre de % dans les chaînes de caractères avec %% ; ça serait interprété comme deux paramètres.

Sinon, un article très intéressant, comme d'habitude

3DArchi > "Normalement, on ne devrait plus voir de '...' dans du code C++0x ;)" Et comment tu met tes templates variadiques, si ce n'est avec les '...' ? (Tu aurais dû dire les macros va_* :p ). Aujourd'hui est un grand jour. J'ai réussi à trouver une erreur dans les dires d'un gourou de dvpz.net :D

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet