Code de grande personne – templates variadiques

Avertissement : ce document met en valeur plusieurs particularités de C++ 11, et suppose pour cette raison une familiarité avec :

Vous pouvez bien sûr le lire et en tirer profit sans avoir en banque une compréhension fine de tous ces a priori, mais n'hésitez pas à vous référer aux liens ci-dessus si vous souhaitez avoir des compléments d'information.

L'un des ajouts conceptuels importants à C++ depuis l'avènement de C++ 11 est la possibilité d'exprimer, directement à l'aide de mécanismes du langage, des types et des algorithmes génériques sur la base d'un nombre arbitrairement grand de paramètres.

Il faut comprendre tout d'abord que l'expression de tels types et algorithmes est possible depuis longtemps, par exemple grâce aux listes de types mises de l'avant par Andrei Alexandrescu. Cependant, les templates variadiques, mécanisme par lequel s'exprime cette nouvelle facilité du langage, simplifient et structurent cette pratique pour en faire un citoyen de première classe du langage.

Soupçon d'histoire – les fonctions variadiques de C

Le terme fonction variadique est utilisé en langage C pour décrire une fonction prenant un nombre arbitrairement grand de paramètres. Les plus connues de ces fonctions sont celles des familles printf() et scanf(), que nous examinerons brièvement ici.

Le code à droite donne un exemple d'utilisation des fonctions printf() et scanf(). Vous constaterez que ces fonctions sont flexibles, au sens où :

  • Elles prennent en paramètre une chaîne de caractères (un const char*) décrivant des règles de formatage, indiquées par des marqueurs tels que %s (chaîne de caractères) ou %d (entier)
  • Elles prennent par la suite un nombre de paramètres aussi grand que le souhaite le code client, et opère sur ceux-ci sur la base des règles décrites par la chaîne de formatage
  • Le hic est que, bien que flexible, cette approche est extrêmement dangereuse. Le code à droite, par exemple, n'empêche pas un usager pervers (ou simplement inconscient) d'entrer un nom plus grand que TAILLE_MAX_NOM caractères, causant un débordement de capacité sur la variable nom (question de formatage insuffisant dans ce cas-ci). Pire encore, c'est à l'exécution que la mise en correspondance de la chaîne de formatage et des paramètres subséquents est réalisée, ce qui fait qu'un programmeur pourrait ne pas utiliser le bon nombre de paramètres pour une chaîne de formatage donnée, ce qui peut constituer un sérieux bris de sécurité (surtout avec les fonctions de la famille scanf())
#include <cstdio>
int main() {
   using namespace std;
   enum { TAILLE_MAX_NOM = 64 };
   int age;
   char nom[TAILLE_MAX_NOM];
   printf("Entrez un nom (sans blancs) suivi d'un blanc et d'un age (entier): ");
   scanf("%s %d", nom, &age);
   printf("Vous avez entré: \"%s\" d'âge %d", nom, age);
}

Certains compilateurs accordent un traitement spécial à ces fonctions (pas toutes les fonctions variadiques, évidemment, mais celles qui sont connues et fortement usitées), et font une validation au préalable sur la correspondance apparente entre la chaîne de formatage et les paramètres supplémentaires. Il faut comprendre ici que cette pratique se veut utile et conviviale, mais dépend pleinement de l'outil; rien ne garantit un tel support a priori, et ces fonctions demeurent fondamentalement dangereuses.

Le langage C a un système de types simple; les fonctions variadiques n'y supportent que les types primitifs, mais cela n'y pose pas vraiment problème puisqu'il n'y a pas autre chose que des primitifs (incluant pointeurs sur des données et pointeurs sur des fonctions), des tableaux et des enregistrements dans ce langage.

La plupart des problèmes associés aux fonctions variadiques de C disparaissent avec C++, qui utilise une métaphore à deux opérandes :

  • Chaque écriture prend un flux, un opérande et retourne une référence sur le flux suite à l'écriture, ce qui permet d'enchaîner les écritures en aussi grand nombre que souhaité
  • La mécanique est la même pour la lecture
  • Un flux est testable comme un booléen, ce qui permet de tester le succès ou l'échec d'une opération. Le code à droite montre comment valider une lecture à l'aide d'une alternative (un if), même s'il ne traite pas le cas d'une erreur de lecture
  • Enfin, la possibilité d'utiliser des objets tels qu'une std::string plutôt que des types primitifs comme des tableaux de char permet d'écrire du code évitant tout débordement de capacité
