IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

LES TEMPLATES VARIADIQUES, UN NOUVEL OUTIL DE PROGRAMMATION GÉNÉRIQUE


précédentsommairesuivant

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).

printf sur fond de template variadique
Sélectionnez
1.
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 :

Exemples de parameter packs
Sélectionnez
1.
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 :

la signature de printf
Sélectionnez
1.
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 :

Développer une expression complexe
Sélectionnez
1.
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 :

sizeof...
Sélectionnez
1.
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.

Un tuple de paramètres
Sélectionnez
1.
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  :

un tuple comparateur
Sélectionnez
1.
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 :

l'accesseur
Sélectionnez
1.
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 :

C thread
Sélectionnez
1.
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

c++ thread
Sélectionnez
1.
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 :

Mauvais transfert
Sélectionnez
1.
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.

transfert parfait
Sélectionnez
1.
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.


précédentsommairesuivant

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2016 Stendhal. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.