web-dev-qa-db-fra.com

Déplacer l'opérateur d'affectation et `if (this! = & Rhs)`

Dans l'opérateur d'affectation d'une classe, vous devez généralement vérifier si l'objet à affecter est l'objet appelant afin de ne pas tout gâcher:

Class& Class::operator=(const Class& rhs) {
    if (this != &rhs) {
        // do the assignment
    }

    return *this;
}

Avez-vous besoin de la même chose pour l'opérateur d'affectation de déménagement? Y a-t-il jamais une situation où this == &rhs serait vrai?

? Class::operator=(Class&& rhs) {
    ?
}
115
Seth Carnegie

Wow, il y a tellement de choses à nettoyer ici ...

Tout d’abord, le Copier et permuter n’est pas toujours la bonne façon de mettre en œuvre l’affectation de copie. Il est presque certain que dans le cas de dumb_array, Il s'agit d'une solution sous-optimale.

L’utilisation de Copy and Swap pour dumb_array Est un exemple classique d’ajout de l’opération la plus chère avec les fonctionnalités les plus complètes au niveau inférieur. Il est parfait pour les clients qui veulent la fonctionnalité la plus complète et sont prêts à payer la pénalité de performance. Ils obtiennent exactement ce qu'ils veulent.

Mais c’est désastreux pour les clients qui n’ont pas besoin de la fonctionnalité la plus complète mais qui recherchent la performance la plus élevée. Pour eux, dumb_array N'est qu'un autre logiciel à réécrire car trop lent. Si dumb_array Avait été conçu différemment, cela aurait pu satisfaire les deux clients sans faire de compromis.

Pour satisfaire les deux clients, il est essentiel de créer les opérations les plus rapides au niveau le plus bas, puis d’ajouter une API par-dessus, pour des fonctionnalités plus complètes à un coût plus élevé. C'est à dire. vous avez besoin de la garantie forte d'exception, très bien, vous payez pour cela. Tu n'en as pas besoin? Voici une solution plus rapide.

Soyons concrets: voici l'opérateur rapide et élémentaire de la garantie des exceptions de copie pour dumb_array:

dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)
    {
        if (mSize != other.mSize)
        {
            delete [] mArray;
            mArray = nullptr;
            mArray = other.mSize ? new int[other.mSize] : nullptr;
            mSize = other.mSize;
        }
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }
    return *this;
}

Explication:

Une des choses les plus coûteuses que vous puissiez faire sur du matériel moderne est de vous rendre au tas. Tout ce que vous pouvez faire pour éviter de vous rendre sur le tas est une perte de temps et d’efforts. Les clients de dumb_array Voudront peut-être souvent assigner des tableaux de la même taille. Et quand ils le font, tout ce que vous devez faire est un memcpy (caché sous std::copy). Vous ne voulez pas allouer un nouveau tableau de la même taille et désallouer l'ancien de la même taille!

Maintenant, pour vos clients qui souhaitent réellement une sécurité des exceptions forte:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    swap(lhs, rhs);
    return lhs;
}

Ou peut-être que si vous souhaitez tirer parti de l'affectation de déplacement dans C++ 11, procédez comme suit:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    lhs = std::move(rhs);
    return lhs;
}

Si la vitesse des clients de dumb_array, Ils doivent appeler le operator=. S'ils ont besoin d'une sécurité forte en matière d'exceptions, ils peuvent faire appel à des algorithmes génériques qui fonctionnent sur une grande variété d'objets et ne doivent être implémentés qu'une seule fois.

Revenons maintenant à la question initiale (qui a un type-o à ce moment-là):

Class&
Class::operator=(Class&& rhs)
{
    if (this == &rhs)  // is this check needed?
    {
       // ...
    }
    return *this;
}

C'est en fait une question controversée. Certains diront oui, absolument, d'autres diront non.

Mon opinion personnelle est non, vous n'avez pas besoin de ce chèque.

Raisonnement:

Lorsqu'un objet se lie à une référence rvalue, c'est l'une des deux choses suivantes:

  1. Un temporaire.
  2. Un objet que l'appelant veut vous faire croire est temporaire.

