Motivation et utilisation des constructeurs de déplacement en C ++

17

J'ai récemment lu sur les constructeurs de déplacement en C ++ (voir par exemple ici ) et j'essaie de comprendre comment ils fonctionnent et quand je dois les utiliser.

Pour autant que je sache, un constructeur de déplacement est utilisé pour atténuer les problèmes de performances causés par la copie de gros objets. La page wikipedia dit: "Un problème de performance chronique avec C ++ 03 est les copies profondes coûteuses et inutiles qui peuvent se produire implicitement lorsque des objets sont passés par valeur."

J'aborde normalement de telles situations

  • en passant les objets par référence, ou
  • en utilisant des pointeurs intelligents (par exemple boost :: shared_ptr) pour passer autour de l'objet (les pointeurs intelligents sont copiés à la place de l'objet).

Quelles sont les situations dans lesquelles les deux techniques ci-dessus ne sont pas suffisantes et l'utilisation d'un constructeur de mouvement est plus pratique?

Giorgio
la source
1
Outre le fait que la sémantique de mouvement peut accomplir beaucoup plus (comme indiqué dans les réponses), vous ne devriez pas vous demander quelles sont les situations où le passage par référence ou par pointeur intelligent n'est pas suffisant, mais si ces techniques sont vraiment la meilleure et la plus propre pour le faire (dieu méfiez-vous shared_ptrjuste pour des raisons de copie rapide) et si la sémantique de mouvement peut atteindre la même chose sans presque aucune pénalité de codage, sémantique et de propreté.
Chris dit Réintégrer Monica le

Réponses:

16

La sémantique de mouvement introduit une dimension entière dans C ++ - elle n'est pas seulement là pour vous permettre de renvoyer des valeurs à moindre coût.

Par exemple, sans move-semantics std::unique_ptrne fonctionne pas - regardez std::auto_ptr, qui a été déconseillé avec l'introduction de move-semantics et supprimé en C ++ 17. Déplacer une ressource est très différent de la copier. Il permet le transfert de propriété d'un article unique.

Par exemple, ne regardons pas std::unique_ptr, car il est assez bien discuté. Regardons, disons, un objet tampon de sommet dans OpenGL. Un tampon de vertex représente la mémoire sur le GPU - il doit être alloué et désalloué à l'aide de fonctions spéciales, pouvant avoir des contraintes strictes sur la durée de vie. Il est également important qu'un seul propriétaire l'utilise.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Maintenant, cela pourrait être fait avec un std::shared_ptr- mais cette ressource ne doit pas être partagée. Cela rend difficile l'utilisation d'un pointeur partagé. Vous pouvez utiliser std::unique_ptr, mais cela nécessite toujours une sémantique de mouvement.

Évidemment, je n'ai pas implémenté de constructeur de déplacement, mais vous avez compris.

L'important ici est que certaines ressources ne sont pas copiables . Vous pouvez passer des pointeurs au lieu de les déplacer, mais à moins d'utiliser unique_ptr, il y a le problème de la propriété. Il vaut la peine d'être aussi clair que possible quant à l'intention du code, donc un constructeur de mouvement est probablement la meilleure approche.