#include <iostream>
#include <string>
int main() {
   using namespace std;
   int age;
   string nom;
   cout << "Entrez un nom (sans blancs) suivi d'un blanc et d'un age (entier): ";
   if (cin >> nom >> age)
      cout << "Vous avez entré: \"" nom << "\" d'âge " << age;
}

Les fonctions variadiques de C ne sont pas une solution généralement applicable en C++, pour les raisons indiquées ci-dessus. Cependant, nombreuses sont celles et nombreux sont qui aiment cette manière d'exprimer des entrées et – surtout – des sorties sous cette forme.

Java a pris une approche simple mais lente en appliquant un traitement particulier au type String et en faisant en sorte que tout type soit convertible en String par une méthode toString() souvent implicite.

Sans surprises, chaque conversion en String prend du temps, mais la métaphore est simple et accessible.

int âge;
String nom;
// ... lecture de l'âge et du nom...
// omis pour fins de simplicité...
System.out.println("Vous avez entré \"" + nom + "\" d'âge " + âge);

Les langages .NET, par exemple C# dans l'exemple à droite, utilisent une métaphore très proche de celle des fonctions du langage C, mais l'expriment en transformant les paramètres supplémentaires en un tableau de paramètres, susceptible d'être traité itérativement par la fonction appelée.

Nous avons ici un cas clair où, même si les langages .NET sont des outils contemporains, la métaphore préconisée par C, avec ses risques et ses tares, demeure en vogue et appréciée de plusieurs.

int âge;
string nom;
// ... lecture de l'âge et du nom...
// omis pour fins de simplicité...
Console.WriteLine("Vous avez entré \"{0}\" d'âge {1}", nom, âge);
// ... ou encore, avec interpolation
Console.WriteLine($"Vous avez entré \"{nom}\" d'âge {âge}");

Peut-on envisager une métaphore semblable à celle-ci, visiblement populaire, tout en demeurant sécuritaire et en évitant les coûts rencontrés en Java ou dans les langages .NET?

Exemple simple – remplacer l'infâme std::printf()

Les templates variadiques permettent d'implémenter un mécanisme pour réaliser l'impression d'autant de paramètres que souhaité dans un seul et même appel de fonction. Pour les besoins de l'exemple, nous ne ferons pas de formatage particulier, mais il ne serait pas difficile d'enrichir notre approche pour tenir compte de choses telles que représenter un nombre en format octal ou hexadécimal par exemple.

Voici une implémentation opérationnelle, sous laquelle j'utilise les flux d'entrée/ sortie standards de C++. Cet exemple a été écrit de manière à mettre en relief quelques caractéristiques des templates variadiques. Des explications suivent.

#include <iostream>
#include <string>
using namespace std;
template <class T>
   void print_(T &&arg) {
      cout << arg << ' ';
   }
template <class T, class ... Args>
   void print_(T && val, Args &&... args) {
      print_(std::forward<T>(val));
      print_(std::forward<Args>(args)...);
   }
template <class ... Args>
   void print(Args &&... args) {
      cout << "Impression de " << sizeof...(Args) << " parametres: ";
      print_(std::forward<Args>(args)...);
   }
int main() {
   string s = "Yo";
   print("J'aime mon prof!", 3, s);
}

J'ai fait le choix délibéré ici d'insérer un blanc entre chaque élément affiché, pour les fins de cet exemple de démonstration, mais en pratique ce n'est pas ce que je privilégierais; il ne faut pas chercher à imposer de tels choix au code client, à mon avis.

Examinons les éléments clés, prenant le programme de bas en haut, du concret vers l'abstrait.

Programme principal – appel à print()

Le programme principal met en relief l'appel à la fonction print() recevant trois paramètres, soit un const char*, un int et une std::string.

Ce qu'il faut noter dans cet exemple est que nous n'avons pas écrit une fonction print() à trois paramètres; nous avons plutôt écrit une fonction print() générale, acceptant autant de paramètres que souhaité.

Fonction générale print(Args && ...)

Cette fonction a été écrite pour mettre en relief :