Si vous avez une référence à un objet qui est un réel temporaire, alors, par définition, vous avez une référence unique à cet objet. Il ne peut être référencé nulle part ailleurs dans votre programme. C'est à dire. this == &temporary n'est pas possible .

Maintenant, si votre client vous a menti et vous a promis d'obtenir un poste temporaire alors que vous ne l'êtes pas, il est de sa responsabilité de vous assurer que vous n'avez pas à vous en soucier. Si vous voulez être vraiment prudent, je pense que ce serait une meilleure implémentation:

Class&
Class::operator=(Class&& other)
{
    assert(this != &other);
    // ...
    return *this;
}

C'est à dire. Si vous êtes passé une référence automatique, il s'agit d'un bogue de la part du client qui doit être corrigé.

Pour être complet, voici un opérateur d’affectation de déplacement pour dumb_array:

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

Dans le cas typique d'une assignation de déplacement, *this Sera un objet déplacé et par conséquent, delete [] mArray; Devrait être un no-op. Il est essentiel que les implémentations fassent de la suppression sur un nullptr le plus rapidement possible.

Caveat:

Certains diront que swap(x, x) est une bonne idée ou tout simplement un mal nécessaire. Et cela, si le swap va au swap par défaut, peut provoquer une assignation de déplacement automatique.

Je ne suis pas d'accord sur le fait que swap(x, x) est jamais une bonne idée. Si cela se trouve dans mon propre code, je le considérerai comme un bogue de performance et le corrigerai. Mais au cas où vous voudriez le permettre, réalisez que swap(x, x) ne fait que self-move-assignemnet sur une valeur déplacée. Et dans notre exemple dumb_array, Cela sera parfaitement inoffensif si nous omettons simplement l'affirmation, ou si nous la limitons au cas déplacé:

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other || mSize == 0);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

Si vous assignez vous-même deux dumb_array Déplacés (vides), vous ne ferez rien de mal en dehors de l'insertion d'instructions inutiles dans votre programme. Cette même observation peut être faite pour la grande majorité des objets.

< Mettre à jour >

J'ai réfléchi davantage à cette question et changé quelque peu ma position. Je pense maintenant que l'affectation doit être tolérante vis-à-vis de l'auto-affectation, mais que les conditions de publication des affectations de copie et de déplacement sont différentes:

Pour l'attribution de copie:

x = y;

il faut post-conditionner que la valeur de y ne soit pas modifiée. Lorsque &x == &y, Cette postcondition se traduit alors par: une affectation de copie automatique ne devrait pas avoir d’impact sur la valeur de x.

Pour l'affectation de déménagement:

x = std::move(y);

il faut post-conditionner que y a un état valide mais non spécifié. Lorsque &x == &y, Cette postcondition se traduit par: x a un état valide mais non spécifié. C'est à dire. L'attribution d'un déménagement individuel ne doit pas nécessairement être un no-op. Mais ça ne devrait pas planter. Cette post-condition est compatible avec le fait de permettre à swap(x, x) de fonctionner:

template <class T>
void
swap(T& x, T& y)
{
    // assume &x == &y
    T tmp(std::move(x));
    // x and y now have a valid but unspecified state
    x = std::move(y);
    // x and y still have a valid but unspecified state
    y = std::move(tmp);
    // x and y have the value of tmp, which is the value they had on entry
}

Ce qui précède fonctionne tant que x = std::move(x) ne plante pas. Il peut laisser x dans n'importe quel état valide mais non spécifié.

Je vois trois façons de programmer l'opérateur d'affectation de déménagement pour dumb_array Pour atteindre cet objectif:

