web-dev-qa-db-fra.com

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

J'ai vu du code quelque part dans lequel quelqu'un a décidé de copier un objet et de le déplacer ensuite vers un membre de données d'une classe. Cela m'a laissé dans la confusion dans la mesure où je pensais que le fait de bouger était d'éviter la copie. 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 sera-t-elle pas coûteuse, surtout compte tenu de quelque chose comme std::string?
  • Quelle serait la raison pour laquelle l'auteur décide de faire une copie puis un mouvement?
  • Quand dois-je le faire moi-même?
97
user2030677

Avant de répondre à vos questions, vous semblez vous tromper: prendre la valeur en C++ 11 ne signifie pas toujours copier. Si une valeur r est passée, ce sera déplacé (à condition qu'il existe un constructeur de déplacement viable) plutôt que d'être copié. Et std::string a 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. Voir aussi ce Q&A sur StackOverflow pour un ensemble plus général de directives 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 le passage de valeurs, comme dans:

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

Si S n'avait qu'un constructeur qui accepte les valeurs r, ce qui précède ne serait pas compilé.

Une copie ne sera-t-elle pas coûteuse, surtout compte tenu de quelque chose comme std::string?

Si vous passez une valeur r, ce sera déplacé dans str, et qui sera finalement déplacé dans data. Aucune copie ne sera effectuée. Si vous passez une valeur l, en revanche, cette valeur sera copiée dans str, puis déplacée dans data.

Donc, pour résumer, deux mouvements pour rvalues, une copie et un mouvement pour lvalues.

Quelle serait la raison pour laquelle l'auteur décide de faire une copie puis un mouvement?

Tout d'abord, comme je l'ai mentionné plus haut, le premier n'est pas toujours une copie; et ceci dit, la réponse est: " Parce que c'est efficace (mouvements de std::string les objets sont bon marché) et simples ".

En supposant que les mouvements sont bon marché (en ignorant SSO ici), ils peuvent être pratiquement ignorés lorsque l'on considère l'efficacité globale de cette conception. Si nous le faisons, nous avons une copie pour lvalues ​​(comme nous l'aurions si nous acceptions une référence lvalue à const) et aucune copie pour rvalues ​​(alors que nous aurions encore une copie si nous acceptions une référence lvalue à const).

Cela signifie que prendre par valeur équivaut à prendre par référence lvalue à const lorsque lvalues ​​sont fournies, et mieux lorsque rvalues ​​sont fournies.

P.S .: Pour fournir un certain contexte, je crois c'est le Q&A le PO fait référence.

96
Andy Prowl

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::string Sera construit, puis recopié: deux allocations.

Il y a la méthode C++ 03 pour prendre une référence à un std::string, Puis l'échanger dans un std::string Local:

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

c'est la version C++ 03 de "déplacer la sémantique", et swap peut souvent être optimisé pour être très bon marché (un peu comme un move). Elle doit également être analysée dans son contexte:

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

et vous oblige à former un std::string non temporaire, puis à le supprimer. (Un std::string Temporaire ne peut pas se lier à une référence non constante). Cependant, une seule allocation est effectuée. La version C++ 11 prendrait un && Et vous obligerait à l'appeler avec std::move, Ou avec un temporaire: cela nécessite que l'appelant explicitement crée une copie en dehors de l'appel et déplacez 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 2 est au moins aussi efficace, sinon plus, que les deux styles C++ 03 ci-dessus. Je vais doubler cette version à 2 surcharges de la version "la plus optimale".

Maintenant, nous allons examiner la version prise 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 faisons exactement un move supplémentaire! Pas une seule fois nous ne faisons un copy supplémentaire.

Donc, si nous supposons que move est bon marché, cette version nous procure 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 de 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, SFINAE s'assure 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 empêche l'augmentation de mille fois du problème de taille du programme, il peut toujours y avoir un tas de fonctions générées à partir de ce modèle. (les instanciations des fonctions modèles génèrent des fonctions)

Et beaucoup de fonctions générées signifient une plus grande taille de code exécutable, qui peut elle-même réduire les performances.

Pour le coût de quelques moves, nous obtenons un code plus court et presque les mêmes performances, et souvent 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 voudrons une copie locale de cet argument. L'idée est que si nous savons que nous allons faire une copie, nous devons informer l'appelant que nous faisons une copie en la mettant dans notre liste d'arguments. Ils peuvent alors optimiser le fait qu'ils vont nous en donner une copie (en passant par exemple à notre argumentation).

Un autre avantage de la technique "prendre par valeur" est que souvent les constructeurs se déplacent ne sont pas exceptés. Cela signifie que les fonctions qui prennent des sous-valeurs et sortent de leur argument peuvent souvent être nulles, en déplaçant les throw de leur corps et dans la portée appelante (qui peut parfois l'éviter via la construction directe, ou construire les éléments et move dans l'argument, pour contrôler où le lancement se produit). Faire des méthodes nothrow en vaut souvent la peine.

51

Ceci est probablement intentionnel et similaire à copier et échanger idiome . Fondamentalement, puisque la chaîne est copiée avant le constructeur, le constructeur lui-même est protégé contre les exceptions car il ne fait qu'échanger (déplacer) la chaîne temporaire str.

13
Joe

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 passe-partout, 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 modèle magique choisira de déplacer ou de copier en fonction du paramètre que vous transmettez. Il s'étend essentiellement à la première version, où les deux constructeurs ont été écrits à la main. Pour des informations générales, voir l'article de Scott Meyer sur références universelles .

Du point de vue des performances, la version de transfert parfaite est supérieure à votre version car elle évite les mouvements 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, il semble donc que ce soit une question de style au final.

11
Philipp Claßen