C++ a toujours produit des programmes rapides. Malheureusement, jusqu’à C++11, il y avait une verrue obstinée qui ralentissait de nombreux programmes C++ : la création d’objets temporaires. Parfois, ces objets temporaires peuvent être optimisés par le compilateur (l’optimisation des valeurs de retour, par exemple). Mais ce n’est pas toujours le cas, et cela peut entraîner des copies d’objets coûteuses. Qu’est-ce que je veux dire ?

Disons que vous avez le code suivant:

#include <iostream>using namespace std;vector<int> doubleValues (const vector<int>& v){ vector<int> new_values; new_values.reserve(v.size()); for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr ) { new_values.push_back( 2 * *itr ); } return new_values;}int main(){ vector<int> v; for ( int i = 0; i < 100; i++ ) { v.push_back( i ); } v = doubleValues( v );}

Si vous avez fait beaucoup de travail de haute performance en C++, désolé pour la douleur que cela a entraîné. Si ce n’est pas le cas, voyons pourquoi ce code est un terrible code C++03. (Le reste de ce tutoriel sera consacré à la raison pour laquelle c’est un bon code C++11.) Le problème vient des copies. Lorsque doubleValues est appelé, il construit un vecteur, new_values, et le remplit. Ce n’est peut-être pas la performance idéale, mais si nous voulons garder notre vecteur original intact, nous avons besoin d’une seconde copie. Mais que se passe-t-il lorsque nous frappons l’instruction return ?

Le contenu entier de new_values doit être copié ! En principe, il pourrait y avoir jusqu’à deux copies ici : une dans un objet temporaire à retourner, et une seconde lorsque l’opérateur d’affectation de vecteur s’exécute sur la ligne v = doubleValues( v ) ;. La première copie peut être optimisée loin par le compilateur automatiquement, mais il n’y a pas d’éviter que l’affectation à v devra copier toutes les valeurs à nouveau,ce qui nécessite une nouvelle allocation de mémoire et une autre itération sur le vecteur entier.

Cet exemple pourrait être un peu contourné – et bien sûr vous pouvez trouver des moyens d’éviter ce genre de problème – par exemple, en stockant et retournant le vecteur par pointeur, ou en passant dans un vecteur à remplir. Le fait est qu’aucun de ces styles de programmation n’est particulièrement naturel. De plus, une approche qui nécessite de retourner un pointeur a introduit au moins une allocation de mémoire supplémentaire, et l’un des objectifs de conception du C++ est d’éviter les allocations de mémoire.

Le pire dans toute cette histoire est que l’objet retourné pardoubleValues est une valeur temporaire qui n’est plus nécessaire. Lorsque vous avez la ligne v = doubleValues( v ), le résultat de doubleValues( v ) va juste être jeté une fois copié ! En théorie, il devrait être possible de sauter la copie et de simplement voler le pointeur dans le vecteur temporaire et le garder dans v. En fait, pourquoi ne pouvons-nous pas déplacer l’objet ? En C++03, la réponse est qu’il n’y avait aucun moyen de savoir si un objet était temporaire ou non, vous deviez exécuter le même code dans l’opérateur d’affectation ou le constructeur de copie, peu importe d’où venait la valeur, donc aucun chapardage n’était possible. En C++11, la réponse est : vous pouvez !

C’est à cela que servent les références rvalue et la sémantique move ! La sémantique de déplacement vous permet d’éviter les copies inutiles lorsque vous travaillez avec des objets temporaires qui sont sur le point de s’évaporer, et dont les ressources peuvent être prises en toute sécurité à partir de cet objet temporaire et utilisées par un autre.

La sémantique de déplacement repose sur une nouvelle fonctionnalité de C++11, appelée références rvalue,que vous voudrez comprendre pour vraiment apprécier ce qui se passe. Donc, tout d’abord, parlons de ce qu’est une rvalue, puis de ce qu’est une référence rvalue.Enfin, nous reviendrons sur la sémantique de déplacement et sur la façon dont elle peut être mise en œuvre avec des références rvalue.

Rvalues et lvalues – rivales acharnées, ou meilleures des amies ?