dumb_array& operator=(dumb_array&& other)
{
    delete [] mArray;
    // set *this to a valid state before continuing
    mSize = 0;
    mArray = nullptr;
    // *this is now in a valid state, continue with move assignment
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

L’implémentation ci-dessus tolère l’auto-affectation, mais *this Et other finissent par être un tableau de taille zéro après l’affectation par déplacement automatique, quelle que soit la valeur initiale de *this. . C'est bon.

dumb_array& operator=(dumb_array&& other)
{
    if (this != &other)
    {
        delete [] mArray;
        mSize = other.mSize;
        mArray = other.mArray;
        other.mSize = 0;
        other.mArray = nullptr;
    }
    return *this;
}

L'implémentation ci-dessus tolère l'auto-affectation de la même manière que l'opérateur de l'affectation de copies, en le rendant non-op. C'est aussi bien.

dumb_array& operator=(dumb_array&& other)
{
    swap(other);
    return *this;
}

Ce qui précède n’est ok que si dumb_array Ne contient pas de ressources devant être détruites "immédiatement". Par exemple, si la seule ressource est la mémoire, la procédure ci-dessus convient. Si dumb_array Pouvait éventuellement contenir des verrous mutex ou l'état ouvert des fichiers, le client pouvait raisonnablement s'attendre à ce que ces ressources sur les lhs de l'affectation de déplacement soient immédiatement libérées et, par conséquent, cette implémentation pouvait être problématique.

Le coût de la première est de deux magasins supplémentaires. Le coût de la seconde est un test-and-branch. Les deux fonctionnent. Les deux répondent à toutes les exigences du tableau 22 Exigences MoveAssignable dans la norme C++ 11. Le troisième fonctionne également modulo avec le problème des ressources non-mémoire.

Les trois implémentations peuvent avoir des coûts différents en fonction du matériel: Quel est le coût d'une branche? Y a-t-il beaucoup de registres ou très peu?

Il en résulte que l’affectation automatique, contrairement à l’affectation automatique, ne doit pas nécessairement conserver la valeur actuelle.

</Mise à jour >

Une édition finale (espérons-le) inspirée par le commentaire de Luc Danton:

Si vous écrivez une classe de haut niveau qui ne gère pas directement la mémoire (mais peut avoir des bases ou des membres qui le font), la meilleure implémentation de l'affectation de déplacement est souvent:

Class& operator=(Class&&) = default;

Cela déplacera à tour de rôle chaque base et chaque membre et n'inclura pas de vérification this != &other. Cela vous donnera les performances les plus élevées et la sécurité des exceptions de base en supposant qu'aucun invariant ne doit être maintenu parmi vos bases et vos membres. Pour vos clients exigeant une sécurité forte des exceptions, dirigez-les vers strong_assign.

132
Howard Hinnant

Tout d'abord, vous avez mal interprété la signature de l'opérateur d'affectation de déménagement. Comme les déplacements volent des ressources de l'objet source, la source doit être une référence non -constr-value.

Class &Class::operator=( Class &&rhs ) {
    //...
    return *this;
}

Notez que vous retournez toujours via une référence (non -const) l - value.

Pour l'un ou l'autre type d'affectation directe, la norme ne consiste pas à vérifier l'auto-affectation, mais à s'assurer qu'une auto-affectation ne provoque pas un crash-burn. Généralement, personne n'appelle explicitement les appels x = x Ou y = std::move(y), mais l'aliasing, en particulier via plusieurs fonctions, peut amener a = b Ou c = std::move(d) à s'auto- missions. Une vérification explicite de l’auto-affectation, c’est-à-dire this == &rhs, Qui ignore le contenu de la fonction lorsque vrai, est un moyen de garantir la sécurité de l’auto-affectation. Mais c’est l’un des pires moyens, car il optimise un cas (espérons-le) rare alors qu’il s’agit d’une anti-optimisation pour le cas le plus courant (en raison de la création de branches et éventuellement de l’absence de cache).

Désormais, quand (au moins) l'un des opérandes est un objet directement temporaire, vous ne pouvez jamais avoir de scénario d'auto-affectation. Certaines personnes préconisent d'assumer cette hypothèse et d'optimiser le code, de sorte que le code devient stupide sur le plan suicidaire lorsque l'hypothèse est fausse. Je dis que le dumping de la vérification du même objet sur les utilisateurs est irresponsable. Nous ne présentons pas cet argument pour l'attribution de copies; pourquoi inverser la position pour l'affectation de mouvement?

Faisons un exemple, modifié par un autre répondant:

dumb_array& dumb_array::operator=(const dumb_array& other)
{
    if (mSize != other.mSize)
    {
        delete [] mArray;
        mArray = nullptr;  // clear this...
        mSize = 0u;        // ...and this in case the next line throws
        mArray = other.mSize ? new int[other.mSize] : nullptr;
        mSize = other.mSize;
    }
    std::copy(other.mArray, other.mArray + mSize, mArray);
    return *this;
}

Cette copie-assignation gère les auto-assignations sans problème, sans vérification explicite. Si les tailles source et cible sont différentes, la désallocation et la réallocation précèdent la copie. Sinon, seule la copie est terminée. L'auto-affectation n'obtient pas un chemin optimisé, il est vidé dans le même chemin que lorsque les tailles de source et de destination sont égales. La copie est techniquement inutile lorsque les deux objets sont équivalents (même s’ils sont identiques), mais c’est le prix à payer s’ils ne vérifient pas l’égalité (valeur ou adresse), car cette vérification serait en soi un gaspillage considérable. du temps. Notez que l'attribution automatique d'objet ici entraînera une série d'affectations automatiques au niveau de l'élément; le type d'élément doit être sûr pour cela.

Comme son exemple source, cette copie-assignation fournit la garantie de sécurité d'exception de base. Si vous souhaitez une garantie forte, utilisez l'opérateur d'opération unifiée de la requête d'origine Copie et échange , qui gère à la fois l'affectation de copie et de déplacement. Mais l'objectif de cet exemple est de réduire la sécurité d'un rang pour gagner de la vitesse. (BTW, nous supposons que les valeurs des éléments individuels sont indépendantes; qu'il n'y a pas de contrainte invariante limitant certaines valeurs par rapport à d'autres.)

