C++0x : Les rvalue references et le perfect forwadingDate de publication : 31 mai 2009
Par
Arzar
En plus d'introduire la sémantique de déplacement, les rvalue reference du C++0x permettent de résoudre un autre problème
sans rapport direct, le problème de la "transmission". Le problème de la "transmission" consisite à passer les
paramètres d'une fonction générique à une autre, sans perdre aucun paramètre et en conservant le maximum d'information sur ces
paramètres. En C++98, le problème peut se résoudre approximativement en écrivant de nombreuses surcharges
et en négligeant certains cas. Nous verrons que le C++0x permet d'obtenir le même résultat sans aucune surcharge, pour tous les cas.
I. Introduction
I-A. Pré-requis
I-B. Le problème de la transmission
II. En C++98
II-A. Par valeur
II-B. Par référence constante
II-C. Par référence
II-D. Un pis-aller : la surcharge
III. En C++0x
III-A. Les rvalue references à la rescousse
III-B. Un raffinement : les templates variadiques
IV. Exemples d'utilisation de la transmission parfaite
IV-A. make_shared
IV-A-1. Deux défauts de shared_ptr
IV-A-2. Solution : make_shared()
IV-B. emplace
IV-B-1. La STL et les copies superflues
IV-B-2. Solution : la construction sur place avec emplace()
I. Introduction
I-A. Pré-requis
Les rvalues references : voir l'article : rvalue reference et sémantique de déplacement en C++0x
Programmation générique avec des templates : voir ...
Les pointeurs intelligents : http://loic-joly.developpez.com/tutoriels/cpp/smart-pointers/
La STL : voir ...
I-B. Le problème de la transmission
Imaginez une fonction fonction template générique, nommée exterieur(), dont le but est de prendre un certain nombre de paramètre en argument
et de les passer (les "transmettre") à une fonction nommée intérieur().
|
template < typename ... Arguments>
void exterieur (Arguments... arguments)
{
interieur (arguments...);
}
|
L'idéal à atteindre est le suivant : la transmission parfaite.
La fonction exterieur() doit transmettre ses paramètres de la manière la plus transparente et la plus fidèle
possible à interieur(). Par exemple, tout usage d'interieur() qui compile avec un certain jeu de paramètre doit aussi compiler
en passant les mêmes paramètres à exterieur(). De même, la qualification des paramètres (const, volatile...) ainsi que la manière dont on
passe les paramètres (par valeur, par référence...) doit être préservée.
Cette article se propose d'étudier plus en détails le problème de
la transmission, d'abord en C++98, puis détaillera l'apport du C++0x dans ce domaine.
Il est décomposé en quatre parties :
Chapitre I : présente introduction.
Chapitre II : Décrit les limitations que l'on rencontre en essayant de résoudre le problème de la transmission en C++98.
Chapitre III : Décrit la solution apportée par le C++0x, solution qui utilise les rvalue references.
Chapitre IV : Décrit en détails trois utilisation de transmission parfaite en C++0x.
II. En C++98
Pour fixer les choses, limitons-nous provisoirement à deux arguments.
La question est la suivante :
|
template < typename A, typename B>
void exterieur (___ a, ___ b)
{
interieur (a, b);
}
|
Dans ce code, que faut-il écrire à la place des "___" pour obtenir la meilleure transmission possible ?
Nous allons examiner successivement les trois possibilités disponibles en C++98 - par valeur, par référence et par référence constante, et
montrer qu'à chaque fois la transmission obtenue ne couvre pas tous les cas.
II-A. Par valeur
|
template < typename A, typename B>
void exterieur (A a, B b)
{
interieur (a, b);
}
|
Supposons que la signature d'interieur() soit interieur(std::istream& a, int b).
Dans ce cas, interieur() prend une référence sur un flux istream existant. Cette utilisation est parfaitement légitime et compile. En revanche
exterieur() ne peut pas passer par valeur le flux istream, car les flux ne sont pas copiables.
La compilation échoue. Le transmission par valeur ne couvre donc pas tous les cas.
II-B. Par référence constante
|
template < typename A, typename B>
void exterieur (const A& a, const B& b)
{
interieur (a, b);
}
|
Supposons que la signature d'interieur soit interieur(A&, B&).
La constance est alors violée. En effet, la fonction exterieur()
en prenant des références constantes s'engage à ne pas modifier a et b, mais elle brise immédiatement
cette promesse en appellant la fonction interieur() qui, elle, peut modifier a et b.
La compilation échoue. La transmission par référence constante ne couvre donc pas tous les cas.
II-C. Par référence
|
template < typename A, typename B>
void exterieur (A& a, B& b)
{
interieur (a, b);
}
|
Supposons que la signature s'interieur() soit interieur(std::string& a, int b).
Il est dans ce cas parfaitement possible d'écrire ce code :
|
std:: string s = " foo " ;
interieur (s, 1984 );
|
Et pourtant
|
std:: string s = " foo " ;
exterieur (s, 1984 );
|
ne compile pas !
Le problème vient du fait qu'exterieur() prend son deuxième argument par référence et qu'on tente de lui passer un int temporaire.
Or il n'est pas possible de prendre un temporaire par référence. Cela n'aurait en effet pas beaucoup de sens
de vouloir modifier un pur nombre, comme "1984", qui n'est pas une variable possédant un nom, comme s,
et qui s'évapore à la fin de l'expression (au point virgule).
La compilation échoue. La transmission par référence ne couvre donc pas tous les cas.
II-D. Un pis-aller : la surcharge
Les trois tentatives précédentes montrent que le problème de la transmission ne peut pas être résolue de manière simple en C++98.
Il existe cependant une solution qui vaut ce qu'elle vaut : la surcharge. Il est en effet possible de créer un jeu de surcharge de la
fonction exterieur() comprenant toutes les combinaisons possible de référence et de référence constantes. Les règles de résolution de surcharge
entrent alors en jeu et le compilateur séléctionne automatiquement la bonne surcharge en fonction des paramètres requis par interieur().
Un tel jeu de surcharge permet effectivement de couvrir toutes les possibilitées et résout le problème de la transmission. Malheureusement,
le nombre de surcharge à écrire double avec le nombre d'arguments... En pratique, cette explosion du
nombre de surcharge devient vite rédhibitoire à mettre en oeuvre.
|
template < typename A> void exterieur (A& );
template < typename A> void exterieur (const A& );
template < typename A, typename B> void exterieur (A& , B& );
template < typename A, typename B> void exterieur (const A& , B& );
template < typename A, typename B> void exterieur (A& , const B& );
template < typename A, typename B> void exterieur (const A& , const B& );
template < typename A, typename B, typename C> void exterieur (A& , B& , C& );
template < typename A, typename B, typename C> void exterieur (A& , const B& , C& );
template < typename A, typename B, typename C> void exterieur (A& , B& , const C& );
template < typename A, typename B, typename C> void exterieur (A& , const B& , const C& );
template < typename A, typename B, typename C> void exterieur (const A& , B& , C& );
template < typename A, typename B, typename C> void exterieur (const A& , const B& , C& );
template < typename A, typename B, typename C> void exterieur (const A& , B& , const C& );
template < typename A, typename B, typename C> void exterieur (const A& , const B& , const C& );
|
III. En C++0x
III-A. Les rvalue references à la rescousse
C'est ici qu'interviennent les rvalue references. Il faut d'ailleurs noter qu'il est assez remarquable qu'une feature comme les rvalue reference,
introduite principalement pour autoriser la sémantique de déplacement ait trouvé une utilisation dans un contexte complètement différent.
Sans plus de cérémonie, voici la solution du C++0x au problème de la transmission :
|
# include <utility> // pour bénéficier de std::forward
template < typename A, typename B>
void exterieur (A& & a, B& & b)
{
interieur (std:: forward< A> (a), std:: forward< B> (b));
}
|
La transmission parfaite se résume à ce bout de code ! exterieur() doit prendre en argument des rvalue reference et les transmettre à interieur()
par le biais d'une nouvelle fonction template de la bibliothèque standart, nommée std::forward(), disponible dans le header <utility>.
Plus precisement, la transmission parfaite fait intervenir :
1) Des règles spéciales lors de la déduction des templates. Ces règles spéciales, nouvelles et conçues pour la transmission parfaite,
s'activent uniquement en présence de rvalue reference.
2) std::forward. Cette nouvelle fonction de la bibliothèque standard, elle aussi concue spécialement pour la transmission parfaite, permet de
"reconstruire" les qualifications d'un paramètre (par exemple const) à partir d'une rvalue reference.
Les détails des règles spéciales de déduction des templates et du fonctionnement de std::forward sont difficiles. La diversité des cas à prendre
en compte est un vrai challenge, comme nous l'a montré le chapitre II. Nous ne les aborderont donc pas ici. La bonne nouvelle,
c'est que la compréhension de ces détails n'est absolument pas requis pour utiliser la transmission parfaite. En fait, son utilisation
est même particulièrement simple ! Par exemple :
|
# include <utility> // pour bénéficier de std::forward
template < typename A, typename B>
void exterieur (A& & a, B& & b)
{
interieur (std:: forward< A> (a), std:: forward< B> (b));
}
void interieur (std:: string& s, int i)
{
}
int main ()
{
std:: string s = " foo " ;
exterieur (s, 1984 );
}
|
Un petit bémol : il faut garder en tête que la transmission parfaite n'est atteinte que si exterieur() est une fonction template, car les
règles spéciales de déduction des template sont obligatoires. Une fonction exterieur()
non template échoue dans certains cas, comme en C++98.
|
exterieur (string& & s, int & & i)
{
interieur (std:: forward< std:: string> (s), std:: forward< int > (i));
}
void interieur (std:: string& s, int i)
{
}
int main ()
{
std:: string s = " foo " ;
exterieur (s, 1984 );
}
|
III-B. Un raffinement : les templates variadiques
Une autre nouveauté du C++0x permet de porter le coup de grâce au problème de la transmission : Il s'agit des template variadiques.
Les fonctions template qui utilisent les templates variadiques sont capables de recevoir un nombre quelconque de paramètre en argument.
La syntaxe des templates variadiques, en "...", est d'ailleurs similaire à celle des fonctions variadiques héritées du C (comme printf).
Les rvalue reference et les templates variadiques forment un mariage particulièrement heureux : La transmission
des paramètres de exterieur() vers interieur() se résume alors à une seule fonction qui marche dans tous les cas !
|
# include <utility>
template < typename ... Args>
void exterieur (Args& & ... args)
{
interieur (std:: forward< Args...> (args));
}
struct MyStruct
{
MyStruct () = default
MyStruct (int i){ }
}
void interieur (int i){ }
void interieur (int i, float f){ }
void interieur (int i, float f, double d){ }
void interieur (int i, float f, double d, std:: string s){ }
void interieur (int i, float f, double d, std:: string s, MyClass mc){ }
int main ()
{
exterieur (1 );
exterieur (1 , 2 .0f );
exterieur (1 , 2 .0f , 3 .0 );
exterieur (1 , 2 .0f , 3 .0 , " 4 " );
exterieur (1 , 2 .0f , 3 .0 , " 4 " , MyClass (5 ));
}
|
Le résultat est impressionant. L'unique fonction exterieur() est parfaitement générique et uniforme. Quelles que soient le nombre
de paramètre, quels que soient le type des paramètres, quelle que soient la manière de passer ces paramètres
(par valeur, par référence...) et quelle que soient la qualification de ces paramètres (const, volatile...), exterieur() transmet fidèlement
à interieur() sans aucune perte d'information.
IV. Exemples d'utilisation de la transmission parfaite
L'élaboration de la technique de la transmission parfaite est récente (2007) mais il est d'ores et déjà prévu
de l'utiliser ponctuellement en C++0x pour certaines fonctions de la librairie standard. Nous proposons de détailler dans cette article make_shared() et
emplace() deux fonctions qui seront dans la bibliothèque standard du C++0x ainsi qu'un exemple du design pattern Factory tirant partit de la transmission
parfaite.
IV-A. make_shared
Le C++0x propose une classe de pointeur intelligent particulièrement utile : shared_ptr.
shared_ptr détient un pointeur sur une ressource ainsi
qu'un compteur de référence. Ce compteur s'incrémente lors d'une copie du shared_ptr et se décrémente lorsqu'un des possesseurs
du shared_ptr est détruit. Si le compteur atteint zéro, alors plus personne ne possède le shared_ptr et
la ressource est automatiquement libérée. La gestion de la mémoire est considérablement simplifiée par shared_ptr, qui, de fait, est
une des nouveauté les plus attendus du C++0x.
IV-A-1. Deux défauts de shared_ptr
Cependant, shared_ptr est perfectible. En particulier l'utilisation basique d'un shared_ptr possède deux petits défauts
qui apparaissent lors de son initialisation.
Un exemple simple suffit pour les mettre en lumière :
|
# include <memory> // pour bénéficier de shared_ptr
class MaClasse
{
MaClasse () = default ;
MaClasse (std:: string s1, int & i, const std:: string& s2){ }
} ;
int main ()
{
std:: string s = " foo " ;
int i = 3 ;
std:: shared_ptr< MaClasse> sp = std:: shared_ptr< MaClasse> ( new MaClasse (s, i, " bar " ));
}
|
1) La syntaxe est inutilement verbeuse. Le type de l'objet (MaClasse) est répété deux fois.
2) Le shared_ptr lance deux allocations, une pour l'objet MaClasse (le new MaClasse(s, i "bar")) et une pour le compteur de référence
(un autre new, dans le constructeur de shared_ptr). C'est parce que shared_ptr est non-intrusif, c'est à dire que ce n'est pas l'objet
lui-même qui gère la logique du comptage de référence mais un compteur externe. Si l'on utilise le constructeur de shared_ptr comme dans l'exemple,
le compteur est instancié dans un bloc à part, ailleurs en mémoire et doit posséder un pointeur sur l'objet MaClasse.
Ce n'est clairement pas optimal en terme de performance. Il serait préferable de n'avoir qu'une seule allocation regroupant l'objet et
son compteur dans le même bloc.
Est-ce que nous tombons dans le piège de l'optimisation prématurée ? Ici, définitivement non. shared_ptr est destiné à être utilisé par le
plus grand nombre possible de programmeur C++, dans une myriade de situations différentes. shared_ptr se doit donc
d'exhiber des perfomances maximales s'il veut convaincre.
IV-A-2. Solution : make_shared()
C'est finalement une nouvelle fonction de la bibliothèque standard, nommée make_shared(), disponible dans le header <memory>, qui
vient corriger ces deux petits défauts.
Voici un exemple d'utilisation de make_shared :
|
# include <memory> // pour bénéficier de shared_ptr et de make_shared
class MaClasse
{
MaClasse () = default ;
MaClasse (std:: string s1, int & i, const std:: string& s2){ }
} ;
int main ()
{
std:: string s = " foo " ;
int i = 3 ;
std:: shared_ptr< MaClasse> sp = std:: make_shared< MaClasse> (s, i, " bar " );
}
|
La syntaxe est plus courte et plus claire car le type MaClasse n'est pas répété. Plus intéressant, l'utilisation de make_shared
ressemble à s'y méprendre aux exemples de transmission parfaite que nous avons étudié dans le chapitre II. Un regard sur le prototype fini
par nous convaincre :
|
template < class T, class ... Args>
shared_ptr< T> make_shared (Args& & ... args);
|
Nous avons effectivement affaire à de la transmission parfaite. Elle permet en fait ici de délayer l'allocation de l'objet MaClasse
dans la fonction make_shared(). Sans que l'utilisateur ne s'en rende compte, make_shared()
va pouvoir allouer l'objet et le compteur à sa sauce, en une seule fois, et en les collant l'un à l'autre pour obteir des performances maximales.
En résumé, make_shared exhibe une utilisation brillante de la transmission parfaite. Plus claire, plus performante, l'utilisation de
make_shared est aussi plus sûre, car elle est exception-safe dans certains cas (non détaillés ici) où
l'utilsation basique du constructeur de shared_ptr ne l'est pas.
 |
