Qu’est-ce que la sémantique de mouvement?

Ma première réponse était une introduction extrêmement simplifiée à la sémantique de mouvement, et de nombreux détails ont été laissés de côté à dessein pour rester simple.Cependant, il y a beaucoup plus à la sémantique de mouvement, et j’ai pensé qu’il était temps pour une deuxième réponse pour combler les lacunes.La première réponse est déjà assez ancienne, et il ne se sentait pas juste de simplement la remplacer par un texte complètement différent. Je pense qu’il peut encore servir de première introduction. Mais si vous voulez creuser plus profondément, lisez la suite 🙂

Stephan T. Lavavej a pris le temps de fournir des commentaires précieux. Merci beaucoup, Stephan !

Introduction

La sémantique du mouvement permet à un objet, sous certaines conditions, de prendre possession des ressources externes d’un autre objet. Ceci est important de deux façons :

  1. Transformer des copies coûteuses en mouvements bon marché. Voir ma première réponse pour un exemple. Notez que si un objet ne gère pas au moins une ressource externe (soit directement, soit indirectement à travers ses objets membres), la sémantique move n’offrira aucun avantage par rapport à la sémantique copy. Dans ce cas, copier un objet et déplacer un objet signifie exactement la même chose :

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Mise en œuvre de types sûrs « move-only » ; c’est-à-dire des types pour lesquels la copie n’a pas de sens, mais le déplacement en a. Les exemples incluent les verrous, les poignées de fichiers et les pointeurs intelligents avec une sémantique de propriété unique. Note : Cette réponse traite de std::auto_ptr, un modèle déprécié de la bibliothèque standard C++98, qui a été remplacé par std::unique_ptr dans C++11. Les programmeurs C++ intermédiaires sont probablement au moins un peu familiers avec std::auto_ptr, et en raison de la « sémantique de déplacement » qu’il affiche, il semble être un bon point de départ pour discuter de la sémantique de déplacement dans C++11. YMMV.

Qu’est-ce qu’un move?

La bibliothèque standard C++98 offre un pointeur intelligent avec une sémantique de propriété unique appelée std::auto_ptr<T>. Au cas où vous ne seriez pas familier avec auto_ptr, son but est de garantir qu’un objet alloué dynamiquement est toujours libéré, même face à des exceptions :

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

La chose inhabituelle à propos de auto_ptr est son comportement de « copie » :

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Notez comment l’initialisation de b avec a ne copie pas le triangle, mais transfère plutôt la propriété du triangle de a à b. On dit aussi « a est déplacé dans b » ou « le triangle est déplacé de a à b« . Cela peut paraître confus car le triangle lui-même reste toujours au même endroit dans la mémoire.

Déplacer un objet signifie transférer la propriété d’une certaine ressource qu’il gère à un autre objet.

Le constructeur de copie de auto_ptr ressemble probablement à quelque chose comme ceci (quelque peu simplifié):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Dangerous and harmless moves

La chose dangereuse à propos de auto_ptr est que ce qui ressemble syntaxiquement à une copie est en fait un déplacement. Essayer d’appeler une fonction membre sur un auto_ptr déplacé de invoquera un comportement non défini, vous devez donc faire très attention à ne pas utiliser un auto_ptr après qu’il ait été déplacé de:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Mais auto_ptr n’est pas toujours dangereux. Les fonctions de fabrique sont un cas d’utilisation parfaitement correct pour auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Notez comment les deux exemples suivent le même modèle syntaxique:

auto_ptr<Shape> variable(expression);double area = expression->area();

Et pourtant, l’un d’entre eux invoque un comportement indéfini, alors que l’autre ne le fait pas. Quelle est donc la différence entre les expressions a et make_triangle() ? Ne sont-elles pas toutes deux du même type ? En effet, elles le sont, mais elles ont des catégories de valeurs différentes.

Catégories de valeurs

Il doit évidemment y avoir une différence profonde entre l’expression a qui désigne une variable auto_ptr, et l’expression make_triangle() qui désigne l’appel d’une fonction qui renvoie un auto_ptr par valeur, créant ainsi un objet auto_ptr temporaire frais à chaque appel. a est un exemple de lvalue, alors que make_triangle() est un exemple de rvalue.

Le passage de lvalues telles que a est dangereux, car nous pourrions plus tard essayer d’appeler une fonction membre via a, invoquant un comportement non défini. En revanche, le déplacement de rvalues telles que make_triangle() est parfaitement sûr, car après que le constructeur de copie ait fait son travail, nous ne pouvons plus utiliser le temporaire. Il n’y a pas d’expression qui dénote ledit temporaire ; si nous écrivons simplement make_triangle() à nouveau, nous obtenons un temporaire différent. En fait, le temporaire déplacé est déjà parti à la ligne suivante :

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Notez que les lettres l et r ont une origine historique dans le côté gauche et le côté droit d’une affectation. Ce n’est plus vrai en C++, car il y a des lvalues qui ne peuvent pas apparaître sur le côté gauche d’une affectation (comme les tableaux ou les types définis par l’utilisateur sans opérateur d’affectation), et il y a des rvalues qui le peuvent (toutes les rvalues de types de classe avec un opérateur d’affectation).