Regardons une assignation de déménagement pour ce même type:

class dumb_array
{
    //...
    void swap(dumb_array& other) noexcept
    {
        // Just in case we add UDT members later
        using std::swap;

        // both members are built-in types -> never throw
        swap( this->mArray, other.mArray );
        swap( this->mSize, other.mSize );
    }

    dumb_array& operator=(dumb_array&& other) noexcept
    {
        this->swap( other );
        return *this;
    }
    //...
};

void  swap( dumb_array &l, dumb_array &r ) noexcept  { l.swap( r ); }

Un type permutable qui nécessite une personnalisation doit avoir une fonction libre à deux arguments appelée swap dans le même espace de noms que le type. (La restriction d'espace de nom permet aux appels non qualifiés de permuter.) Un type de conteneur doit également ajouter une fonction publique swap member pour correspondre aux conteneurs standard. Si un membre swap n'est pas fourni, alors la fonction libre swap devra probablement être marquée en tant qu'ami du type échangeable. Si vous personnalisez les déplacements pour utiliser swap, vous devez alors fournir votre propre code d'échange. le code standard appelle le code de déplacement du type, ce qui entraînerait une récursion mutuelle infinie pour les types personnalisés.

Comme les destructeurs, les fonctions de swap et les opérations de déplacement doivent être non lancées si possible, et probablement marquées comme telles (en C++ 11). Les types et les routines standard des bibliothèques ont des optimisations pour les types mobiles non jetables.

Cette première version de move-assign remplit le contrat de base. Les marqueurs de ressources de la source sont transférés à l'objet de destination. Les anciennes ressources ne seront pas perdues puisque l'objet source les gère maintenant. Et l'objet source est laissé dans un état utilisable où d'autres opérations, y compris l'affectation et la destruction, peuvent lui être appliquées.

Notez que cette affectation de mouvement est automatiquement sécurisée pour l'auto-affectation, puisque l'appel swap l'est. Il est également fortement sauf exception. Le problème est la rétention inutile de ressources. Conceptuellement, les anciennes ressources de la destination ne sont plus nécessaires, mais ici, elles ne sont toujours présentes que si l'objet source peut rester valide. Si la destruction planifiée de l'objet source est lointaine, nous gaspillons de l'espace sur les ressources ou, pire encore, si l'espace total des ressources est limité et que d'autres requêtes de ressources surviennent avant la mort officielle du (nouvel) objet source.

C'est ce problème qui a motivé le conseil controversé du guru concernant l'auto-ciblage lors de l'affectation d'un déménagement. La façon d'écrire une affectation de déménagement sans ressources en attente est la suivante:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        delete [] this->mArray;  // kill old resources
        this->mArray = other.mArray;
        this->mSize = other.mSize;
        other.mArray = nullptr;  // reset source
        other.mSize = 0u;
        return *this;
    }
    //...
};