Max
la source
Merci d'avoir répondu. Que se passerait-il si l'on utilisait un pointeur partagé ici?
Giorgio
J'essaie de me répondre: l'utilisation d'un pointeur partagé ne permettrait pas de contrôler la durée de vie de l'objet, alors que c'est une condition que l'objet ne puisse vivre que pendant un certain temps.
Giorgio
3
@Giorgio Vous pourriez utiliser un pointeur partagé, mais ce serait sémantiquement faux. Il n'est pas possible de partager un tampon. En outre, cela vous ferait essentiellement passer un pointeur vers un pointeur (car le vbo est essentiellement un pointeur unique vers la mémoire GPU). Quelqu'un qui consulte votre code plus tard peut se demander «Pourquoi y a-t-il un pointeur partagé ici? Est-ce une ressource partagée? C'est peut-être un bug! '. Il vaut mieux être aussi clair que possible quant à l'intention initiale.
Max
@Giorgio Oui, cela fait également partie de l'exigence. Lorsque le «moteur de rendu» dans ce cas veut désallouer une ressource (peut-être pas assez de mémoire pour de nouveaux objets sur le GPU), il ne doit pas y avoir d'autre poignée à la mémoire. Utiliser un shared_ptr qui passe hors de portée fonctionnerait si vous ne le gardez pas ailleurs, mais pourquoi ne pas le rendre complètement évident quand vous le pouvez?
Max
@Giorgio Voir mon montage pour un autre essai de clarification.
Max
5

La sémantique de déplacement n'est pas nécessairement une grande amélioration lorsque vous retournez une valeur - et lorsque / si vous utilisez un shared_ptr(ou quelque chose de similaire), vous êtes probablement prématurément prématuré. En réalité, presque tous les compilateurs raisonnablement modernes font ce qu'on appelle l'optimisation de la valeur de retour (RVO) et l'optimisation de la valeur de retour nommée (NRVO). Cela signifie que lorsque vous retournez une valeur, au lieu de copier réellement la valeur du tout, ils transmettent simplement un pointeur / référence caché à l'endroit où la valeur va être affectée après le retour, et la fonction l'utilise pour créer la valeur là où elle va finir. Le standard C ++ inclut des dispositions spéciales pour permettre cela, donc même si (par exemple) votre constructeur de copie a des effets secondaires visibles, il n'est pas nécessaire d'utiliser le constructeur de copie pour renvoyer la valeur. Par exemple:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

L'idée de base ici est assez simple: créer une classe avec suffisamment de contenu, nous préférons éviter de la copier, si possible (le std::vectornous remplissons avec 32767 entiers aléatoires). Nous avons un ctor de copie explicite qui nous montrera quand / s'il est copié. Nous avons également un peu plus de code pour faire quelque chose avec les valeurs aléatoires dans l'objet, donc l'optimiseur n'éliminera pas (au moins facilement) tout ce qui concerne la classe juste parce qu'il ne fait rien.

Nous avons ensuite du code pour renvoyer l'un de ces objets à partir d'une fonction, puis utilisons la sommation pour nous assurer que l'objet est vraiment créé, et pas simplement ignoré complètement. Lorsque nous l'exécutons, au moins avec les compilateurs les plus récents / modernes, nous constatons que le constructeur de copie que nous avons écrit ne fonctionne jamais du tout - et oui, je suis presque sûr que même une copie rapide avec un shared_ptrest encore plus lente que de ne pas copier du tout.

Le déménagement vous permet de faire un bon nombre de choses que vous ne pourriez tout simplement pas faire (directement) sans elles. Considérez la partie «fusion» d'un tri de fusion externe - vous avez, disons, 8 fichiers que vous allez fusionner ensemble. Idéalement, vous aimeriez mettre les 8 de ces fichiers dans un vector- mais puisque vector(à partir de C ++ 03) doit pouvoir copier des éléments, et que ifstreams ne peut pas être copié, vous êtes coincé avec certains unique_ptr/ shared_ptr, ou quelque chose sur cet ordre pour pouvoir les mettre dans un vecteur. Notez que même si (par exemple) nous reserveespaceons dans le vectorafin que nous soyons sûrs que nos ifstreams ne seront jamais vraiment copiés, le compilateur ne le saura pas, donc le code ne compilera pas même si nous savons que le constructeur de copie ne sera jamais utilisé de toute façon.

Même s'il ne peut toujours pas être copié, en C ++ 11 un ifstream peut être déplacé. Dans ce cas, les objets ne seront probablement jamais déplacés, mais le fait qu'ils le soient si nécessaire garde le compilateur heureux, afin que nous puissions placer nos ifstreamobjets vectordirectement, sans aucun piratage de pointeur intelligent.