Une rvalue de type de classe est une expression dont l’évaluation crée un objet temporaire. Dans des circonstances normales, aucune autre expression à l’intérieur de la même portée ne dénote le même objet temporaire.

Rvalue références

Nous comprenons maintenant que le passage des lvalues est potentiellement dangereux, mais que le passage des rvalues est inoffensif. Si C++ avait un support de langage pour distinguer les arguments lvalue des arguments rvalue, nous pourrions soit interdire complètement le déplacement à partir de lvalues, soit au moins rendre le déplacement à partir de lvalues explicite au site d’appel, de sorte que nous ne déplacions plus par accident.

La réponse de C++11 à ce problème est les références rvalue. Une référence rvalue est un nouveau type de référence qui ne se lie qu’aux rvalues, et la syntaxe est X&&. La bonne vieille référence X& est maintenant connue comme une référence lvalue. (Notez que X&& n’est pas une référence à une référence ; une telle chose n’existe pas en C++.)

Si nous jetons const dans le mélange, nous avons déjà quatre types différents de références. A quels types d’expressions de type X peuvent-elles se lier ?

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

En pratique, vous pouvez oublier const X&&. Être restreint à la lecture de rvalues n’est pas très utile.

Une référence rvalue X&& est un nouveau type de référence qui ne se lie qu’à des rvalues.

Conversions implicites

Les références rvalue sont passées par plusieurs versions. Depuis la version 2.1, une référence rvalue X&& se lie également à toutes les catégories de valeurs d’un type différent Y, à condition qu’il y ait une conversion implicite de Y à X. Dans ce cas, un temporaire de type X est créé, et la référence rvalue est liée à ce temporaire :

void some_function(std::string&& r);some_function("hello world");

Dans l’exemple ci-dessus, "hello world" est une lvalue de type const char. Comme il y a une conversion implicite de const char à std::string en passant par const char*, un temporaire de type std::string est créé, et r est lié à ce temporaire. C’est l’un des cas où la distinction entre rvalues (expressions) et temporaires (objets) est un peu floue.

Constructeurs de déplacement

Un exemple utile de fonction avec un paramètre X&& est le constructeur de déplacement X::X(X&& source). Son but est de transférer la propriété de la ressource gérée de la source vers l’objet courant.

En C++11, std::auto_ptr<T> a été remplacé par std::unique_ptr<T> qui tire avantage des références rvalue. Je vais développer et discuter une version simplifiée de unique_ptr. Tout d’abord, nous encapsulons un pointeur brut et surchargeons les opérateurs -> et *, ainsi notre classe se sent comme un pointeur:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

Le constructeur prend possession de l’objet, et le destructeur le supprime:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Vient maintenant la partie intéressante, le constructeur de déplacement :

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Ce constructeur de déplacement fait exactement ce que le constructeur de copie auto_ptr faisait, mais il ne peut être fourni qu’avec des rvalues:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

La deuxième ligne échoue à compiler, parce que a est une lvalue, mais le paramètre unique_ptr&& source ne peut être lié qu’à des rvalues. C’est exactement ce que nous voulions ; les déplacements dangereux ne devraient jamais être implicites. La troisième ligne compile très bien, parce que make_triangle() est une rvalue. Le constructeur move va transférer la propriété du temporaire à c. Encore une fois, c’est exactement ce que nous voulions.

Le constructeur move transfère la propriété d’une ressource gérée dans l’objet courant.

Opérateurs d’affectation move

La dernière pièce manquante est l’opérateur d’affectation move. Son travail consiste à libérer l’ancienne ressource et à acquérir la nouvelle ressource à partir de son argument :

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Notez comment cette implémentation de l’opérateur d’affectation de déplacement duplique la logique du destructeur et du constructeur de déplacement. Êtes-vous familier avec l’idiome copy-and-swap ? Il peut également être appliqué à la sémantique de déplacement comme l’idiome move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Maintenant que source est une variable de type unique_ptr, elle sera initialisée par le constructeur move ; c’est-à-dire que l’argument sera déplacé dans le paramètre. L’argument doit toujours être une rvalue, car le constructeur de déplacement lui-même a un paramètre de référence rvalue. Lorsque le flux de contrôle atteint l’accolade fermante de operator=, source sort de la portée, libérant automatiquement l’ancienne ressource.

L’opérateur d’affectation move transfère la propriété d’une ressource gérée dans l’objet courant, libérant l’ancienne ressource. L’idiome move-and-swap simplifie l’implémentation.

