Avantages de pass-by-value et std :: move over pass-by-reference

107

J'apprends le C ++ en ce moment et j'essaie d'éviter de prendre de mauvaises habitudes. D'après ce que je comprends, clang-tidy contient de nombreuses «meilleures pratiques» et j'essaie de m'y tenir du mieux possible (même si je ne comprends pas nécessairement pourquoi elles sont encore considérées comme bonnes), mais je ne suis pas sûr si je comprendre ce qui est recommandé ici.

J'ai utilisé cette classe du tutoriel:

class Creature
{
private:
    std::string m_name;

public:
    Creature(const std::string &name)
            :  m_name{name}
    {
    }
};

Cela conduit à une suggestion de clang-tidy que je devrais passer par valeur au lieu de référence et d'utilisation std::move. Si je le fais, je reçois la suggestion de faire nameune référence (pour assurer qu'il ne soit pas copié à chaque fois) et l'avertissement qui std::moven'aura aucun effet parce nameest un constdonc je dois l' enlever.

La seule façon de ne pas recevoir d'avertissement est de supprimer constcomplètement:

Creature(std::string name)
        :  m_name{std::move(name)}
{
}

Ce qui semble logique, car le seul avantage constétait d'éviter de jouer avec la chaîne d'origine (ce qui n'arrive pas parce que j'ai passé par valeur). Mais j'ai lu sur CPlusPlus.com :

Notez cependant que -dans la bibliothèque standard- le déplacement implique que l'objet déplacé est laissé dans un état valide mais non spécifié. Ce qui signifie qu'après une telle opération, la valeur de l'objet déplacé ne doit être détruite que ou affectée d'une nouvelle valeur; y accéder donne sinon une valeur non spécifiée.

Imaginez maintenant ce code:

std::string nameString("Alex");
Creature c(nameString);

Parce qu'il nameStringest passé par valeur, std::movene sera invalidé namequ'à l'intérieur du constructeur et ne touchera pas la chaîne d'origine. Mais quels en sont les avantages? Il semble que le contenu ne soit copié qu'une seule fois de toute façon - si je passe par référence lorsque j'appelle m_name{name}, si je passe par valeur lorsque je le passe (et ensuite il est déplacé). Je comprends que c'est mieux que de passer par valeur et de ne pas utiliser std::move(car il est copié deux fois).

Donc deux questions:

  1. Ai-je bien compris ce qui se passe ici?
  2. Y a-t-il un avantage à utiliser std::movele dépassement par référence et simplement appeler m_name{name}?
Blackbot
la source
3
Avec passe par référence, Creature c("John");fait une copie supplémentaire
user253751
1
Ce lien peut être une lecture précieuse, il couvre également le passage std::string_viewet l'authentification unique.
lubgr
J'ai trouvé clang-tidyun excellent moyen de devenir obsédé par les microoptimisations inutiles au détriment de la lisibilité. La question à se poser ici, avant toute autre chose, est de savoir combien de fois appelons-nous réellement le Creatureconstructeur.
cz

Réponses:

37
  1. Ai-je bien compris ce qui se passe ici?

Oui.

  1. Y a-t-il un avantage à utiliser std::movele dépassement par référence et simplement appeler m_name{name}?

Une signature de fonction facile à saisir sans aucune surcharge supplémentaire. La signature révèle immédiatement que l'argument sera copié - cela évite aux appelants de se demander si une const std::string&référence pourrait être stockée en tant que membre de données, devenant éventuellement une référence pendante plus tard. Et il n'est pas nécessaire de surcharger les arguments std::string&& nameet les const std::string&arguments pour éviter des copies inutiles lorsque des rvalues ​​sont passées à la fonction. Passer une lvalue

std::string nameString("Alex");
Creature c(nameString);

à la fonction qui prend son argument par valeur provoque une construction de copie et de déplacement. Passer une rvalue à la même fonction

std::string nameString("Alex");
Creature c(std::move(nameString));

provoque deux constructions de déplacement. En revanche, lorsque le paramètre de fonction est const std::string&, il y aura toujours une copie, même lors du passage d'un argument rvalue. C'est clairement un avantage tant que le type d'argument est bon marché à déplacer-construire (c'est le cas pour std::string).

Mais il y a un inconvénient à considérer: le raisonnement ne fonctionne pas pour les fonctions qui affectent l'argument de la fonction à une autre variable (au lieu de l'initialiser):

void setName(std::string name)
{
    m_name = std::move(name);
}

provoquera une désallocation de la ressource à laquelle se m_nameréfère avant sa réaffectation. Je recommande de lire l'article 41 dans Effective Modern C ++ et aussi cette question .