Un vecteur qui fait développer est un exemple assez décent d'un temps que la sémantique de mouvement peut vraiment être / sont si utiles. Dans ce cas, RVO / NRVO n'aidera pas, car nous ne traitons pas la valeur de retour d'une fonction (ou quelque chose de très similaire). Nous avons un vecteur contenant certains objets et nous voulons déplacer ces objets dans un nouveau bloc de mémoire plus grand.

En C ++ 03, cela a été fait en créant des copies des objets dans la nouvelle mémoire, puis en détruisant les anciens objets dans l'ancienne mémoire. Faire toutes ces copies juste pour jeter les anciennes était cependant une perte de temps. En C ++ 11, vous pouvez vous attendre à ce qu'ils soient déplacés à la place. Cela nous permet généralement, essentiellement, de faire une copie superficielle au lieu d'une copie profonde (généralement beaucoup plus lente). En d'autres termes, avec une chaîne ou un vecteur (pour seulement quelques exemples), nous copions simplement le ou les pointeurs dans les objets, au lieu de faire des copies de toutes les données auxquelles ces pointeurs se réfèrent.

Jerry Coffin
la source
Merci pour l'explication très détaillée. Si je comprends bien, toutes les situations dans lesquelles le déplacement entre en jeu pourraient être gérées par des pointeurs normaux, mais il serait dangereux (complexe et sujet aux erreurs) de programmer tous les jongleurs de pointeurs à chaque fois. Donc, à la place, il y a un unique_ptr (ou un mécanisme similaire) sous le capot et la sémantique de mouvement garantit qu'à la fin de la journée, il n'y a qu'une copie de pointeur et aucune copie d'objet.
Giorgio
@Giorgio: Oui, c'est à peu près correct. Le langage n'ajoute pas vraiment de sémantique de mouvement; il ajoute des références rvalue. Une référence rvalue (bien évidemment) peut se lier à une rvalue, auquel cas vous savez qu'il est sûr de "voler" sa représentation interne des données et de simplement copier ses pointeurs au lieu de faire une copie complète.
Jerry Coffin
4

Considérer:

vector<string> v;

Lors de l'ajout de chaînes à v, il se développera selon les besoins et à chaque réallocation, les chaînes devront être copiées. Avec les constructeurs de mouvements, ce n'est fondamentalement pas un problème.

Bien sûr, vous pouvez également faire quelque chose comme:

vector<unique_ptr<string>> v;

Mais cela ne fonctionnera bien que parce que les std::unique_ptroutils déplacent le constructeur.

L'utilisation std::shared_ptrn'a de sens que dans des situations (rares) où vous avez réellement la propriété partagée.

Nemanja Trifunovic
la source
mais que se passe-t-il si au lieu de stringnous avions une instance Foooù il a 30 membres de données? La unique_ptrversion ne serait pas plus efficace?
Vassilis
2

Les valeurs de retour sont l'endroit où j'aimerais le plus souvent passer par valeur au lieu d'une sorte de référence. Pouvoir retourner rapidement un objet «sur la pile» sans pénalité de performance massive serait bien. D'un autre côté, ce n'est pas particulièrement difficile de contourner cela (les pointeurs partagés sont tellement faciles à utiliser ...), donc je ne suis pas sûr que cela vaille vraiment la peine de faire un travail supplémentaire sur mes objets juste pour pouvoir le faire.

Michael Kohne
la source
J'utilise également normalement des pointeurs intelligents pour envelopper des objets qui sont renvoyés par une fonction / méthode.
Giorgio
1
@Giorgio: C'est définitivement à la fois obscur et lent.
DeadMG
Les compilateurs modernes devraient effectuer un déplacement automatique si vous retournez un simple objet sur la pile, donc il n'y a pas besoin de ptr partagés etc.
Christian Severin