Pourquoi certaines personnes utilisent-elles le swap pour les affectations de déplacement?

Par exemple, stdlibc ++ a les caractéristiques suivantes:

unique_lock& operator=(unique_lock&& __u) { if(_M_owns) unlock(); unique_lock(std::move(__u)).swap(*this); __u._M_device = 0; __u._M_owns = false; return *this; } 

Pourquoi ne pas simplement assigner les deux membres __u à * cela directement? Le swap n’implique-t-il pas que __u se voit atsortingbuer le * ce membre, pour ensuite lui atsortingbuer 0 et false … auquel cas le swap fait un travail inutile. Qu’est-ce que je rate? (le unique_lock :: swap fait juste un std :: swap sur chaque membre)

C’est de ma faute. (mi-blague, à moitié pas).

Lorsque j’ai montré pour la première fois des exemples d’implémentation d’opérateurs d’affectation de mouvements, je me suis contenté d’utiliser swap. Ensuite, un type intelligent (je ne me souviens plus de qui) m’a fait remarquer que les effets secondaires de la destruction des lhs avant l’affectation pouvaient être importants (comme le délocking () dans votre exemple). J’ai donc arrêté d’utiliser le swap pour une affectation de déménagement. Mais l’historique de l’utilisation de swap est toujours là et continue.

Il n’y a aucune raison d’utiliser swap dans cet exemple. C’est moins efficace que ce que vous proposez. En effet, dans libc ++ , je fais exactement ce que vous proposez:

 unique_lock& operator=(unique_lock&& __u) { if (__owns_) __m_->unlock(); __m_ = __u.__m_; __owns_ = __u.__owns_; __u.__m_ = nullptr; __u.__owns_ = false; return *this; } 

En général, un opérateur d’affectation de mouvement doit:

  1. Détruisez les ressources visibles (mais enregistrez peut-être les ressources détaillées de l’implémentation).
  2. Déplacer assigner toutes les bases et tous les membres.
  3. Si l’affectation des bases et des membres n’a pas rendu les ressources sans ressources, faites-le en conséquence.

