web-dev-qa-db-fra.com

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

Par exemple, stdlibc ++ a 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 directement les deux membres __u à * cela? Le swap n'implique-t-il pas que __u se voit attribuer les * this membres, pour ensuite avoir attribué 0 et false ... auquel cas le swap fait un travail inutile. Qu'est-ce que je rate? (l'unique_lock :: swap fait juste un échange std :: sur chaque membre)

58
Display Name

C'est de ma faute. (moitié blague, moitié non).

Lorsque j'ai montré pour la première fois des exemples d'implémentation d'opérateurs d'affectation de déplacement, j'ai simplement utilisé 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 la mission pouvaient être importants (comme le unlock () dans votre exemple). J'ai donc cessé d'utiliser swap pour l'affectation des mouvements. Mais l'histoire de l'utilisation du swap est toujours là et persiste.

Il n'y a aucune raison d'utiliser swap dans cet exemple. C'est moins efficace que ce que vous suggérez. 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 déplacement doit:

  1. Détruisez les ressources visibles (mais enregistrez peut-être les ressources des détails de mise en œuvre).
  2. Déplacer attribuer toutes les bases et les membres.
  3. Si l'affectation de déplacement des bases et des membres n'a pas rendu les ressources sans ressources, faites-le.

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

Mise à jour

Dans les commentaires, il y a une question de suivi sur la façon de gérer les constructeurs de déplacement. 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: quel est le meilleur modèle pour créer un constructeur de mouvement? Déléguer au constructeur par défaut, puis échanger? Cela a l'avantage de réduire la duplication de code.

Ma réponse est: je pense que le plus important à emporter est que les programmeurs devraient se méfier des modèles suivants sans réfléchir. Il peut y avoir certaines classes où l'implémentation d'un constructeur de déplacement comme défaut + échange est exactement la bonne réponse. La classe peut être grande et compliquée. La 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 œil à 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, pour être utilisée par un nombre inconnu de clients. Dans une telle situation, les problèmes de performance sont hautement prioritaires. Nous ne savons pas si nos clients vont utiliser cette classe dans le code critique de performance ou non. Nous devons donc supposer qu'ils le sont.

C. Le constructeur de déplacement pour cette classe va se composer d'un petit nombre de charges et de magasins, quoi qu'il arrive. Donc, un bon moyen de regarder les performances 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 le leur est deux fois aussi rapide que le vôtre! Cette différence pourrait être critique dans la boucle étroite de certains clients.

Permet d'abord 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_);
}

Permet maintenant d'implémenter 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 façon semble beaucoup plus compliquée que la seconde. Et le code source est plus grand, et du code quelque peu en double que nous aurions 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.

La deuxième façon est le code plus simple et réutilise que nous avons déjà écrit. Ainsi moins de risques de bugs.

La première façon est plus rapide. Si le coût des charges et des magasins est à peu près le même, peut-être 66% plus rapide!

Il s'agit d'un compromis d'ingénierie classique. Il n'y a pas de repas gratuit. Et les ingénieurs ne sont jamais déchargés du fardeau d'avoir à prendre des décisions sur les compromis. Dès l'instant 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 justification est que pour cette classe, je ferais mieux de bien faire les choses quoi qu'il arrive; le cours est assez simple pour que mes chances de réussir soient élevées; et mes clients vont valoriser la performance. Je pourrais très bien arriver à une autre conclusion pour une classe différente dans un contexte différent.

88
Howard Hinnant

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

Si vous faisiez les affectations de membres manuellement, vous risqueriez que chacune d'entre elles lève une exception, puis vous auriez à faire face à une affectation partielle de quelque chose mais à vous renflouer.

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

  • Copier-assigner par copier-construire et échanger.
  • Move-assign par move-construct et swap.
  • Écrire + en termes de construction et +=, etc.

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

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

8
Kerrek SB

Une autre chose à considérer concernant le compromis:

L'implémentation default-construct + swap peut sembler plus lente, mais, parfois, l'analyse du flux de données dans le compilateur peut éliminer certaines affectations 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::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 qu'il 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 produiront pas toujours la version optimale. Je pourrais vouloir 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'attribution en raison d'une sémantique de valeur "intelligente", par exemple si l'un des membres de la classe est std :: shared_ptr. Aucune raison pour laquelle un constructeur de mouvements devrait jouer avec le refcounter atomique.

2
yonil

Je vais répondre à la question de l'en-tête: "Pourquoi certaines personnes utilisent-elles l'échange pour les affectations de déplacement?".

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

D'après le commentaire de Howard Hinnant:

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

Mais en général la fonction destroy/release peut échouer et lever l'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 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 N'est absolument pas sauf!

Oui, close() l'appel peut être "retardé" dans le cas où #2. Mais! Si nous voulons une vérification stricte des erreurs, nous devons utiliser l'appel explicite close(), pas le destructeur. Destructor ne libère des ressources que dans des situations "d'urgence", où l'exemption ne peut être levée de toute façon.

P.S. Voir aussi la discussion ici dans les commentaires

2
Victor Dyachenko