Déplacement de lvalues

Parfois, nous voulons déplacer de lvalues. C’est-à-dire, parfois, nous voulons que le compilateur traite une lvalue comme si c’était une rvalue, afin qu’il puisse invoquer le constructeur move, même si cela pourrait être potentiellement non sécurisé.À cette fin, C++11 offre un modèle de fonction de la bibliothèque standard appelé std::move à l’intérieur de l’en-tête <utility>.Ce nom est un peu malheureux, parce que std::move coule simplement une lvalue en une rvalue ; il ne déplace rien par lui-même. Il permet simplement le déplacement. Peut-être aurait-il dû s’appeler std::cast_to_rvalue ou std::enable_move, mais nous sommes coincés avec ce nom maintenant.

Voici comment on se déplace explicitement à partir d’une lvalue:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Notez qu’après la troisième ligne, a ne possède plus de triangle. Ce n’est pas grave, car en écrivant explicitement std::move(a), nous avons rendu nos intentions claires :  » Cher constructeur, faites ce que vous voulez avec a afin d’initialiser c ; je ne me soucie plus de a. Sentez-vous libre de faire ce que vous voulez avec a. »

std::move(some_lvalue) caste une lvalue en une rvalue, permettant ainsi un déplacement ultérieur.

Xvalues

Notez que même si std::move(a) est une rvalue, son évaluation ne crée pas un objet temporaire. Cette énigme a forcé le comité à introduire une troisième catégorie de valeur. Quelque chose qui peut être lié à une référence rvalue, même si ce n’est pas une rvalue au sens traditionnel, est appelé une xvalue (eXpiring value). Les rvalues traditionnelles ont été renommées en prvalues (Pure rvalues).

Les prvalues et les xvalues sont toutes deux des rvalues. Les xvalues et les lvalues sont toutes deux des glvalues (Generalized lvalues). Les relations sont plus faciles à saisir avec un diagramme :

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Notez que seules les xvalues sont vraiment nouvelles ; le reste est juste dû au renommage et au regroupement.

Les rvalues de C++98 sont connues sous le nom de prvalues en C++11. Remplacez mentalement toutes les occurrences de « rvalue » dans les paragraphes précédents par « prvalue ».

Déplacement hors des fonctions

Jusqu’ici, nous avons vu le déplacement dans les variables locales, et dans les paramètres des fonctions. Mais le déplacement est également possible dans la direction opposée. Si une fonction retourne par valeur, un certain objet au site d’appel (probablement une variable locale ou un temporaire, mais pourrait être n’importe quel type d’objet) est initialisé avec l’expression après l’instruction return comme argument au constructeur de déplacement :

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Peut-être surprenant, les objets automatiques (variables locales qui ne sont pas déclarées comme static) peuvent aussi être implicitement déplacés hors des fonctions:

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

Comment se fait-il que le constructeur move accepte la lvalue result comme argument ? La portée de result est sur le point de se terminer, et elle sera détruite pendant le déroulement de la pile. Personne ne pourrait éventuellement se plaindre après coup que result a changé d’une manière ou d’une autre ; lorsque le flux de contrôle est de retour chez l’appelant, result n’existe plus ! Pour cette raison, C++11 a une règle spéciale qui permet de retourner des objets automatiques à partir de fonctions sans avoir à écrire std::move. En fait, vous ne devriez jamais utiliser std::move pour déplacer des objets automatiques hors des fonctions, car cela inhibe la « named return value optimization » (NRVO).

Ne jamais utiliser std::move pour déplacer des objets automatiques hors des fonctions.

Notez que dans les deux fonctions de fabrique, le type de retour est une valeur, et non une référence rvalue. Les références rvalue sont toujours des références, et comme toujours, vous ne devriez jamais retourner une référence à un objet automatique ; l’appelant se retrouverait avec une référence pendante si vous trompiez le compilateur pour qu’il accepte votre code, comme ceci :

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Ne retournez jamais des objets automatiques par référence rvalue. Le déplacement est exclusivement effectué par le constructeur move, pas par std::move, et pas en liant simplement une rvalue à une référence rvalue.

Déplacement dans les membres

Tôt ou tard, vous allez écrire du code comme ceci:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Basiquement, le compilateur se plaindra que parameter est une lvalue. Si vous regardez son type, vous voyez une référence rvalue, mais une référence rvalue signifie simplement « une référence qui est liée à une rvalue » ; cela ne signifie pas que la référence elle-même est une rvalue ! En effet, parameter est juste une variable ordinaire avec un nom. Vous pouvez utiliser parameter aussi souvent que vous le souhaitez à l’intérieur du corps du constructeur, et elle désigne toujours le même objet. Le déplacement implicite de celle-ci serait dangereux, c’est pourquoi le langage l’interdit.