lubgr
la source
Cela a du sens, d'autant plus que cela rend la déclaration plus intuitive à lire. Je ne suis pas sûr de bien comprendre la partie de désallocation de votre réponse (et de comprendre le fil lié), donc juste pour vérifier si j'utilise move, l'espace est désalloué. Si je ne l'utilise pas move, il n'est désalloué que si l'espace alloué est trop petit pour contenir la nouvelle chaîne, ce qui améliore les performances. Est-ce exact?
Blackbot
1
Oui, c'est exactement ça. Lors de l'assignation à à m_namepartir d'un const std::string&paramètre, la mémoire interne est réutilisée tant qu'elle m_nametient. Lors de l'assignation de déplacement m_name, la mémoire doit être désallouée au préalable. Sinon, il était impossible de «voler» les ressources du côté droit de la mission.
lubgr
Quand devient-il une référence balançante? Je pense que la liste d'initialisation utilise une copie profonde.
Li Taiji
104
/* (0) */ 
Creature(const std::string &name) : m_name{name} { }
  • Une lvalue passée se lie à name, puis est copiée dans m_name.

  • Une rvalue passée se lie à name, puis est copiée dans m_name.


/* (1) */ 
Creature(std::string name) : m_name{std::move(name)} { }
  • Une lvalue passée est copiée dans name, puis déplacée dans m_name.

  • Une rvalue passée est déplacée dans name, puis est déplacée dans m_name.


/* (2) */ 
Creature(const std::string &name) : m_name{name} { }
Creature(std::string &&rname) : m_name{std::move(rname)} { }
  • Une lvalue passée se lie à name, puis est copiée dans m_name.

  • Une rvalue passée se lie à rname, puis est déplacée dans m_name.


Comme les opérations de déplacement sont généralement plus rapides que les copies, (1) est meilleur que (0) si vous passez beaucoup de temporaires. (2) est optimal en termes de copies / déplacements, mais nécessite la répétition du code.

La répétition du code peut être évitée avec un transfert parfait :

/* (3) */
template <typename T,
          std::enable_if_t<
              std::is_convertible_v<std::remove_cvref_t<T>, std::string>, 
          int> = 0
         >
Creature(T&& name) : m_name{std::forward<T>(name)} { }

Vous pouvez éventuellement vouloir contraindre Tafin de restreindre le domaine des types avec lesquels ce constructeur peut être instancié (comme indiqué ci-dessus). C ++ 20 vise à simplifier cela avec Concepts .


En C ++ 17, les valeurs prvalues sont affectées par l' élision de copie garantie , qui - le cas échéant - réduira le nombre de copies / déplacements lors du passage d'arguments aux fonctions.

Vittorio Romeo
la source
Pour (1) les cas pr-value et xvalue ne sont pas identiques puisque c ++ 17 no?
Oliv
1
Notez que vous n'avez pas besoin du SFINAE pour perfectionner dans ce cas. Il suffit de lever l'ambiguïté. C'est plausiblement utile pour les messages d'erreur potentiels lors de la transmission de mauvais arguments
Caleth
@Oliv Oui. xvalues ​​doivent être déplacées, tandis que les prvalues ​​peuvent être éliminées :)
Rakete1111
1
Peut-on écrire: Creature(const std::string &name) : m_name{std::move(name)} { }dans le (2) ?
skytree
4
@skytree: vous ne pouvez pas vous déplacer à partir d'un objet const, car le déplacement mute la source. Cela compilera, mais cela fera une copie.
Vittorio Romeo
1

Comment vous passez n'est pas la seule variable ici, ce que vous passez fait la grande différence entre les deux.

En C ++, nous avons toutes sortes de catégories de valeur et ce « langage » existe pour les cas où l' on passe dans un rvalue ( par exemple "Alex-string-literal-that-constructs-temporary-std::string"ou std::move(nameString)), dont les résultats en 0 exemplaires de std::stringcours (le genre ne dispose même pas d'être constructible copie pour les arguments rvalue), et utilise uniquement std::stringle constructeur de déplacement de.

Questions et réponses quelque peu liées .

LogicStuff
la source
1

Il y a plusieurs inconvénients de l'approche pass-by-value-and-move par rapport à la référence pass-by-(RV):

  • il provoque l'apparition de 3 objets au lieu de 2;
  • le fait de passer un objet par valeur peut entraîner une surcharge de pile supplémentaire, car même une classe de chaîne régulière est généralement au moins 3 ou 4 fois plus grande qu'un pointeur;
  • la construction des objets d'argument va être faite du côté de l'appelant, provoquant un gonflement du code;
user7860670
la source
Pourriez-vous clarifier pourquoi cela ferait apparaître 3 objets? D'après ce que j'ai compris, je peux simplement passer "Peter" comme une chaîne. Cela serait engendré, copié puis déplacé, n'est-ce pas? Et la pile ne serait-elle pas utilisée à un moment donné? Pas au point de l'appel du constructeur, mais dans la m_name{name}partie où il est copié?
Blackbot
@Blackbot Je faisais référence à votre exemple: std::string nameString("Alex"); Creature c(nameString);un objet est nameString, un autre est un argument de fonction et le troisième est un champ de classe.
user7860670