Remarquez la syntaxe pour les paramètres variadiques :

Opérateur sizeof...(Args)

Alors que l'opérateur statique sizeof(T) s'applique à un type T (ou à une instance d'un type T) permet de connaître dès la compilation le nombre de bytes qu'occupe son paramètre en mémoire, l'opérateur sizeof...(Args) s'applique à un groupe de paramètres et permet de connaître dès la compilation le nombre d'éléments du groupe de paramètres Args.

Fonction print_(T&&, Args&&...)

Pour traiter correctement chaque paramètre d'un groupe de paramètres, la manière la plus simple est de les prendre un par un, comme si nous traitions une liste dans un langage fonctionnel : traiter l'élément en tête de liste, puis traiter le reste des éléments (qui constituent eux-mêmes une liste) de manière récursive.

Les templates variadiques se distinguent des listes traditionnelles des langages fonctionnels au sens où ici, la récursivité se fait par la génération, dès la compilation, de fonctions spécialisées pour traiter les types impliqués lors de l'appel. Ainsi :

Utilité du Perfect Forwarding

Les appels à la fonction std::forward() dans le code ne sont pas décoratifs. À titre d'exemple, supposons la fonction print_size() imprimant la taille des types des paramètres plutôt que leur valeur, et supposons-là écrite comme suit :

template <class T>
   void print_size(T &&) {
      cout << sizeof(T) << ' ';
   }
template <class T, class ... Args>
   void print_size(T && val, Args && ... args) {
      print_size(val);
      print_size(args...);
   }

Si nous appelons cette fonction comme suit :

print_size("J'aime mon prof", 3, 3.14159);

... alors nous verrons probablement s'afficher quelque chose comme 16 4 8. Par contre, si nous appelons cette fonction comme suit :

print_size(3, "J'aime mon prof", 3.14159);

... alors nous verrons probablement s'afficher quelque chose comme8.

Merci à Jean Gabriel Le Sauteur, d'Eidos/ Square Enix, pour m'avoir fait remarquer ceci.

La différence tient à la position de la chaîne de caractères dans la séquence, et à un phénomène de décrépitude de pointeurs : lorsque la chaîne est placée au début de la séquence de paramètres, le compilateur la perçoit comme un const char (&)[16] (donc la taille en bytes est considérée 16) lors que lorsque ce tableau est passé à une fonction, la sémantique associée au pointeur se perd et le compilateur ne voit plus qu'un const char* (donc pour le compilateur, la taille devient celle d'un pointeur).

Cette perte de sémantique est évitée par le recours à std::forward(), qui a pour rôle de réaliser le Perfect Forwarding, donc de convertir un type de manière à en éviter les pertes de sémantique. écrire ceci :

template <class T>
   void print_size(T &&) {
      cout << sizeof(T) << ' ';
   }
template <class T, class ... Args>
   void print_size(T && val, Args && ... args) {
      print_size(forward<T>(val));
      print_size(forward<Args>(args)...);
   }

... a pour conséquence que les deux appels ci-dessous afficheront respectivement 16 4 8 et16 8, donc que nous éviterons les pertes de sens.

print_size("J'aime mon prof", 3, 3.14159);
print_size(3, "J'aime mon prof", 3.14159);

Fonction print_(T&&)

Enfin, la version de print_() à un seul paramètre fait l'affichage à proprement dit.

Comme pour toute fonction du genre, il est possible de la spécialiser pour différents types, tout en gardant la version générique proposée ici comme cas général. Ceci explique que print_(T,Args...) appelle successivement print_(T) et print_(Args...) plutôt que d'afficher le T directement : en passant par la fonction à un seul paramètre dans tous les cas, la spécialisation par type devient possible, et nous gagnons en flexibilité sans pertes du point de vue de la vitesse d'exécution.

Exemple amusant – somme et moyenne de valeurs

Petit exemple amusant : écrire une fonction somme_elements() qui accepte plusieurs valeurs « libres », au sens de « qui ne sont pas dans un conteneur », et retourne la somme de ces valeurs, puis écrire une fonction moyenne acceptant aussi plusieurs valeurs « libres ».