En C++, il existe des rvalues et des lvalues. Une lvalue est une expression dont l’adresse peut être prise, une valeur localisatrice – essentiellement, une lvalue fournit un morceau (semi-)permanent de mémoire. Vous pouvez effectuer des affectations aux lvalues. Forexample:

int a;a = 1; // here, a is an lvalue

Vous pouvez aussi avoir des lvalues qui ne sont pas des variables:

int x;int& getRef () { return x;}getRef() = 4;

Ici, getRef retourne une référence à une variable globale, donc il retourne une valeur qui est stockée dans un emplacement permanent. (Vous pourriez littéralement écrire & getRef() si vous le vouliez, et cela vous donnerait l’adresse de x.)

Les rvalues sont… eh bien, les rvalues ne sont pas des lvalues. Une expression est une rvalue si elle aboutit à un objet temporaire. Par exemple :

int x;int getVal (){ return x;}getVal();

Ici, getVal() est une rvalue – la valeur retournée n’est pas une référence à x, c’est juste une valeur temporaire. Cela devient un peu plus intéressant si nous utilisons des objets réels au lieu de nombres:

string getName (){ return "Alex";}getName();

Ici, getName renvoie une chaîne de caractères qui est construite à l’intérieur de la fonction. Vous pouvez assigner le résultat de getName à une variable:

string name = getName();

Mais vous assignez à partir d’un objet temporaire, pas à partir d’une valeur qui a un emplacement fixe. getName() est une rvalue.

Détecter les objets temporaires avec des références rvalue

L’important est que les rvalues se réfèrent à des objets temporaires – tout comme la valeur retournée par doubleValues. Ne serait-il pas formidable si nous pouvions savoir, sans l’ombre d’un doute, qu’une valeur renvoyée par une expression est temporaire, et en quelque sorte écrire du code surchargé pour se comporter différemment pour les objets temporaires ? Eh bien, oui, oui, en effet, ce serait génial. Et c’est à cela que servent les références rvalue. Une référence rvalue est une référence qui ne se liera qu’à un objet temporaire. Qu’est-ce que je veux dire ?

Avant C++11, si vous aviez un objet temporaire, vous pouviez utiliser une référence « régulière » ou « lvalue » pour le lier, mais seulement si elle était const:

const string& name = getName(); // okstring& name = getName(); // NOT ok

L’intuition ici est que vous ne pouvez pas utiliser une référence « mutable » parce que, si vous le faisiez, vous seriez capable de modifier un objet qui est sur le point de disparaître, et ce serait dangereux. Notez, en passant, que le fait de conserver une référence constante à un objet temporaire garantit que cet objet temporaire ne sera pas immédiatement détruit. C’est une belle garantie du C++, mais c’est toujours un objet temporaire, donc vous ne voulez pas le modifier.

En C++11, cependant, il y a un nouveau type de référence, une « référence rvalue »,qui vous permettra de lier une référence mutable à une rvalue, mais pas à une lvalue. En d’autres termes, les références rvalue sont parfaites pour détecter si une valeur est un objet temporaire ou non. Les références rvalue utilisent la syntaxe && au lieu de &, et peuvent être const et non-const, tout comme les références lvalue, bien que vous verrez rarement une référence rvalue const (comme nous le verrons, les références mutables sont un peu le but):

const string&& name = getName(); // okstring&& name = getName(); // also ok - praise be!

Alors, tout cela est bien beau, mais en quoi cela aide-t-il ? La chose la plus importante sur les références lvalue vs rvalue est ce qui se passe lorsque vous écrivez des fonctions qui prennent des références lvalue ou rvalue comme arguments. Disons que nous avons deux fonctions:

printReference (const String& str){ cout << str;}printReference (String&& str){ cout << str;}

Maintenant le comportement devient intéressant- la fonction printReference prenant une référence lvalue aconst acceptera tout argument qui lui est donné, que ce soit une lvalue ou une rvalue, et indépendamment du fait que la lvalue ou la rvalue soit mutable ou non. Cependant, en présence de la seconde surcharge, printReference prenant une référence rvalue, elle recevra toutes les valeurs sauf les références mutables. En d’autres termes, si vous écrivez :

string me( "alex" );printReference( me ); // calls the first printReference function, taking an lvalue referenceprintReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference

Nous avons maintenant un moyen de déterminer si une variable de référence se réfère à un objet temporaire ou à un objet permanent. La version de référence rvalue de la méthode est comme l’entrée secrète de la porte arrière du club que vous ne pouvez entrer que si vous êtes un objet temporaire (club ennuyeux, je suppose). Maintenant que nous avons notre méthode pour déterminer si un objet était une chose temporaire ou permanente, comment pouvons-nous l’utiliser ?

Constructeur move et opérateur d’affectation move

Le modèle le plus commun que vous verrez lorsque vous travaillez avec des références rvalue est de créer un constructeur move et un opérateur d’affectation move (qui suit les mêmes principes). Un constructeur de déplacement, comme un constructeur de copie, prend une instance d’un objet comme argument et crée une nouvelle instance basée sur l’objet d’origine. Cependant, le constructeur move peut éviter la réallocation de mémoire car nous savons qu’on lui a fourni un objet temporaire, donc plutôt que de copier les champs de l’objet, nous allons les déplacer.

Que signifie déplacer un champ de l’objet ? Si le champ est un primitivetype, comme int, nous le copions simplement. Cela devient plus intéressant si le champ est un pointeur : ici, plutôt que d’allouer et d’initialiser une nouvelle mémoire, nous pouvons simplement voler le pointeur et le rendre nul dans l’objet temporaire ! Nous savons que l’objet temporaire ne sera plus nécessaire, donc nous pouvons prendre son pointeur sous lui.

Imaginez que nous avons une simple classe ArrayWrapper, comme ceci:

class ArrayWrapper{ public: ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; } private: int *_p_vals; int _size;};

Notez que le constructeur de copie doit à la fois allouer de la mémoire et copier chaque valeur du tableau, une à la fois ! C’est beaucoup de travail pour une copie. Ajoutons un constructeur move et gagnons une efficacité massive.

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _size( 64 ) {} ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _size( other._size ) { other._p_vals = NULL; other._size = 0; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; int _size;};

Wow, le constructeur move est en fait plus simple que le constructeur copy !
C’est tout un exploit. Les principales choses à noter sont:

  1. Le paramètre est une référence rvalue non const
  2. other._p_vals est mis à NULL

La deuxième observation explique la première–nous ne pourrions pas mettre other._p_vals àNULL si nous avions pris une référence rvalue const. Mais pourquoi avons-nous besoin de mettre other._p_vals = NULL ? La raison est le destructeur – lorsque l’objet temporaire sort de sa portée, comme tous les autres objets C++, son destructeur s’exécute et libère _p_vals. Les mêmes _p_vals que nous venons de copier ! Si nous n’attribuons pas la valeur NULL à other._p_vals, le déplacement ne sera pas vraiment un déplacement – il s’agira simplement d’une copie qui provoquera un crash plus tard, lorsque nous commencerons à utiliser la mémoire libérée. C’est tout l’intérêt d’un constructeur move : éviter l’acopie en changeant l’objet original, temporaire !

Encore, les règles de surcharge fonctionnent de telle sorte que le constructeur move n’est appelé que pour un objet temporaire–et seulement un objet temporaire qui peut être modifié. Une chose que cela signifie est que si vous avez une fonction qui renvoie un objet const, cela provoquera l’exécution du constructeur de copie au lieu du constructeur de déplacement – ne pas écrire du code comme ceci:

const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!

Il y a encore une autre situation que nous n’avons pas discuté comment gérer dans un constructeur de déplacement – quand nous avons un champ qui est un objet. Par exemple, imaginons qu’au lieu d’avoir un champ de taille, nous ayons un champ de métadonnées qui ressemble à ceci:

