Pourquoi copions-nous puis déplaçons-nous?

98

J'ai vu du code quelque part dans lequel quelqu'un a décidé de copier un objet et de le déplacer par la suite vers une donnée membre d'une classe. Cela m'a laissé dans la confusion en ce sens que je pensais que tout l'intérêt de bouger était d'éviter de copier. Voici l'exemple:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Voici mes questions:

  • Pourquoi ne prenons-nous pas une référence à rvalue str?
  • Une copie ne coûtera-t-elle pas cher, surtout avec quelque chose comme std::string?
  • Quelle serait la raison pour laquelle l'auteur déciderait de faire une copie puis un déménagement?
  • Quand dois-je faire cela moi-même?
user2030677
la source
Cela me semble une erreur stupide, mais je serai intéressé de voir si quelqu'un avec plus de connaissances sur le sujet a quelque chose à dire à ce sujet.
Dave
Ce Q&R que j'ai initialement oublié de lier peut également être pertinent pour le sujet.
Andy Prowl

Réponses:

97

Avant de répondre à vos questions, une chose que vous semblez vous tromper: prendre par valeur en C ++ 11 ne signifie pas toujours copier. Si une rvalue est passée, elle sera déplacée (à condition qu'un constructeur de déplacement viable existe) plutôt que d'être copiée. Et std::stringa un constructeur de mouvement.

Contrairement à C ++ 03, en C ++ 11, il est souvent idiomatique de prendre des paramètres par valeur, pour les raisons que je vais expliquer ci-dessous. Consultez également ces questions-réponses sur StackOverflow pour obtenir un ensemble de directives plus générales sur la façon d'accepter les paramètres.

Pourquoi ne prenons-nous pas une référence à rvalue str?

Parce que cela rendrait impossible la transmission de lvalues, comme dans:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

S'il y Savait seulement un constructeur qui accepte rvalues, ce qui précède ne compilerait pas.

Une copie ne coûtera-t-elle pas cher, surtout avec quelque chose comme std::string?

Si vous passez une rvalue, celle-ci sera déplacée vers str, et éventuellement déplacée vers data. Aucune copie ne sera effectuée. Si vous passez une lvalue, par contre, cette lvalue sera copiée dans str, puis déplacée dans data.

Donc, pour résumer, deux mouvements pour les valeurs, une copie et un mouvement pour les valeurs.

Quelle serait la raison pour laquelle l'auteur déciderait de faire une copie puis un déménagement?

Tout d'abord, comme je l'ai mentionné ci-dessus, le premier n'est pas toujours une copie; et ceci dit, la réponse est: " Parce que c'est efficace (les déplacements d' std::stringobjets sont bon marché) et simple ".

Sous l'hypothèse que les mouvements sont bon marché (en ignorant ici SSO), ils peuvent être pratiquement ignorés lors de l'examen de l'efficacité globale de cette conception. Si nous le faisons, nous avons une copie pour lvalues ​​(comme nous l'aurions fait si nous acceptions une référence lvalue à const) et aucune copie pour rvalues ​​(alors que nous aurions toujours une copie si nous acceptions une référence lvalue à const).

Cela signifie que prendre par valeur est aussi bon que prendre par référence constlvalue quand lvalues ​​sont fournies, et mieux quand rvalues ​​sont fournies.

PS: Pour fournir un certain contexte, je crois que c'est le Q&R auquel le PO fait référence.

Andy Prowl
la source
2
Il vaut la peine de mentionner que c'est un modèle C ++ 11 qui remplace le const T&passage d'argument: dans le pire des cas (lvalue) c'est la même chose, mais dans le cas d'un temporaire, vous n'avez qu'à déplacer le temporaire. Gagnant-gagnant.
syam
3
@ user2030677: Il est impossible de contourner cette copie, sauf si vous stockez une référence.
Benjamin Lindley
5
@ user2030677: Qui se soucie du prix de la copie tant que vous en avez besoin (et vous le faites, si vous voulez en conserver une copie dans votre datamembre)? Vous en auriez une copie même si vous preniez par référence lvaleur àconst
Andy Prowl
3
@BenjaminLindley: Pour commencer, j'ai écrit: " En supposant que les mouvements sont bon marché, ils peuvent être pratiquement ignorés lorsque l'on considère l'efficacité globale de cette conception. ". Alors oui, il y aurait la surcharge d'un déménagement, mais cela devrait être considéré comme négligeable à moins qu'il n'y ait la preuve que c'est une préoccupation réelle qui justifie de changer une conception simple en quelque chose de plus efficace.
Andy Prowl
1
@ user2030677: Mais c'est un exemple complètement différent. Dans l'exemple de votre question, vous finissez toujours par en tenir une copie data!
Andy Prowl
51

Pour comprendre pourquoi c'est un bon modèle, nous devons examiner les alternatives, à la fois en C ++ 03 et en C ++ 11.

Nous avons la méthode C ++ 03 pour prendre un std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

dans ce cas, il y aura toujours une seule copie effectuée. Si vous construisez à partir d'une chaîne C brute, un std::stringsera construit, puis copié à nouveau: deux allocations.

Il existe la méthode C ++ 03 pour prendre une référence à a std::string, puis la permuter dans un local std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

c'est la version C ++ 03 de "move semantics", et swappeut souvent être optimisée pour être très bon marché (un peu comme a move). Il doit également être analysé dans son contexte:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

et vous oblige à former un non-temporaire std::string, puis à le jeter. (Un temporaire std::stringne peut pas se lier à une référence non-const). Une seule allocation est cependant effectuée. La version C ++ 11 prendrait un &&et vous demanderait de l'appeler avec std::move, ou avec un temporaire: cela nécessite que l'appelant crée explicitement une copie en dehors de l'appel et déplace cette copie dans la fonction ou le constructeur.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Utilisation:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Ensuite, nous pouvons faire la version complète de C ++ 11, qui prend en charge à la fois la copie et move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Nous pouvons ensuite examiner comment cela est utilisé:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

Il est assez clair que cette technique de surcharge est au moins aussi efficace, sinon plus, que les deux styles C ++ 03 ci-dessus. Je vais doubler cette version à 2 surcharges la version "la plus optimale".

Maintenant, nous allons examiner la version à prendre par copie:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

dans chacun de ces scénarios:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Si vous comparez cette version côte à côte avec la version "la plus optimale", nous en faisons exactement une de plus move! Pas une seule fois, nous ne faisons un extra copy.

Donc, si nous supposons que movec'est bon marché, cette version nous offre presque les mêmes performances que la version la plus optimale, mais 2 fois moins de code.

Et si vous prenez par exemple 2 à 10 arguments, la réduction du code est exponentielle - 2x fois moins avec 1 argument, 4x avec 2, 8x avec 3, 16x avec 4, 1024x avec 10 arguments.

Maintenant, nous pouvons contourner cela via un transfert parfait et SFINAE, vous permettant d'écrire un seul constructeur ou modèle de fonction qui prend 10 arguments, fait SFINAE pour s'assurer que les arguments sont de types appropriés, puis les déplace ou les copie dans le état local selon les besoins. Bien que cela évite l'augmentation de mille fois du problème de la taille du programme, il peut encore y avoir toute une pile de fonctions générées à partir de ce modèle. (les instanciations de fonction de modèle génèrent des fonctions)

Et de nombreuses fonctions générées signifient une taille de code exécutable plus grande, ce qui peut en soi réduire les performances.

Pour le coût de quelques movesecondes, nous obtenons un code plus court et presque les mêmes performances, et souvent un code plus facile à comprendre.

Maintenant, cela ne fonctionne que parce que nous savons, lorsque la fonction (dans ce cas, un constructeur) est appelée, que nous voulons une copie locale de cet argument. L'idée est que si nous savons que nous allons faire une copie, nous devrions informer l'appelant que nous faisons une copie en la plaçant dans notre liste d'arguments. Ils peuvent alors s'optimiser autour du fait qu'ils vont nous en donner une copie (en se déplaçant dans notre argumentation, par exemple).

Un autre avantage de la technique de «prise par valeur» est que les constructeurs de déplacement sont souvent noexcept. Cela signifie que les fonctions qui prennent par valeur et sortent de leur argument peuvent souvent être noexcept, déplaçant les throws hors de leur corps et dans la portée d'appel (qui peut parfois l'éviter via la construction directe, ou construire les éléments et movedans l'argument, pour contrôler où se produit le lancer).

Yakk - Adam Nevraumont
la source
J'ajouterais aussi si nous savons que nous allons faire une copie, nous devrions laisser le compilateur le faire, car le compilateur sait toujours mieux.
Rayniery
6
Depuis que j'ai écrit ceci, un autre avantage m'a été signalé: souvent les constructeurs de copie peuvent lancer, alors que les constructeurs de déplacement le sont souvent noexcept. En prenant des données par copie, vous pouvez créer votre fonction noexceptet faire en sorte que toute construction de copie provoquée des lancements potentiels (comme une mémoire insuffisante) se produise en dehors de votre appel de fonction.
Yakk - Adam Nevraumont
Pourquoi avez-vous besoin de la version "lvalue non-const, copy" dans la technique de surcharge 3? Le "lvalue const, copy" ne gère-t-il pas également le cas non const?
Bruno Martinez
@BrunoMartinez nous ne le faisons pas!
Yakk - Adam Nevraumont
13

Ceci est probablement intentionnel et est similaire à l' idiome de copie et d'échange . Fondamentalement, puisque la chaîne est copiée avant le constructeur, le constructeur lui-même est exceptionnellement sûr car il échange (déplace) uniquement la chaîne temporaire str.

Joe
la source
+1 pour le parallèle de copie et d'échange. En effet, il présente de nombreuses similitudes.
syam
11

Vous ne voulez pas vous répéter en écrivant un constructeur pour le déplacement et un pour la copie:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

C'est beaucoup de code standard, surtout si vous avez plusieurs arguments. Votre solution évite cette duplication sur le coût d'un déménagement inutile. (L'opération de déplacement devrait cependant être assez bon marché.)

L'idiome concurrent est d'utiliser une transmission parfaite:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

Le template magic choisira de se déplacer ou de copier en fonction du paramètre que vous passez. Il s'étend essentiellement à la première version, où les deux constructeurs ont été écrits à la main. Pour plus d'informations, voir l'article de Scott Meyer sur les références universelles .

Du point de vue des performances, la version de transmission parfaite est supérieure à votre version car elle évite les déplacements inutiles. Cependant, on peut affirmer que votre version est plus facile à lire et à écrire. L'impact possible sur les performances ne devrait pas avoir d'importance dans la plupart des situations, de toute façon, cela semble donc être une question de style à la fin.

Philipp Claßen
la source