Une référence rvalue nommée est une lvalue, comme n’importe quelle autre variable.

La solution est d’activer manuellement le déplacement:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

On pourrait argumenter que parameter n’est plus utilisée après l’initialisation de member. Pourquoi n’y a-t-il pas de règle spéciale pour insérer silencieusement std::move comme pour les valeurs de retour ? Probablement parce que ce serait une charge trop importante pour les implémenteurs du compilateur. Par exemple, que se passerait-il si le corps du constructeur se trouvait dans une autre unité de traduction ? En revanche, la règle de la valeur de retour doit simplement vérifier les tables de symboles pour déterminer si l’identifiant après le mot-clé return dénote ou non un objet automatique.

Vous pouvez également passer le parameter par valeur. Pour les types à déplacement seul comme unique_ptr, il semble qu’il n’y ait pas encore d’idiome établi. Personnellement, je préfère passer par valeur, car cela provoque moins d’encombrement dans l’interface.

Fonctions membres spéciales

C++98 déclare implicitement trois fonctions membres spéciales à la demande, c’est-à-dire lorsqu’elles sont nécessaires quelque part : le constructeur de copie, l’opérateur d’affectation de copie et le destructeur.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

Les références aux valeurs R sont passées par plusieurs versions. Depuis la version 3.0, C++11 déclare deux fonctions membres spéciales supplémentaires à la demande : le constructeur move et l’opérateur d’affectation move. Notez que ni VC10 ni VC11 ne sont encore conformes à la version 3.0, vous devrez donc les implémenter vous-même.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Ces deux nouvelles fonctions membres spéciales ne sont déclarées implicitement que si aucune des fonctions membres spéciales n’est déclarée manuellement. De même, si vous déclarez votre propre constructeur de déplacement ou opérateur d’affectation de déplacement, ni le constructeur de copie ni l’opérateur d’affectation de copie ne seront déclarés implicitement.

Que signifient ces règles en pratique ?

Si vous écrivez une classe sans ressources non gérées, il n’est pas nécessaire de déclarer vous-même l’une des cinq fonctions membres spéciales, et vous obtiendrez gratuitement une sémantique de copie et une sémantique de déplacement correctes. Dans le cas contraire, vous devrez implémenter vous-même les fonctions membres spéciales. Bien sûr, si votre classe ne bénéficie pas de la sémantique de déplacement, il n’est pas nécessaire d’implémenter les opérations spéciales de déplacement.

Notez que l’opérateur d’affectation de copie et l’opérateur d’affectation de déplacement peuvent être fusionnés en un seul opérateur d’affectation unifié, prenant son argument par valeur:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

De cette façon, le nombre de fonctions membres spéciales à implémenter tombe de cinq à quatre. Il y a un compromis entre la sécurité des exceptions et l’efficacité ici, mais je ne suis pas un expert sur cette question.

Références de renvoi (anciennement connues sous le nom de références universelles)

Considérez le modèle de fonction suivant:

template<typename T>void foo(T&&);

Vous pourriez vous attendre à ce que T&& ne se lie qu’à des rvalues, car à première vue, il ressemble à une référence rvalue. Il s’avère cependant que T&& se lie également à des lvalues:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Si l’argument est une rvalue de type X, T est déduit comme étant X, donc T&& signifie X&&. C’est ce que tout le monde attendrait.Mais si l’argument est une lvalue de type X, en raison d’une règle spéciale, T est déduit pour être X&, donc T&& signifierait quelque chose comme X& &&. Mais comme le C++ n’a toujours pas de notion de références à références, le type X& && est effondré en X&. Cela peut sembler confus et inutile au premier abord, mais le collapsing des références est essentiel pour un forwarding parfait (qui ne sera pas discuté ici).

T&& n’est pas une référence de rvalue, mais une référence de forwarding. Il se lie également à des lvalues, auquel cas T et T&& sont toutes deux des références lvalue.

Si vous voulez contraindre un modèle de fonction à des rvalues, vous pouvez combiner SFINAE avec des traits de type :

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Mise en œuvre de move

Maintenant que vous comprenez le collapsing de référence, voici comment std::move est mis en œuvre :

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Comme vous pouvez le voir, move accepte tout type de paramètre grâce à la référence de transfert T&&, et il retourne une référence rvalue. L’appel à la métafonction std::remove_reference<T>::type est nécessaire car sinon, pour les lvalues de type X, le type de retour serait X& &&, ce qui s’effondrerait en X&. Puisque t est toujours une lvalue (rappelez-vous qu’une référence rvalue nommée est une lvalue), mais que nous voulons lier t à une référence rvalue, nous devons explicitement couler t au type de retour correct.L’appel d’une fonction qui renvoie une référence rvalue est lui-même une xvalue. Maintenant vous savez d’où viennent les xvalues 😉

Laisser un commentaire

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