73 votes

Pourquoi certaines personnes utilisent-elles le swap pour les missions de déménagement ?

Par exemple, stdlibc++ présente les éléments suivants :

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 affecter les deux membres __u à *this directement ? Le swap n'implique-t-il pas que __u se voit attribuer les membres de *this, pour ensuite se voir attribuer 0 et false... dans ce cas, le swap fait un travail inutile. Qu'est-ce qui m'échappe ? (l'unique_lock::swap fait juste un std::swap sur chaque membre)

130voto

Howard Hinnant Points 59526

C'est ma faute. (mi-blague, mi-pas).

Lorsque j'ai montré pour la première fois des exemples d'implémentations d'opérateurs d'assignation de mouvement, j'ai simplement utilisé swap. Puis un type intelligent (je ne me souviens plus qui) m'a fait remarquer que les effets secondaires de la destruction du lhs avant l'affectation pouvaient être importants (comme le unlock() dans votre exemple). J'ai donc cessé d'utiliser swap pour l'affectation des déplacements. Mais l'histoire de l'utilisation de swap est toujours là et perdure.

Il n'y a aucune raison d'utiliser swap dans cet exemple. Il est moins efficace que ce que vous suggérez. En effet, dans libc++ Je fais exactement ce que vous suggérez :

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 devrait :

  1. Détruire les ressources visibles (mais peut-être sauver les ressources de détail de l'implémentation).
  2. Déplacer assigner toutes les bases et les membres.
  3. Si le déplacement des bases et des membres ne rendait pas la rhs sans ressources, alors faites-le.

Comme ça :

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;
    }

Mise à jour

Dans les commentaires, il y a une question complémentaire sur la façon de gérer les constructeurs de mouvements. J'ai commencé à y répondre (dans les commentaires), mais les contraintes de formatage et de longueur rendent difficile la création d'une réponse claire. Je mets donc ma réponse ici.

La question est la suivante : quel est le meilleur modèle pour créer un constructeur de déplacement ? Déléguer au constructeur par défaut et ensuite échanger ? Cela présente l'avantage de réduire la duplication du code.

Ma réponse est : Je pense que le point le plus important à retenir est que les programmeurs devraient se méfier de suivre des modèles sans réfléchir. Il peut y avoir des classes où l'implémentation d'un constructeur de mouvement comme default+swap est exactement la bonne réponse. La classe peut être grande et compliquée. Le site A(A&&) = default; peut faire le mauvais choix. Je pense qu'il est important de considérer tous vos choix pour chaque classe.

Examinons en détail l'exemple de l'OP : std::unique_lock(unique_lock&&) .

Observations :

A. Cette classe est assez simple. Elle possède deux membres de données :

mutex_type* __m_;
bool __owns_;

B. Cette classe se trouve dans une bibliothèque à usage général, destinée à être utilisée par un nombre inconnu de clients. Dans une telle situation, les préoccupations de performance sont une priorité élevée. Nous ne savons pas si nos clients vont utiliser cette classe dans un code critique pour les performances ou non. Nous devons donc supposer qu'ils le feront.

C. Le constructeur de mouvement de cette classe va consister en un petit nombre de chargements et de stockages, quoi qu'il arrive. Une bonne façon d'évaluer les performances est donc de compter les chargements et les stockages. 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 la leur est deux fois aussi rapide que la vôtre ! Cette différence pourrait s'avérer cruciale dans la boucle serrée de certains clients.

Tout d'abord, comptons les chargements et les stockages 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_);
}

Implémentons maintenant le constructeur move de deux façons :

// 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 méthode semble beaucoup plus compliquée que la seconde. Et le code source est plus volumineux, et duplique quelque peu le code 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 bogues.

La deuxième méthode est plus simple et réutilise le code que nous avons déjà écrit. Il y a donc moins de risques de bogues.

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 !

Il s'agit d'un compromis classique d'ingénierie. Il n'y a pas de repas gratuit. Et les ingénieurs ne sont jamais libérés du fardeau d'avoir à prendre des décisions sur les compromis. Dès qu'ils le font, les avions commencent à s'écraser et les centrales nucléaires à fondre.

Pour libc++ j'ai choisi la solution la plus rapide. Mon raisonnement est le suivant : pour ce cours, j'ai intérêt à bien faire les choses quoi qu'il arrive ; le cours est suffisamment simple pour que mes chances de bien faire les choses soient élevées ; et mes clients vont apprécier la performance. Je pourrais bien arriver à une autre conclusion pour un autre cours dans un autre contexte.

9voto

Kerrek SB Points 194696

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

Si vous effectuez les affectations de membres manuellement, vous risquez que chacune d'entre elles lève une exception, et vous devrez alors faire face au fait d'avoir partiellement affecté un membre à un mouvement, mais de devoir abandonner.

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

  • Copie-assignation par copie-construction et échange.
  • Déplacement-assignation par déplacement-construction et échange.
  • Écrire à + en termes de construction et += etc.

Fondamentalement, vous essayez de minimiser la quantité de code "réel" et d'exprimer autant d'autres fonctionnalités en termes de fonctionnalités de base que possible.

(Le unique_ptr prend une référence rvalue explicite dans l'affectation parce qu'il ne permet pas la construction/affectation de copies, donc ce n'est pas le meilleur exemple de ce principe de conception).

2voto

Victor Dyachenko Points 478

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

La principale raison d'utiliser swap est fournir une affectation de mouvement noexcept .

Extrait du commentaire de Howard Hinnant :

En général, un opérateur d'affectation de mouvement devrait :
1. Détruire les ressources visibles (mais peut-être sauver les ressources de détail de l'implémentation).

Mais en général la fonction destroy/release peut échouer et lancer une exception !

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 l'affectation des déplacements :

// #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;
}

y

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

#2 est parfaitement sans exception !

Oui, close() l'appel peut être "retardé" en cas #2 . Mais ! Si nous voulons un contrôle d'erreur strict, nous devons utiliser explicitement close() appel, pas destructeur. Le destructeur ne libère la ressource que dans des situations "d'urgence", où l'exception ne peut pas être lancée de toute façon.

P.S. Voir aussi la discussion aquí dans les commentaires

2voto

yonil Points 141

Un autre élément à prendre en compte concernant le compromis :

L'implémentation par défaut de la construction et de l'échange peut sembler plus lente, mais l'analyse du flux de données dans le compilateur peut parfois éliminer certaines affectations inutiles et aboutir à un code très similaire au code écrit à la main. 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::string text;
 }

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

 <inline std::string() default ctor>
 x = 0;
 y = 0;
 temp = x;
 x = other.x;
 other.x = temp;
 temp = y;
 y = other.y;
 other.y = temp;
 <inline impl of text.swap(other.text)>

Cela a l'air horrible, mais l'analyse du flux de données peut déterminer que c'est équivalent au code :

 x = other.x;
 other.x = 0;
 y = other.y;
 other.y = 0;
 <overwrite this->text with other.text, set other.text to default>

Peut-être qu'en pratique, les compilateurs ne produisent pas toujours la version optimale. Vous pourriez vouloir l'expérimenter et jeter un coup d'oeil à l'assemblage.

Il y a aussi des cas où le swapping est meilleur que l'assignation à cause d'une sémantique de valeur "intelligente", par exemple si l'un des membres de la classe est un std::shared_ptr. Il n'y a aucune raison pour qu'un constructeur de déplacement s'embrouille avec le refcounter atomique.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X