Introduction au Perfect Forwarding

Ce qui suit est une introduction, sans plus, à un mécanisme conceptuellement pertinent et fort utile sur le plan de la vitesse que nous propose C++ 11. Il serait nettement préférable de comprendre la sémantique de mouvement au préalable.

C++ 11, outre la copie et le partage, offre aussi un support plein et entier de la sémantique de mouvement. Une technique reposant sur cette nouvelle sémantique, et l'exploitant à plein, est ce qu'on nomme le Perfect Forwarding : la capacité qu'a une fonction générique de faire suivre ses paramètres à une autre fonction de manière parfaite.

Règles gouvernant le Reference Collapsing

Avec l'avènement des références sur des rvalue et de la sémantique de mouvement, des règles ont été mises en place pour gérer le passage de références à des fonctions prenant en paramètre des références sur des rvalue. On dit que ces règles gèrent le Reference Collapsing (je n'ai pas trouvé de traduction élégante encore), et elles dictent ceci :

 Type original   Passé par   Type résultant 
T&
&
T&
T&
&&
T&
T&&
&
T&
T&&
&&
T&&

Il faut comprendre l'interaction de ces types pour saisir l'enjeu ici. Examinons la fonction générique f() ci-dessous :

template <class T>
   void f(T&&);

Si on passe à cette fonction un lvalue de type X (objet non-transitoire, nommé, souvent modifiable), alors T devient un X& et f() le recevra par référence. Toutefois, si f() reçoit un rvalue de type X (objet jetable), alors T deviendra X et le paramètre sera un X&& pour profiter de sa nature transitoire.

Fabrique traditionnelle et fabrique à relai parfait

Les exemples classiques du relai parfait sont des fabriques (les exemples les plus jolis utilisent des templates variadiques, mais le compilateur sur mon poste ne les supporte pas encore alors je ferai plus simple).

Voici deux fabriques de pointeurs intelligents standards implémentant une sémantique de partage. Vous remarquerez que la nuance entre les deux tient à un appel de fonction supplémentaire (appel à std::forward()) dans l'un des deux cas.

#include <memory>
template <class T, class A>
   std::shared_ptr<T> fabriquer_trad(A &&param) {
      return std::shared_ptr<T>{new T(param)};
   }
template <class T, class A>
   std::shared_ptr<T> fabriquer(A &&param) {
      return std::shared_ptr<T>{new T(std::forward<A>(param))};
   }

Le recours à shared_ptr n'est pas essentiel ici (ça fait seulement un bel exemple pratique de la chose). On appellerait l'une ou l'autre de ces deux fabriques pour instancier une version partageable d'une indirection vers un T construite en prenant un A en paramètre, et les deux fonctionnent en pratique. En effet, le programme suivant compile et s'exécute très bien :

#include <iostream>
int main() {
   using namespace std;
   auto i0 = fabriquer_trad<int>(3);
   auto i1 = fabriquer<int>(3);
   cout << *i0 << ' ' << *i1 << endl;
}

Pour voir l'effet du relai parfait, il est préférable d'avoir des classes implémentant la sémantique de copie et la sémantique de mouvement. Notez que plusieurs types sont incopiables mais déplaçables; cependant, pour les fins de l'exemple, nous utiliserons des types qui implémentent les deux sémantiques (nous voulons un résultat plus « visuel »).

La classe X représentera un type arbitrairement complexe. Pensez à un vecteur de listes d'objets 3D ou à d'autres bestioles du même acabit si cela vous amuse.

class X {
   // ...code arbitrairement complexe...
};

La classe Y recevra en paramètre un X par référence-vers-const (dans quel cas on présume qu'une copie du X se fera dans le Y) ou par référence-sur-rvalue (dans quel cas on est en droit de s'attendre à un déplacement d'états, beaucoup moins coûteux).

struct Y {
   Y(const X&) {
      cout << "Y prend un X par copie" << endl;
   }
   Y(X &&) {
      cout << "Y prend un X par deplacement" << endl;
   }
};

Examinons maintenant le programme de test proposé à droite. Dans ce programme :

  • À la création de p0, le paramètre x est passé par valeur, donc copié, car x est une variable nommée et continuera d'exister par la suite
  • Il en va de même pour la création de p2. La copie est la meilleure option dans des cas
int main() {
   X x;
   auto p0 = fabriquer_trad<Y>(x);
   auto p1 = fabriquer_trad<Y>(X{});
   auto p2 = fabriquer<Y>(x);
   auto p3 = fabriquer<Y>(X{});
   auto p4 = fabriquer<Y>(std::move(x));
}

La situation devient plus intéressante pour p1 et p3. En effet :

Enfin, p4 reçoit un X nommé mais le code client décide explicitement d'en déplacer le contenu, ce qui explique le recours à std::move(). Suite à cette instruction, x doit être tel qu'il puisse être détruit mais ne doit plus être considéré comme utilisable.

À l'exécution, on aura donc :

Y prend un X par copie
Y prend un X par copie
Y prend un X par copie
Y prend un X par deplacement
Y prend un X par deplacement
Appuyez sur une touche pour continuer...

Implémentation plus correcte de fabriquer()

La fonction fabriquer() utilisée plus haut bénéficierait grandement de saisir ses paramètres sur une base variadique, ce qui la rendrait bien plus générale. Ainsi, une écriture plus adéquate (et qui subsumerait celle proposée plus haut) serait :

#include <memory>
using std::shared_ptr;
template <class T, class ... A>
   shared_ptr<T> fabriquer(A && ... params) 
      return shared_ptr<T>{new T(std::forward<A>(params)...)};
   }

Notez le recours aux parenthèses plutôt qu'aux accolades dans l'appel au constructeur de l'objet fabriqué. Ceci tient au fait qu'avec des accolades, si tous les paramètres étaient du même type (ce qui est possible), alors le compilateur construirait une initializer_list ce qui n'est pas l'objectif visé ici.

Fonctions std::move() et std::forward()

Les fonctions standards std::move() et std::forward() sont à toutes fins pratiques des conversions explicites de types. Leurs rôles respectifs sont très similaires, soit prendre un paramètre et en faire une référence-sur-rvalue (dans le cas de std::move()) ou préserver les qualifications d'origine (dans le cas de std::forward()).

Le code de std::forward() suit.

template<class S>
   S&& forward(typename remove_reference<S>::type &a) noexcept {
      return static_cast<S&&>(a);
   }
template <class X>
   typename remove_reference<X&>::type&& move(X& && a) noexcept {
      using RvalRef = typename remove_reference<X&>::type&&;
      return static_cast<RvalRef>(a);
   }

En gros, ce sont des fonctions « convertissant » un T, T& ou T&& en T&&. On préférera std::move() dans la majorité des cas et std::forward() dans des cas pointus de relais de paramètres à des types génériques.

Lectures complémentaires


Valid XHTML 1.0 Transitional

CSS Valide !