Ainsi:

 unique_lock& operator=(unique_lock&& __u) { // 1. Destroy visible resources if (__owns_) __m_->unlock(); // 2. Move assign all bases and members. __m_ = __u.__m_; __owns_ = __u.__owns_; // 3. If the move assignment of bases and members didn't, // make the rhs resource-less, then make it so. __u.__m_ = nullptr; __u.__owns_ = false; return *this; } 

Mettre à jour

Dans les commentaires, il y a une question de suivi sur la manière de gérer les constructeurs de déplacements. J’ai commencé à y répondre (dans les commentaires), mais les contraintes de format et de longueur rendent difficile la création d’une réponse claire. Je mets donc ma réponse ici.

La question est: quel est le meilleur modèle pour créer un constructeur de déménagement? Déléguer au constructeur par défaut et ensuite échanger? Cela a l’avantage de réduire la duplication de code.

Ma réponse est la suivante: je pense que le plus important à retenir est que les programmeurs doivent se méfier de suivre des modèles sans réfléchir. Il peut y avoir certaines classes où la mise en œuvre d’un constructeur de déplacement par défaut + swap est exactement la bonne réponse. La classe peut être grande et compliquée. Le A(A&&) = default; peut faire la mauvaise chose. Je pense qu’il est important de considérer tous vos choix pour chaque classe.

Jetons un coup d’oeil à l’exemple de l’OP en détail: std::unique_lock(unique_lock&&) .

Observations:

A. Cette classe est assez simple. Il a deux membres de données:

 mutex_type* __m_; bool __owns_; 

B. Cette classe se trouve dans une bibliothèque à usage général, à utiliser par un nombre inconnu de clients. Dans une telle situation, les problèmes de performance sont prioritaires. Nous ne soaps pas si nos clients vont utiliser cette classe en code de performance critique ou non. Nous devons donc supposer qu’ils le sont.

C. Le constructeur de déménagement pour cette classe se composera d’un petit nombre de charges et de magasins, peu importe quoi. Donc, une bonne façon de regarder la performance est de compter les charges et les magasins. Par exemple, si vous faites quelque chose avec 4 magasins et que quelqu’un d’autre fait la même chose avec seulement 2 magasins, vos deux implémentations sont très rapides. Mais leur est deux fois plus rapide que le vôtre! Cette différence pourrait être critique dans la boucle serrée de certains clients.

D’abord, permet de compter les charges et les magasins dans le constructeur par défaut, et dans la fonction d’échange de membres:

 // 2 stores unique_lock() : __m_(nullptr), __owns_(false) { } // 4 stores, 4 loads void swap(unique_lock& __u) { std::swap(__m_, __u.__m_); std::swap(__owns_, __u.__owns_); } 

Maintenant, implémentons le constructeur de déplacement de deux manières:

 // 4 stores, 2 loads unique_lock(unique_lock&& __u) : __m_(__u.__m_), __owns_(__u.__owns_) { __u.__m_ = nullptr; __u.__owns_ = false; } // 6 stores, 4 loads unique_lock(unique_lock&& __u) : unique_lock() { swap(__u); } 

La première voie semble beaucoup plus compliquée que la seconde. Et le code source est plus volumineux et le code de duplication que nous avons peut-être déjà écrit ailleurs (par exemple, dans l’opérateur d’affectation de déplacement). Cela signifie qu’il y a plus de risques de bugs.

Le deuxième moyen est plus simple et réutilise le code que nous avons déjà écrit. Donc moins de chance de bugs.

Le premier moyen est plus rapide. Si le coût des charges et des magasins est approximativement le même, peut-être 66% plus rapide!

Ceci est un compromis d’ingénierie classique. Il n’y a pas de repas gratuit. Et les ingénieurs ne sont jamais soulagés de devoir prendre des décisions concernant les compromis. La minute où on le fait, les avions commencent à tomber de l’air et les centrales nucléaires commencent à fondre.

Pour libc ++ , j’ai choisi la solution la plus rapide. Ma raison est que pour cette classe, je ferais mieux de bien faire quoi que ce soit; la classe est assez simple pour que mes chances de réussir soient élevées; et mes clients vont évaluer la performance. Je pourrais bien arriver à une autre conclusion pour une classe différente dans un contexte différent.

Il s’agit de la sécurité des exceptions. Puisque __u est déjà construit lorsque l’opérateur est appelé, nous soaps qu’il n’y a pas d’exception et que swap ne lance pas.

Si vous effectuiez manuellement les affectations de membres, vous risqueriez de créer une exception, et vous auriez alors à faire face à une erreur d’atsortingbution partielle.

Peut-être que dans cet exemple sortingvial, cela ne se voit pas, mais c’est un principe de conception général:

  • Copier-assigner par copie-construction et échange.
  • Move-assign par move-construct et swap.
  • Ecrire + en termes de construction et += , etc.

Fondamentalement, vous essayez de minimiser la quantité de “vrai” code et essayez d’exprimer autant de fonctionnalités que possible en termes de fonctionnalités de base.

(L’ unique_ptr prend une référence de valeur explicite dans l’affectation car il n’autorise pas la construction / affectation de copie, ce n’est donc pas le meilleur exemple de ce principe de conception.)

Une autre chose à considérer concernant le compromis:

L’implémentation default-construct + swap peut sembler plus lente, mais parfois l’parsing de stream de données dans le compilateur peut éliminer certaines atsortingbutions inutiles et se retrouver très similaire au code manuscrit. Cela ne fonctionne que pour les types sans sémantique de valeur “intelligente”. Par exemple,

  struct Dummy { Dummy(): x(0), y(0) {} // suppose we require default 0 on these Dummy(Dummy&& other): x(0), y(0) { swap(other); } void swap(Dummy& other) { std::swap(x, other.x); std::swap(y, other.y); text.swap(other.text); } int x, y; std::ssortingng text; } 

code généré dans move ctor sans optimisation:

   x = 0; y = 0; temp = x; x = other.x; other.x = temp; temp = y; y = other.y; other.y = temp;  

Cela semble terrible, mais l’parsing du stream de données peut déterminer qu’il est équivalent au code:

  x = other.x; other.x = 0; y = other.y; other.y = 0; text with other.text, set other.text to default> 

Peut-être qu’en pratique, les compilateurs ne produiront pas toujours la version optimale. Peut-être voudrez-vous l’expérimenter et jeter un coup d’œil à l’assemblée.

Il existe également des cas où l’échange est préférable à l’atsortingbution à cause d’une sémantique de valeur “intelligente”, par exemple si l’un des membres de la classe est un std :: shared_ptr. Aucune raison de déplacer un constructeur ne devrait gâcher le refcounter atomique.

Je vais répondre à la question de l’en-tête: “Pourquoi certaines personnes utilisent-elles le swap pour les affectations de déménagement?”.

La principale raison d’utiliser swap est de fournir une affectation de déplacement sans exception .

D’après le commentaire de Howard Hinnant:

En général, un opérateur d’affectation de mouvement doit:
1. Détruisez les ressources visibles (mais enregistrez peut-être les ressources de mise en œuvre).

Mais en général, la fonction destroy / release peut échouer et lancer des exceptions !

Voici un exemple:

 class unix_fd { int fd; public: explicit unix_fd(int f = -1) : fd(f) {} ~unix_fd() { if(fd == -1) return; if(::close(fd)) /* !!! call is failed! But we can't throw from destructor so just silently ignore....*/; } void close() // Our release-function { if(::close(fd)) throw system_error_with_errno_code; } }; 

Comparons maintenant deux implémentations de déplacement-affectation:

 // #1 void unix_fd::operator=(unix_fd &&o) // Can't be noexcept { if(&o != this) { close(); // !!! Can throw here fd = o.fd; o.fd = -1; } return *this; } 

et

 // #2 void unix_fd::operator=(unix_fd &&o) noexcept { std::swap(fd, o.fd); return *this; } 

#2 est parfaitement sans exception!

Oui, l’appel de close() peut être “retardé” dans le cas #2 . Mais! Si nous voulons une vérification ssortingcte des erreurs, nous devons utiliser l’appel explicite close() et non le destructeur. Le destructeur ne libère des ressources que dans des situations “d’urgence”, où l’exception ne peut être lancée de toute façon.

PS Voir aussi la discussion ici dans les commentaires