class MetaData{public: MetaData (int size, const std::string& name) : _name( name ) , _size( size ) {} // copy constructor MetaData (const MetaData& other) : _name( other._name ) , _size( other._size ) {} // move constructor MetaData (MetaData&& other) : _name( other._name ) , _size( other._size ) {} std::string getName () const { return _name; } int getSize () const { return _size; } private: std::string _name; int _size;};

Maintenant notre tableau peut avoir un nom et une taille, donc nous pourrions avoir à changer la définition de ArrayWrapper comme ceci:

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _metadata( 64, "ArrayWrapper" ) {} ArrayWrapper (int n) : _p_vals( new int ) , _metadata( n, "ArrayWrapper" ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { other._p_vals = NULL; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _metadata( other._metadata ) { for ( int i = 0; i < _metadata.getSize(); ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; MetaData _metadata;};

Est-ce que cela fonctionne ? Il semble très naturel, n’est-ce pas, de simplement appeler le constructeur de MetaDatamove à partir du constructeur de move pour ArrayWrapper ? Le problème est que cela ne fonctionne pas. La raison en est simple : la valeur de other dans le constructeur de move – c’est une référence rvalue. Mais une référence rvalue n’est pas, en fait, une rvalue. C’est une lvalue, et donc le constructeur copy est appelé, pas le constructeur move. C’est bizarre. Je sais, c’est déroutant. Voici la façon d’y penser. Une rvalue est une expression qui crée un objet sur le point de s’évaporer dans l’air. Il est à bout de souffle ou sur le point d’atteindre son but dans la vie. Soudain, nous passons le temporaire à un constructeur de déplacement, et il prend une nouvelle vie dans la nouvelle portée. Dans le contexte où l’expression rvalue a été évaluée, l’objet temporaire est vraiment terminé. En d’autres termes, nous pourrions utiliser la variable other plus d’une fois dans la fonction, et l’objet temporaire a un emplacement défini qui persiste vraiment pendant toute la durée de la fonction. C’est une lvalue au sens propre du terme locator value, nous pouvons localiser l’objet à une adresse particulière qui est stable pour toute la durée de l’appel de fonction. Nous pourrions, en fait, vouloir l’utiliser plus tard dans la fonction. Si un constructeur move était appelé chaque fois que nous tenions un objet dans une rvaluereference, nous pourrions utiliser un objet déplacé, par accident !

 // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { // if _metadata( other._metadata ) calls the move constructor, using // other._metadata here would be extremely dangerous! other._p_vals = NULL; }

Pour finir, les références lvalue et rvalue sont toutes deux des expressions lvalue. La différence est qu’une référence lvalue doit être const pour contenir une référence à une rvalue, alors qu’une référence rvalue peut toujours contenir une référence à une rvalue.C’est comme la différence entre un pointeur, et ce qui est pointé vers. La chose pointée vers venait d’une rvalue, mais lorsque nous utilisons la référence rvalue elle-même, cela donne une lvalue.

std::move

Alors quelle est l’astuce pour gérer ce cas ? Nous devons utiliser std::move, from<utility>–std::move est une façon de dire, « ok, honnêtement, je sais que j’ai une lvalue, mais je veux que ce soit une rvalue. » std::move ne déplace rien, en soi ; il transforme juste une lvalue en rvalue, afin que vous puissiez invoquer le constructeur move. Notre code devrait ressembler à ceci:

#include <utility> // for std::move // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( std::move( other._metadata ) ) { other._p_vals = NULL; }

Et bien sûr, nous devrions vraiment retourner à MetaData et corriger son propre constructeur move pour qu’il utilise std::move sur la chaîne qu’il contient:

 MetaData (MetaData&& other) : _name( std::move( other._name ) ) // oh, blissful efficiency : _size( other._size ) {}

Opérateur d’affectation move

De même que nous avons un constructeur move, nous devrions également avoir un opérateur d’affectation move. Vous pouvez facilement en écrire un en utilisant les mêmes techniques que pour créer un constructeur de déplacement.

Constructeurs de déplacement et constructeurs implicitement générés

Comme vous le savez, en C++, lorsque vous déclarez un constructeur quelconque, le compilateur ne génère plus le constructeur par défaut pour vous. Il en va de même ici : ajouter un constructeur de déplacement à une classe vous obligera à déclarer et définir votre propre constructeur par défaut. D’un autre côté, déclarer un constructeur move n’empêche pas le compilateur de fournir un copyconstructeur généré implicitement, et déclarer un opérateur d’affectation move n’empêche pas la création d’un opérateur d’affectation standard.

Comment fonctionne std::move

Vous vous demandez peut-être comment on écrit une fonction comme std::move ? Comment obtient-on cette propriété magique de transformer une lvalue en une référence de lvalue ? La réponse, comme vous pouvez le deviner, est le typecasting. La déclaration réelle de std::move est un peu plus complexe, mais au fond, il s’agit simplement d’un static_cast vers une référence rvalue. Cela signifie, en fait, que vous n’avez pas vraiment besoin d’utiliser move – mais vous devriez, puisque votre sens est beaucoup plus clair. Le fait qu’un cast soit nécessaire est, d’ailleurs, une très bonne chose ! Cela signifie que vous ne pouvez pas accidentellement convertir une lvalue en rvalue, ce qui serait dangereux puisque cela pourrait permettre à un déplacement accidentel d’avoir lieu. Vous devez explicitement utiliser std::move (ou un cast) pour convertir une lvalue en une référence rvalue, et une référence rvalue ne se liera jamais à une lvalue sur sa propre.

Retourner une référence rvalue explicite à partir d’une fonction

Y a-t-il des moments où vous devriez écrire une fonction qui retourne une référence rvalue ? De toute façon, qu’est-ce que cela signifie de retourner une référence rvalue ? Les fonctions qui renvoient des objets par valeur ne sont-elles pas déjà des rvalues ?

Répondons d’abord à la deuxième question : renvoyer une référence rvalue explicite est différent de renvoyer un objet par valeur. Prenez l’exemple simple suivant :

int x;int getInt (){ return x;}int && getRvalueInt (){ // notice that it's fine to move a primitive type--remember, std::move is just a cast return std::move( x );}

Il est clair que dans le premier cas, malgré le fait que getInt() soit une rvalue, il y a une copie de la variable x qui est faite. Nous pouvons même le voir en écrivant une petite fonction d’aide :

void printAddress (const int& v) // const ref to allow binding to rvalues{ cout << reinterpret_cast<const void*>( & v ) << endl;}printAddress( getInt() ); printAddress( x );

Lorsque vous exécuterez ce programme, vous verrez qu’il y a deux valeurs distinctes imprimées.

D’autre part,

printAddress( getRvalueInt() ); printAddress( x );

imprime la même valeur parce que nous retournons explicitement une rvalue ici.

Donc, retourner une référence rvalue est une chose différente que de ne pas retourner une référence rvalue, mais cette différence se manifeste plus sensiblement si vous avez un objet préexistant que vous retournez au lieu d’un objet temporaire créé dans la fonction (où le compilateur est susceptible d’éliminer la copie pour vous).

Maintenant, à la question de savoir si vous voulez faire cela. La réponse est : probablement pas. Dans la plupart des cas, cela rend juste plus probable que vous vous retrouverez avec une référence pendante (un cas où la référence existe, mais l’objet temporaire auquel elle se réfère a été détruit). Le problème est assez similaire au danger de renvoyer une référence lvalue : l’objet auquel il est fait référence peut ne plus exister. Les références rvalue ne peuvent pas magiquement garder un objet en vie pour vous.Renvoyer une référence rvalue aurait principalement du sens dans de très rares casoù vous avez une fonction membre et avez besoin de renvoyer le résultat de l’appel àstd::move sur un champ de la classe de cette fonction–et combien de fois allez-vous faire cela ?

La sémantique de move et la bibliothèque standard

Retournons à notre exemple original–nous utilisions un vecteur, et nous n’avons pas le contrôle sur la classe de vecteur et si oui ou non elle a un constructeur move opérateur d’affectation. Heureusement, le comité de normalisation est avisé, et la sémantique move a été ajoutée à la bibliothèque standard. Cela signifie que vous pouvez maintenant retourner efficacement des vecteurs, des cartes, des chaînes de caractères et tout autre objet de la bibliothèque standard que vous voulez, en tirant pleinement parti de la sémantique move.

Objets déplaçables dans les conteneurs STL

En fait, la bibliothèque standard va un peu plus loin. Si vous activez la sémantique de déplacement dans vos propres objets en créant des opérateurs d’affectation de déplacement et des constructeurs de déplacement, lorsque vous stockez ces objets dans un conteneur, la STL utilisera automatiquement std::move, en tirant automatiquement avantage des classes activées par le déplacement pour éliminer les copies inefficaces.

Articles

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.