II. Qu'est-ce qu'un template variadique ?▲
II-A. Une généralisation de la programmation générique en C++▲
Un template variadique permet de paramétrer par un nombre indéterminé de types une classe, une fonction ou une directive (par exemple, la directive using).
2.
// notre fonction printf prend une chaîne de caractères et un nombre indéterminé d'arguments (0 - +∞)
template
<
typename
... T>
void
printf(const
char
*
format, T... args) ;
Contrairement aux fonctions et aux macros variadiques du C, dont la fonction standard printf est l'exemple le plus célèbre, le nombre et le type des arguments d'un template variadique peuvent être vérifiés. C'est un des objectifs de leur introduction : améliorer la sécurité du code par rapport à ces anciennes fonctionnalités. Mais les templates variadiques ont d'autres supériorités sur les constructions variadiques du C :
- l'argument précédant l'ellipse dans une fonction C est nécessairement un type POD (pour plain old data, plus de précisions ici), ce qui exclut l'utilisation d'une std::string, pourtant bien pratique pour des fonctions de log, par exemple ;
- l'ellipse d'une construction variadique du C doit nécessairement être précédée d'un argument de type déterminé : elle ne permet donc pas des constructions aussi génériques que le template variadique, qui représente aussi bien aucun type qu'une infinité de types (dans la limite des capacités de traitement du compilateur).
Les types recouverts par le template variadique forment un parameter pack. Un parameter pack est introduit par l'opérateur ellipse (…). En voici quelques exemples :
2.
3.
template
<
typename
... Types>
struct
Some_types {}
; // Types est un parameter pack
Some_types<
char
, int
, long
, long
long
>
entiers ; // il contient ici char, int, long, long long
template
<
int
... ints>
struct
Ints ; // un parameter pack peut aussi contenir des constantes
Dans les paramètres templates d'une déclaration de classe, il ne peut y avoir qu'un parameter pack et il doit être le dernier des paramètres templates.
Cette restriction ne vaut pas pour les spécialisations de templates ni pour les fonctions.
Naturellement, le pack peut être développé pour retrouver les types qu'il contient. Cette étape s'appelle pack expansion. Cette opération est également réalisée grâce à l'opérateur ellipse, placé cette fois après le parameter pack. Regardez à nouveau la signature de printf pour voir un premier exemple :
2.
template
<
typename
... T>
void
printf(const
char
*
format, T... args) ;
L'opérateur ellipse développe l'argument T en t1, t2, … tn. Il n'est pas nécessairement placé directement après le parameter pack : celui-ci peut être compris dans une expression, de complexité arbitraire, qui sera développée en même temps que lui. Voici un exemple -pas très utile- avec une structure qui affiche les valeurs par défaut des types qui la paramètrent :
2.
3.
4.
5.
6.
template
<
typename
... Types>
struct
Default_values {
void
affiche() {
auto
n =
{
(std::
cout <<
Types{}
<<
' '
, 1
)... }
;
}
}
;
Nous reviendrons sur cette technique un peu étonnante plus tard, je voulais juste montrer qu'une expression compliquée pouvait être développée lors de la pack expansion.
L'ellipse se retrouve encore dans l'opérateur sizeof... qui permet de connaître le nombre de types contenus dans le parameter pack. Par exemple :
2.
template
<
typename
... Types>
struct
Arg_counter {
static
constexpr
std::
size_t value =
sizeof
...(Types) ; }
;
Regardons maintenant un peu quel parti les auteurs de la bibliothèque standard en ont tiré.
II-B. L'utilisation des templates variadiques dans la bibliothèque standard▲
template
<
typename
... T>
std::
tuple: les tuples sont des généralisations de la paire. Au lieu d'être limités à deux éléments, ils peuvent en contenir un nombre arbitraire.
std::
tuple<
std::
size_t, std::
size_t, float
, float
>
algo_params ;
Les tuples sont dotés d'un opérateur de comparaison lexicographique : ses éléments sont comparés dans l'ordre, comme lorsque l'on compare deux chaînes de caractères. Ils sont fréquemment utilisés pour en bénéficier. La fonction std::tie crée un tuple de références à ses arguments :
2.
3.
4.
5.
6.
struct
Date {
int
day, month, year ;
bool
operator
<
(const
Date&
o) {
return
std::
tie(year, month, day) <
std::
tie(o.year, o.month, o.day) ;
}
}
;
On accède aux membres du tuple grâce à la fonction get :
std::
get<
0
>
(mon_tuple) ; // renvoie le premier élément
template
<
class
ReturnTypes, class
... Args>
std::
function: ce foncteur générique universel permet d'envelopper n'importe quelle fonction, qu'elle appartienne ou non à une classe, foncteur ou lambda. Il peut être très utile pour conserver des fonctions « call-back » dans une classe qui les appellera plus tard, par exemple.
Une observation : il n'existe pas de fonction std::make_function qui déduise le type d'une fonction donnée en argument pour créer un foncteur universel, parce que des ambiguïtés sont possibles dès lors que la fonction est surchargée. Si le std::thread peut déduire le type de la fonction, c'est qu'il dispose aussi du type de ses arguments.
template
<
class
Fn, class
... Args>
thread(Fn&&
, Args&&
...): le constructeur de thread prend en paramètre une fonction Fn (sous la forme d'un pointeur sur fonction, d'un pointeur sur une fonction membre, ou de tout foncteur ou fonction lambda) et ses arguments Args... pour créer un nouveau thread.
Si l'on compare avec l'implémentation des threads en C, par exemple avec pthread, on voit l'avantage : il n'est plus besoin d'effacer les types ou le nombre d'arguments de la fonction lancée dans le thread, derrière une signature comme (void
*
)(*
threaded_function)(void
*
argument_structure). Comparez :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
typedef
struct
Result {
/* le résultat de la fonction */
}
Result ;
Result*
my_function
(
int
, double
, char
) ;
// qu'il faut traduire en :
typedef
struct
Arguments {
int
i, double
d, char
c }
;
Arguments args =
{
1
, 2
.5
, '
f
'
}
;
void
*
thread_thread_signature
(
void
*
idc) {
Arguments*
args =
idc ;
return
my_function
(
args->
i, args->
d, args->
c) ;
}
// enfin !
int
t =
pthread_create
(
… , &
thread_signature, &
args) ;
// et il faudra caster la valeur de retour
et
std::
thread t(&
my_function, 1
, 2.5
, 'f'
) ;
Le gain en sécurité se double d'un gain en performance, qui vient de ce qu'il n'est plus nécessaire de « traduire » les arguments ou le type de retour.
Les plus attentifs d'entre vous auront remarqué la signature étrange du constructeur de thread : template
<
class
Fn, class
... Args>
thread(Fn&&
, Args&&
...). Cet idiome -des arguments template sous forme de rvalue reference- permet ce qui est appelé perfect forwarding, ou en français, transfert parfait. C'est une des possibilités les plus intéressantes ouvertes par les templates variadiques.
II-C. Le transfert parfait, ou perfect forwarding▲
Le transfert parfait ne modifie pas le type des arguments transférés. Si on reçoit une lvalue, on retourne une lvalue, pareil pour une rvalue (si vous voulez rafraîchir votre mémoire sur le sujet des lvalues et rvalue, vous pouvez regarder ici). Ce qui pouvait sembler un détail dans les versions précédentes du standard est devenu capital désormais, car les opérations portant sur une rvalue peuvent être bien plus rapides que leur équivalent lvalue : c'est tout le sujet de la move semantic qui est un des apports majeurs des derniers standards.
Un argument template comme T&& ressemble à une rvalue, mais ne l'est pas vraiment ; c'est plutôt une opération de type : "au type initial représenté par T on ajoute &&". Par exemple, si le type initial est Ma_classe&, on obtient Ma_classe&&&. Ce qui n'existe pas. Les règles du C++ exigent que les esperluettes au-delà de deux soient contractées selon les règles suivantes :
- &&& => &
- &&&& => &&
Donc Ma_classe& && équivaut à MaClasse&, soit une lvalue reference. De même, Ma_classe&& && donne Ma_classe&&, soit une rvalue reference. C'est pour cette raison que l'on appelle les références templates de type T&& des références universelles. Elles constituent la première pierre du transfert parfait.
Cela ne suffit pas toujours :
2.
3.
4.
5.
6.
template
<
typename
Fn, typename
... Args>
thread(Fn&&
fn, Args&&
... args) {
// …
fn(args...) // args a un nom, une adresse → c'est une lvalue !
// …
}
Pour faire les choses bien, il faut faire dans le corps de la fonction ce que la référence universelle a fait aux arguments. C'est le rôle de la fonction standard std::forward, qui a exactement le même effet.
2.
3.
4.
5.
6.
template
<
typename
Fn, typename
... Args>
thread(Fn&&
fn, Args&&
... args) {
// …
fn(std::
forward<
Args>
(args)...) // args garde son type originel
// …
}
Cette technique a un emploi qu'il faut connaître dans la bibliothèque standard. Les conteneurs ont désormais, à côté de la fonction push_back ou insert, une fonction emplace_back ou emplace qui utilise le perfect forwarding.On passe à emplace_back les arguments pour un des constructeurs du type contenu, et il crée l'objet directement dans la mémoire allouée. Il n'est donc plus nécessaire de créer une copie de l'objet pour l'insérer dans le conteneur, ni même de le transférer (pour le cas où la move semantic est prévue). S'il avait fallu copier les arguments du constructeur, le gain aurait été faible ; mais grâce à la combinaison entre les templates variadiques, qui permettent de cibler n'importe quel constructeur et les références universelles, qui permettent de conserver le type exact des arguments, une construction directe dans la mémoire allouée est possible.