Un résultat possible serait ceci, qui est légal en C++ 14 (avec C++ 11, il y a une subtilité à explorer qui rend la chose possible mais plus croustillante; voir https://isocpp.org/blog/2012/12/stupid-name-lookup-tricks-for-c11 par Eric Niebler en 2012 pour les détails).

#include <iostream>
using namespace std;

template <class T, class ... Reste>
   auto somme_elements(T val, Reste ... reste) {
      return val + somme_elements(reste...);
   }

template <class T, class U>
   auto somme_elements(T val0, U val1) {
      return val0 + val1;
   }

template <class R, class ... Args>
   R moyenne(Args ... args) {
      return static_cast<R>(somme_elements(args...)) / sizeof...(Args);
   }

int main() {
   cout << somme_elements(2,3,5,7,11) << endl;
   cout << moyenne<double>(2,3,5,7,11) << endl;
}

Littéralement :

Joli, non?

À partir de C++ 17, l'avènement des Fold Expressions permettra de réduire le code à ceci :

template <class R, class ... Args>
   R moyenne(Args &&... args) {
      return static_cast<R>(args + ...) / sizeof...(Args);
   }

Difficile de faire mieux!

Exemple plus riche – « validateur » général

L'exemple ci-dessous présente une classe Validateur<T> dont les instances pourront être utilisées comme des foncteurs booléens applicables à un T. à la construction, un Validateur<T> recevra une séquence de taille arbitrairement grande faite d'entités (fonctions, foncteurs, expressions λ), ce qui explique le recours à un constructeur variadique.

Par la suite, quand on utilisera un Validateur<T> en tant que prédicat, celui-ci s'avérera seulement si tous les prédicats qu'il aura reçus à la construction s'avèrent aux aussi.

#include <iostream>
#include <functional>
#include <vector>
#include <string>
#include <algorithm>
#include <locale>
using namespace std; // un peu de lâcheté, je sais
template <class T>
   class Valideur {
      vector<function<bool(const T&)>> tests;
      template <class U>
         void insert(U &&pred) {
            tests.emplace_back(forward<U>(pred));
         }
      template <class U, class ... Args>
         void insert(U &&pred, Args&& ... args) {
            insert(forward<U>(pred));
            insert(forward<Args>(args)...);
         }
   public:
      template <class ... Args>
         Valideur(Args&& ...  args) {
            insert(forward<Args>(args)...);
         }
      bool operator()(T arg) {
         return find_if(begin(tests), end(tests), [&](function<bool(const T&)> pred) {
            return !pred(arg);
         }) == end(tests);
      }
   };

struct est_non_nul {
   template <class T>
      bool operator()(const T *p) {
         return p != nullptr;
      }
};

bool est_de_longueur_raisonnable(const string *s) {
   return s && s->size() < 10;
}

template <class V>
   class valider_avec_impl {
      V pred;
      string nom;
   public:
      valider_avec_impl(V pred, const string &nom) : pred(pred), nom(nom) {
      }
      void operator()(const string &s) {
         cout << "Chaine \"" << s << "\"... ";
         if (pred(&s))
            cout << "Ok selon " << nom << endl;
         else
            cout << "Pas Ok selon " << nom << endl;
      }
   };

template <class V>
   valider_avec_impl<V> valider_avec(V &&valideur, const string &nom) {
      return valider_avec_impl<V>(forward<V>(valideur), nom);
   }

int main() {
   auto sans_majuscules = [](const string *s) -> bool {
      const auto &loc = locale{""};
      return !s ||
             find_if(begin(*s), end(*s), [&](const char c) {
                return isupper(c, loc);
             }) == end(*s);
   };
   Valideur<const string*> val0 { est_non_nul() };
   Valideur<const string*> val1 (est_non_nul(), est_de_longueur_raisonnable, sans_majuscules);
   string tab [] { "J'aime mon prof", "moi aussi", "CECI EST PAS MAL PAS CHOUETTE" };
   for_each(begin(tab), end(tab), valider_avec(val0, "val0"));
   for_each(begin(tab), end(tab), valider_avec(val1, "val1"));
   if (val1(nullptr))
      cout << "val(nullptr) est Ok selon val1" << endl;
   else
      cout << "val(nullptr) n'est pas Ok selon val1" << endl;
}

Portez attention aux constructeurs des instances val0 et val1 dans main(). La première instanciation utilise la nouvelle syntaxe d'initialisation des objets de C++ 11 et prend son paramètre entre accolades, pour éviter une ambiguïté syntaxique très irritante qui laisserait entendre au compilateur que val0 serait un prototype de fonction; la seconde montre comment il est possible de passer plusieurs entités distinctes à la construction d'un même objet en utilisant les templates variadiques.

Dans les constructeurs, les éléments sont déduits un à un du groupe de paramètre par une approche semblable à celle décrite pour la fonction print() variadique, plus haut.

Templates variadiques et héritage multiple

Autre exemple d'application des templates variadiques : réaliser une injection de parents pour plusieurs parents d'un seul coup. Le code proposé en exemple suit.

template <class ... P>
   class D : public P... {
   };
struct X {
   int f() const { return 3; }
};
struct Y {
   int g() const { return 4; }
};
struct Z {
   int h() const { return 5; }
};
int main() {
   D<X,Y,Z> d;
   print(d.f(), d.g(), d.h()); // par exemple
}

Cet exemple, bien qu'académique, montre comment main() peut construire une instance d de D<X,Y,Z>, où D<X,Y,Z> dérive à la fois de X, de Y et de Z.

Dans une formation pour Ubisoft Québec, une participante m'a demandé ce qui se passe si X, Y et Z exposent toutes trois des fonctions nommées f mais avec des signatures différentes, par exemple :

struct X { int f(int) const { return 3; } };
struct Y { int f(double) const { return 4; } };
struct Z { int f(char) const { return 5; } };
template <class ... P>
   struct Tapon : P... {
   };
int main() {
   Tapon<X,Y,Z>ze_tapon;
   ze_tapon.f(3); // ???
   ze_tapon.X::f(3); // Ok, mais laborieux
}

La réponse est que par défaut, main() ici est ambigu, et que la solution « simple » que serait ajouter using P...::f; dans Tapon<P...> serait illégale (c'est peut-être un bogue, à réfléchir).

Il y a toutefois une voie de contournement, qui passe par les listes de types :

struct X { int f(int) const { return 3; } };
struct Y { int f(double) const { return 4; } };
struct Z { int f(char) const { return 5; } };

template <class ...> struct type_list;
template <class>
   struct base_tapon;
template <class T, class ... Ts>
   struct base_tapon<type_list<T, Ts...>>
      : T, base_tapon <type_list<Ts...>> {
      using T::f;
      using base_tapon <type_list<Ts...>>::f;
   };
template <class T>
   struct base_tapon<type_list<T>> : T {
      using T::f;
   };

template <class ... P>
   struct Tapon : base_tapon<type_list<P...>> {
      using base_tapon<type_list<P...>>::f;
   };

int main() {
   Tapon<X, Y, Z> ze_tapon;
   cout << ze_tapon.X::f(3) << endl;
   cout << ze_tapon.Y::f(3.5) << endl;
   cout << ze_tapon.Z::f('a') << endl;
   cout << ze_tapon.f(3) << endl;
   cout << ze_tapon.f(3.5) << endl;
   cout << ze_tapon.f('a') << endl;
}

Vous remarquerez que le palliatif antérieur fonctionne toujours (ze_tapon est un X, donc ze_tapon.X::f(3) demeure légal), et que les fonctions f des divers parents de ze_tapon sont directement accessibles à partir de ze_tapon (dans la mesure où ces fonctions ne sont pas ambiguës au point d'appel).

Depuis C++ 17, il y a une solution bien plus simple, cependant :

struct X { int f(int) const { return 3; } };
struct Y { int f(double) const { return 4; } };
struct Z { int f(char) const { return 5; } };

template <class ... P>
   struct Tapon : P... {
      using P::f...;
   };

int main() {
   Tapon<X, Y, Z> ze_tapon;
   cout << ze_tapon.X::f(3) << endl;
   cout << ze_tapon.Y::f(3.5) << endl;
   cout << ze_tapon.Z::f('a') << endl;
   cout << ze_tapon.f(3) << endl;
   cout << ze_tapon.f(3.5) << endl;
   cout << ze_tapon.f('a') << endl;
}

Application type – opération emplace()

La délégation d'un nombre arbitraire de paramètres d'une fonction vers une autre permet au standard d'implémenter des opéraitons de type emplace(), qui constituent des optimisations importantes si on les compare avec leur équivalent traditionnel (p. ex. : en comparant emplace_back() avec push_back()).

Avec push_back()Avec emplace_back()
class X {
   // ...
public:
   X(int, const char*, double);
   // ...
};
// ...
#include <vector>
int main() {
   using std::vector;
   vector<X> v;
   // ...
   v.push_back(X{3,"J'aime mon prof",3.14159});
   // ...
}
class X {
   // ...
public:
   X(int, const char*, double);
   // ...
};
// ...
#include <vector>
int main() {
   using std::vector;
   vector<X> v;
   // ...
   v.emplace_back(3,"J'aime mon prof",3.14159);
   // ...
}

Visiblement, l'appel à push_back() insère un objet déjà construit dans le conteneur, ce qui tend à entraîner une séquence d'opération de la forme « créer une temporaire, copier dans le conteneur, détruire la temporaire », alors que l'appel à emplace_back() construit tout simplement un objet « en place » à partir des paramètres passés à emplace_back().

Pour qu'une opération emplace() soit possible, il faut que la méthode emplace() puisse à la fois prendre autant de paramètres que requis, peu importe leur type, et les relayer au constructeur du type à instancier. Les templates variadiques sont le mécanisme idéal pour y arriver.

Quand un conteneur offre une opération telle que push_back() et une opération telle qu'emplace_back(), il est généralement préférable d'utiliser emplace_back() qui constitue en ce sens une optimisation simple d'utilisation et dont les résultats sont immédiats.

Application type – fonction de fabrication

De manière analogue, les templates variadiques permettent d'écrire de meilleures fonctions de fabrication (schéma de conception Fabrique), en permettant d'expression d'une fabrique générale telle que la suivante :

template <class T, class ... Args>
   T* fabriquer(Args && ... args) {
      return new T(std::forward<Args>(args)...);
   }

Cette pratique est utilisée entre autres dans la fonction std::make_shared(), qui en profite pour réaliser certaines optimisations importantes pour la localité des données. En effet, créer un shared_ptr<T> de la manière suivante :

shared_ptr<T> p{new T(params)};

... fait en sorte d'allouer dynamiquement le T (instanciation faite par le code client) et un compteur de références sur ce T (instanciation faite par le constructeur du shared_ptr<T>), les deux devant être partagés. En retour, l'écriture suivante :

auto p = make_shared<T>(params);

...permet à la fonction de fabrication make_shared() de créer une structure compacte comprenant à la fois un compteur de références sur le T et le T lui-même, plaçant les deux à proximité l'un de l'autre pour que l'accès à la Cache soit nettement meilleure et pour réduire la fragmentation de la mémoire. Ici encore, le recours aux templates variadiques est nécessaire pour en arriver à une solution générale, mais le résultat est une optimisation directe, sans alourdissement de l'écriture.

Application type – Généraliser la double généricité

Dans un texte de 2014 (mentionné plus haut), Eli Bendersky met de l'avant une application charmante des templates variadiques. Supposons que nous souhaitions écrire une fonction print_container() capable de projeter sur un flux les éléments d'un conteneur donné. Une approche serait la suivante :

#include <iostream>
#include <string>
#include <iostream>
#include <vector>
#include <map>
#include <utility>
using namespace std;

template <class T>
   void print_one(const T &arg, ostream &os) {
      os << arg << ' ';
   }

template <class K, class V>
   void print_one(const pair<K,V> & arg, ostream &os) {
      os << arg.first << ',' << arg.second << ' ';
   }

template <template <class> class C, class T>
   void print_container(const C<T>& c, ostream &os) {
      for (const auto& v : c)
         print_one(v, os);
      os << '\n';
   }

On remarquera que par prudence, nous avons délégué l'impression de chaque élément vers une fonction générique print_one(), que nous avons spécialisé pour une paire clé, valeur dans le but, par exemple, d'être en mesure de prendre en charge les éléments d'un tableau associatif comme une std::map.

Supposons toutefois le code client suivant :

// ...
int main() {
   string s = "J'aime mon prof";
   vector<int> v{ 2,3,5,7,11 };
   map<string,int> m{ {"un", 1}, {"deux", 2}, {"trois",3} };
   print_container(s, cout);
   print_container(v, cout);
   print_container(m, cout); // <-- OUPS!
}

Le code de test ne compilera pas dû à l'appel de print_container() prenant en paramètre m, qui est une map<string,int>.

Le problème ici est qu'une map<K,V> n'a pas la signature attendue de print_container(), qui s'attend à recevoir un conteneur générique sur la base d'un seul type T. Notez que bien que vector et string soient générique sur la base de plusieurs types, les types autres que le type des valeurs peuvent être omis car ils ont une « valeur » par défaut; ce n'est pas le cas avec map, qui doit expliciter le type de la clé et le type de la valeur.

Les templates variadiques viennent à notre secours. Examinez l'écriture de print_container() ci-dessous :

#include <iostream>
#include <string>
#include <iostream>
#include <vector>
#include <map>
#include <utility>
using namespace std;

template <class T>
   void print_one(const T &arg, ostream &os) {
      os << arg << ' ';
   }

template <class K, class V>
   void print_one(const std::pair<K,V> & arg, ostream &os) {
      os << arg.first << ',' << arg.second << ' ';
   }

template <template <class, class...> class C,
          class T, class... Args>
void print_container(const C<T, Args...>& c, ostream &os) {
   for (const auto& v : c)
      print_one(v, os);
   os << '\n';
}

int main() {
   string s = "J'aime mon prof";
   vector<int> v{ 2,3,5,7,11 };
   map<string,int> m = { {"un", 1}, {"deux", 2}, {"trois",3} };
   print_container(s, cout);
   print_container(v, cout);
   print_container(m, cout); // Ok!
}

Ce petit changement, qui tient à dire que le type C est générique sur la base d'au moins un type (les autres types étant présentés de manière variadique mais n'étant jamais nommés ou utilisés!) permet au code de s'adapter aux signatures des divers conteneurs standards.

L'exécution de ce programme nous donnera :

J ' a i m e   m o n   p r o f
2 3 5 7 11
deux,2 trois,3 un,1

Pour une string, traitée comme une sorte de conteneur de char, les caractères sont affichés un à un, séparés par des espaces. Pour un vector<int>, la fonction affiche chaque élément, dans l'ordre. Enfin, pour le conteneur associatif, les éléments sont présentés dans l'ordre selon lequel ils sont placés dans le conteneur (typiquement un arbre, pour obtenir un temps d'extraction logarithmique).

Application amusante – Afficher des valeurs de différents types séparées par des délimiteurs

Ce qui suit a été suggéré par Jason Turner, un très chic type, à titre d'application des expansions variadiques. Sa fonction projette sur un flux en sortie une suite de valeurs séparées par des virgules.

Son premier jet tient à générer une séquence de string construite à partir de l'expansion variadique d'une expression en trois temps (notez les parenthèses et le recours à l'opérateur ,) :

  • Réinitialiser une std::stringstream, donc un flux représenté sur une chaîne de caractères
  • Y injecter un objet (quel qu'en soit le type, dans la mesure où il est sérialisable sous forme de texte), et
  • Extraire le texte résultant

Ces trois étapes sont appliquées (notez le recours à ... suite à la parenthèse fermante) sur tous les paramètres de la fonction, construisant au passage une initializer_list<string> qui est utilisée pour construire le vector<string> retourné par la fonction. Le résultat est :

13.59812bob

On constate que la sérialisation a été complète, sans toutefois ajouter le délimiteur souhaité (la virgule). On aurait pu le faire aisément en remplaçant ss.str() par ss.str()+"," dans le code, mais nous aurions alors eu une virgule en fin de séquence aussi (ce qui aurait été inélégant).

#include <vector>
#include <string>
#include <sstream>
#include <iostream>
template<class... T>
std::vector<std::string> to_string(T&&... t) {
   std::stringstream ss;
   return{ (ss.str(""), ss << t, ss.str())... };
}
int main() {
   for (const auto &s : to_string(1, 3.5, 98, 12.0f, "bob"))
      std::cout << s;
}

Son deuxième essai retourne aussi un vector<string>, et utilise un booléen local à la fonction pour noter le besoin ou non d'injecter une virgule. Ce booléen est initialement true, mais devient false dès qu'au moins une string a été placée dans la séquence à retourner.

Le résultat est celui attendu :

1, 3.5, 98, 12, bob
#include <vector>
#include <string>
#include <sstream>
#include <iostream>
template<class... T>
std::vector<std::string> to_string(T&&... t) {
   std::stringstream ss;
   bool noComma = true;
   return{ (ss.str(""), ss << (noComma ? "" : ", ") << t, noComma = false, ss.str())... };
}
int main() {
   for (const auto &s : to_string(1, 3.5, 98, 12.0f, "bob"))
      std::cout << s;
}

Son troisième essai crée une string plutôt qu'un vector<string>, en accumulant dans un stringstream les diverses string intermédiaires de même que les ", " intercalaires. Notez le passage par une initializer_list<int> qui ne sera pas utilisée (ce qui explique le trranstypage en void et le 0 à la toute fin), mais sert tout de même pour l'expression variadique étendue qui tient compte de tous les paramètres. C'est pervers, mais charmant.

Le résultat est encore une fois celui attendu :

1, 3.5, 98, 12, bob
#include <vector>
#include <string>
#include <sstream>
#include <iostream>
template<class... T>
std::string to_string(T&&... t) {
   std::stringstream ss;
   bool noComma = true;
   (void)std::initializer_list<int>{ (ss << (noComma ? "" : ", ") << t, noComma = false, 0)... };
   return ss.str();
}
int main() {
   std::cout << to_string(1, 3.5, 98, 12.0f, "bob");
}

Enfin, son quatrième essai passe par une initializer_list<bool> car cela permet d'éviter le 0 bidon en fin de séquence et d'utiliser l'affectation de false au booléen local comme valeur à insérer dans la séquence bidon générée.

Le résultat demeure celui attendu :

1, 3.5, 98, 12, bob
#include <vector>
#include <string>
#include <sstream>
#include <iostream>
template<class... T>
std::string to_string(T&&... t) {
   std::stringstream ss;
   bool noComma = true;
   (void)std::initializer_list<bool>{ (ss << (noComma ? "" : ", ") << t, noComma = false)... };
   return ss.str();
}
int main() {
   std::cout << to_string(1, 3.5, 98, 12.0f, "bob");
}

Voilà!

Expansions variadiques possibles

Les expansions variadiques s'appliquent à plusieurs choses, mais pas toutes. Par exemple, il n'est pas possible de réaliser l'expansion variadique d'une déclaration, tout comme il n'est pas possible de réaliser l'expansion variadique d'un énoncé (c'est simple à comprendre : une expansion variadique sépare les éléments par des virgules, alors que les énoncés sont séparés les uns des autres par des points-virgules).

L'exemple à droite montre deux formes d'expansion :

  • apply_impl() prend une séquence variadique d'indices (des entiers consécutifs) et applique get<Ns>(tup)... par expansion, appliquant l'ellipse (le ...) sur les éléments de Ns, menant à print_size(get<0>(tup)), puis print_size(get<1>(tup)) et ainsi de suite
  • std::forward<Ts>(ts)... applique l'ellipse sur ts, réalisant l'expansion std::forward<T0>(t0), std::forward<T1>(t1) et ainsi de suite sur les éléments t0, t1, t2, ... de Ts

Évidemment, d'autres formes d'expansions variadiques ont été utilisées plus haut sur cette page, par exemple pour initialiser une liste d'initialisation ou un tableau.

#include <tuple>
#include <string>
#include <iostream>
using namespace std;
template <class T>
   auto print_size(T &&) {
      cout << sizeof(T) << ' ';
   }
template <class T, class ... Ts>
   auto print_size(T &&, Ts&&... ts) {
      cout << sizeof(T) << ' ';
      print_size(std::forward<Ts>(ts)...);
   }
template <class ... Ts, size_t ... Ns>
   auto apply_impl_(tuple<Ts...> tup, index_sequence<Ns...>) {
      return print_size(get<Ns>(tup)...);
   }
template <class ... Ts>
   auto apply_(tuple<Ts...> tup) {
      return apply_impl_(tup, make_index_sequence<sizeof...(Ts)>());
   }
int main() {
   apply_(make_tuple(3, string{ "J'aime mon prof" }, 3.14159));
}

Lectures complémentaires

Quelques liens pour en savoir plus.


Valid XHTML 1.0 Transitional

CSS Valide !