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.
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ù :
|
|
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 :
|
|
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. |
|
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. |
|
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?
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.
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é.
Cette fonction a été écrite pour mettre en relief :
Remarquez la syntaxe pour les paramètres variadiques :
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.
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 :
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 comme 4 4 8.
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 et 4 16 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);
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.
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!
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.
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;
}
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() |
---|---|
|
|
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.
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.
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).
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 ,) :
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 :
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). |
|
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 :
|
|
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 :
|
|
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 :
|
|
Voilà!
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 :
É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. |
|
Quelques liens pour en savoir plus.