La source est réinitialisée aux conditions par défaut, tandis que les anciennes ressources de destination sont détruites. Dans le cas de l'auto-affectation, votre objet actuel finit par se suicider. La solution principale consiste à entourer le code d'action d'un bloc if(this != &other) ou à le visser et à laisser les clients manger une ligne initiale assert(this != &other) (si vous vous sentez bien).

Une autre solution consiste à étudier comment sécuriser fortement les affectations de copie, sans attribution unifiée, et à les appliquer à une affectation de déplacement:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        dumb_array  temp{ std::move(other) };

        this->swap( temp );
        return *this;
    }
    //...
};

Lorsque other et this sont distincts, other est vidé par le déplacement vers temp et reste ainsi. Ensuite, this perd ses anciennes ressources pour passer à temp tout en récupérant les ressources détenues à l'origine par other. Ensuite, les anciennes ressources de this se font tuer quand temp le fait.

Lorsque l'auto-affectation se produit, le vidage de other vers temp vide également this. Ensuite, l'objet cible récupère ses ressources lorsque temp et this s'échangent. La mort de temp réclame un objet vide, qui devrait être pratiquement un no-op. L'objet this/other conserve ses ressources.

L'affectation de mouvement doit être jamais lancée tant que la construction et l'échange de mouvements le sont également. En outre, le coût de la sécurité lors de l’affectation automatique est de quelques instructions supplémentaires par rapport aux types de bas niveau, qui devraient être submergées par l’appel de désallocation.

11
CTMacUser

Je suis dans le camp de ceux qui veulent des opérateurs sûrs auto-assignés, mais ne veulent pas écrire de chèques auto-assignés dans les implémentations de operator=. Et en fait, je ne veux même pas du tout implémenter operator=, Je veux que le comportement par défaut fonctionne "immédiatement". Les meilleurs membres spéciaux sont ceux qui viennent gratuitement.

Cela étant dit, les exigences MoveAssignable présentes dans la norme sont décrites comme suit (à partir de 17.6.3.1 Exigences relatives aux arguments de modèle [utility.arg.requirements], n3290):

 Expression Type de valeur renvoyée Valeur de retour Post-condition 
 T = va, tv est équivalent à la valeur de va avant l'affectation 

où les espaces réservés sont décrits comme suit: "t [est une] lvalue modifiable de type T;" et "rv est une valeur de type T;". Notez qu'il s'agit d'exigences sur les types utilisés en tant qu'arguments des modèles de la bibliothèque Standard, mais si vous regardez ailleurs dans la norme, vous remarquerez que chaque exigence relative à l'affectation de déplacement est similaire à celle-ci.