Conseil : Utilisez make_shared() dès que possible pour initialiser vos shared_ptr en C++0x.
|
IV-B. emplace
IV-B-1. La STL et les copies superflues
L'utilisation de la copie est un principe fondateur qui serpente dans toute la STL. Cette approche possède de très nombreux avantages (non détaillé ici),
mais parfois certaines copies semblent un peu superflue. Par exemple :
|
# include <vector>
class MaClasse
{
MaClasse () { std:: cout < < " Constructeur " < < std:: endl; }
MaClasse (const MaClasse& ) { std:: cout < < " Constructeur par copie " < < std:: endl; }
} ;
std:: vector< MaClasse> vec;
vec.push_back (MaClasse ());
|
Le code ci-dessus affiche :
1) Constructeur
2) Constructeur par copie
Pourtant l'intention du programmeur était d'ajouter directement une instance de MaClasse au conteneur. La création
d'une instance temporaire de MaClasse n'est donc pas vraiment justifiée.
En C++0x, on peut faire mieux grace à la sémantique de déplacement :
|
class MaClasse
{
MaClasse () { std:: cout < < " Constructeur " < < std:: endl; }
MaClasse (const MaClasse& ) { std:: cout < < " Constructeur par copie " < < std:: endl; }
MaClasse (MaClasse & & ) { std:: cout < < " Constructeur par déplacement " < < std:: endl; }
} ;
(
std:: vector< MaClasse> vec;
vec.push_back (MaClasse ());
|
Le code ci-dessus affiche :
1) Constructeur
2) Constructeur par déplacement
Si MaClasse possède des membres adaptés au déplacement, comme des std::string ou des unique_ptr, alors le constructeur
par déplacement peut être redoutablement efficace. Par exemple, si MaClasse possède une std::string comme membre
alors l'opération de déplacement se résume à :
1) Création de l'objet MaClasse temporaire contenant la string. (couteux)
2) Le constructeur par déplacement de l'objet MaClasse d'arrivée (dans le vecteur) crée une string vide (peu couteux)
3) Le pointeur sur la chaine de caractère de la string vide est echangé avec le pointeur sur la chaine de caractère de la string de l'objet MaClasse temporaire. (peu couteux)
4) L'objet MaClasse temporaire, qui possède donc maintenant une string vide, est détruit (peu couteux).
Au final, la seule opération véritablement couteuse est la création de l'instance temporaire. Le déplacement, lui, est efficace.
Mais remplaçons maintenant MaClasse par cette classe :
|
struct Vecteur3D
{
float x;
float y;
float z;
} ;
|
Dans ce cas, le constructeur par déplacement n'est d'aucun secours, car les types natifs, comme les float; ne peuvent pas être déplacés,
seulement copiés. De même les classes ne possédant pas de constructeur par déplacement se rabattent sur leur contructeur par copie. La sémantique
de déplacement ne pallie donc pas tous les cas.
Il manque en fait une notion fondamentale au conteneur de la STL : la construction directe, "sur-place",
d'un objet dans un conteneur, sans aucune copie.
IV-B-2. Solution : la construction sur place avec emplace()
C'est une nouvelle famille de fonction qui règle le problème : la famille des "emplace"
Par exemple, emplace_back(), le pendant de push_back() permet de construire sur place un nouvel objet en queue de conteneur.
|
# include <vector>
struct Vecteur3D
{
Vecteur3D (float x, float y, float z): x (x), y (y), z (z){ }
float x, y, z;
} ;
std:: vector< Vecteur3D> base;
base.emplace_back (1 .0f , 0 .0f , 0 .0f );
base.emplace_back (0 .0f , 1 .0f , 0 .0f ):
base.emplace_back (0 .0f , 0 .0f , 1 .0f ):
|
Comme pour make_shared, l'utilisation d'emplace_back() fait imanquablement penser à de la transmission parfaite. Le prototype confirme :
| template < typename ... Args> void emplace_back (Args& & ... args);
|
Ici encore, la transmission parfaite permet de délayer la création du nouvel objet, ce qui permet de le construire directement, "en place", dans
le vecteur.
En C++0x la famille des emplace() est disponible pour tous les conteneurs de la STL. Grosso modo, chaque insert(), push_back() et push_front()
possède maintenant son équivalent en terme d'emplace(). Par exemple, pour une map, emplace() s'utilise comme ceci :
|
# include <map>
# include <string>
struct Vecteur3D
{
Vecteur3D (float x, float y, float z): x (x), y (y), z (z){ }
float x, y, z;
} ;
std:: map< std:: string, Vecteur3D> base;
base.insert (std:: make_pair (" X " , Vecteur3D (1 .0f , 0 .0f , 0 .0f )));
base.insert (std:: make_pair (" Y " , Vecteur3D (0 .0f , 1 .0f , 0 .0f )));
base.insert (std:: make_pair (" Z " , Vecteur3D (0 .0f , 0 .0f , 1 .0f )));
base[" X " ] = Vecteur3D (1 .0f , 0 .0f , 0 .0f );
base[" Y " ] = Vecteur3D (0 .0f , 1 .0f , 0 .0f );
base[" X " ] = Vecteur3D (0 .0f , 0 .0f , 1 .0f );
base.emplace (" X " , 1 .0f , 0 .0f , 0 .0f );
base.emplace (" Y " , 0 .0f , 1 .0f , 0 .0f ):
base.emplace (" Z " , 0 .0f , 0 .0f , 1 .0f ):
|
Dans le cas d'une std::map, l'avantage d'emplace() est très net. En effet, une std::map manipule des std::pair, rajoutant une
couche suplémentaire entre la map et l'objet, ce qui a tendance à créer des copies superflues.
emplace() permet de couper court à ces copies en "percant" toutes les couches intérmédiaires pour
construire directement sur place, dans la map.
L'exemple qui suit permet de prendre la mesure du nombre de copie effectuées en sous-main lors d'une utilisation basique d'une map en C++98.
|
struct Vecteur3D
{
Vecteur3D (): x (0 .0f ), y (0 .0f ), z (0 .0f ){ std:: cout < < " Constructeur() " < < std:: endl;}
Vecteur3D (float x, float y, float z): x (x), y (y), z (z){ std:: cout < < " Constructeur(float, float, float) " < < std:: endl;}
Vecteur3D (const Vecteur3D& ){ std:: cout < < " Constructeur par copie " < < std:: endl;}
Vecteur3D& operator = (const Vecteur3D& ){ std:: cout < < " Opérateur = " < < std:: endl; return * this ;}
float x, y, z;
} ;
std:: map< std:: string, Vecteur3D> base;
std:: cout < < " insert, clé non présente " < < std:: endl;
base.insert (std:: make_pair (" X " , Vecteur3D (1 .0f , 0 .0f , 0 .0f )));
std:: cout < < " insert, clé présente " < < std:: endl;
base.insert (std:: make_pair (" X " , Vecteur3D (0 .0f , 1 .0f , 0 .0f )));
std:: cout < < std:: endl;
std:: cout < < " op[], clé non présente " < < std:: endl;
base[" Y " ] = Vecteur3D (0 .0f , 0 .0f , 1 .0f );
std:: cout < < " op[], clé présente " < < std:: endl;
base[" Y " ] = Vecteur3D (1 .0f , 1 .0f , 0 .0f );
std:: cout < < std:: endl;
std:: cout < < " emplace, clé non présente " < < std:: endl;
std:: cout < < " emplace, clé présente " < < std:: endl;
|
On obtient, en release avec Visual Studio 2010 Beta :
insert |
operateur[] |
emplace (prévision) |
clé non présente |
clé non présente |
clé non présente |
Constructeur(float, float, float)
Constructeur par copie
Constructeur par copie
|
Constructeur(float, float, float)
Constructeur()
Constructeur par copie
Constructeur par copie
Opérateur =
|
Constructeur(float, float, float) |
clé présente |
clé présente |
clé présente |
Constructeur(float, float, float)
Constructeur par copie
Constructeur par copie
|
Constructeur(float, float, float)
Opérateur =
|
Constructeur(float, float, float) |
Les sources présentés sur cette page sont libres de droits,
et vous pouvez les utiliser à votre convenance. Par contre cette page de présentation de ces sources constitue une oeuvre intellectuelle protégée par les droits d'auteurs.
Copyright ©2009
Developpez LLC. Tous droits réservés Developpez LLC.
Aucune reproduction, même partielle, ne peut être faite de ce site et de
l'ensemble de son contenu : textes, documents et images sans l'autorisation
expresse de Developpez LLC. Sinon vous encourez selon la loi jusqu'à 3 ans
de prison et jusqu'à 300 000 E de dommages et intérêts.
Cette page est déposée à la SACD.
|