Cela signifie que a = std::move(a) doit être 'sûr'. Si vous avez besoin d’un test d’identité (par exemple, this != &other), Allez-y, sinon vous ne pourrez même pas placer vos objets dans std::vector! (À moins que vous n'utilisiez pas les membres/opérations nécessitant MoveAssignable; mais ne vous y trompez pas.) Notez qu'avec l'exemple précédent a = std::move(a), alors this == &other Sera effectivement valable.

6
Luc Danton

Pendant que votre fonction actuelle operator= Est écrite, puisque vous avez défini l'argument rvalue-reference const, vous ne pouvez "voler" les pointeurs et changer les valeurs de la référence rvalue entrante. ... vous ne pouvez simplement pas le changer, vous ne pouvez que lire. Je ne verrais un problème que si vous commenciez à appeler delete sur des pointeurs, etc. dans votre objet this comme vous le feriez dans une méthode normale lvaue-reference operator=, Mais ce genre de défaites le point de la version de rvalue ... c'est-à-dire, il semblerait redondant d'utiliser la version de rvalue pour effectuer essentiellement les mêmes opérations normalement laissées à une méthode const - lvalue operator= .

Maintenant, si vous avez défini votre operator= Pour prendre une référence non-valeur const, le seul moyen de vérifier si un contrôle était requis était si vous passiez l'objet this à une fonction qui renvoie intentionnellement une référence rvalue plutôt que temporaire.

Par exemple, supposons que quelqu'un ait essayé d'écrire une fonction operator+ Et utilise un mélange de références rvalue et de références lvalue afin "d'empêcher" la création d'externes temporaires lors d'une opération d'addition empilée sur le type d'objet:

struct A; //defines operator=(A&& rhs) where it will "steal" the pointers
          //of rhs and set the original pointers of rhs to NULL

A&& operator+(A& rhs, A&& lhs)
{
    //...code

    return std::move(rhs);
}

A&& operator+(A&& rhs, A&&lhs)
{
    //...code

    return std::move(rhs);
}

int main()
{
    A a;

    a = (a + A()) + A(); //calls operator=(A&&) with reference bound to a

    //...rest of code
}

Si je comprends bien ce que je comprends des références rvalue, il est déconseillé de faire ce qui précède (c’est-à-dire que vous devriez simplement renvoyer une référence temporaire, et non rvalue), mais si quelqu'un le faisait toujours, vous voudriez vérifier que la référence rvalue entrante ne faisait pas référence au même objet que le pointeur this.

2
Jason

Ma réponse est toujours que l'affectation de déménagement ne doit pas nécessairement être mise en réserve, mais elle a une explication différente. Considérons std :: unique_ptr. Si je devais en implémenter un, je ferais quelque chose comme ceci:

unique_ptr& operator=(unique_ptr&& x) {
  delete ptr_;
  ptr_ = x.ptr_;
  x.ptr_ = nullptr;
  return *this;
}

Si vous regardez Scott Meyers l'expliquant il fait quelque chose de similaire. (Si vous vous égarez, pourquoi ne pas échanger - il y a une écriture en plus). Et ce n'est pas sûr pour l'auto-affectation.

Parfois, c'est malheureux. Envisagez de sortir du vecteur tous les nombres pairs:

src.erase(
  std::partition_copy(src.begin(), src.end(),
                      src.begin(),
                      std::back_inserter(even),
                      [](int num) { return num % 2; }
                      ).first,
  src.end());

Ceci est correct pour les entiers mais je ne pense pas que vous puissiez faire quelque chose comme cela avec la sémantique du déplacement.

Pour conclure: déplacer l’affectation vers l’objet lui-même n’est pas correct et vous devez le surveiller.

Petite mise à jour.

  1. Je ne suis pas d’accord avec Howard, ce qui est une mauvaise idée, mais néanmoins - je pense que l’affectation des objets "déplacés" à un déplacement automatique devrait fonctionner, car swap(x, x) devrait fonctionner. Les algorithmes aiment ces choses! C'est toujours agréable quand un cas d'angle fonctionne. (Et je ne vois pas encore de cas où ce n'est pas gratuit. Cela ne veut pas dire que ça n'existe pas).
  2. Voici comment assigner unique_ptrs est implémenté dans libc ++: unique_ptr& operator=(unique_ptr&& u) noexcept { reset(u.release()); ...} C'est sans danger pour l'affectation par déplacement automatique.
  3. Lignes directrices de base pense qu'il devrait être correct de se déplacer soi-même.
1
Denis Yaroshevskiy

Il y a une situation à laquelle je peux penser (ceci == rhs). Pour cette déclaration: Myclass obj; std :: move (obj) = std :: move (obj)

0
little_monster