Question Qu'est-ce que l'idiome copy-and-swap?


Quel est cet idiome et quand devrait-il être utilisé? Quels problèmes résout-il? Est-ce que l'idiome change quand C ++ 11 est utilisé?

Bien que cela ait été mentionné dans beaucoup d'endroits, nous n'avons pas eu de questions et de réponses, ce qui est le cas. Voici une liste partielle des endroits où il a été mentionné précédemment:


1668
2017-07-19 08:42


origine


Réponses:


Aperçu

Pourquoi avons-nous besoin de l'idiome copy-and-swap?

Toute classe qui gère une ressource (un wrapper, comme un pointeur intelligent) doit mettre en œuvre Les trois grands. Alors que les objectifs et la mise en œuvre du constructeur de copie et du destructeur sont simples, l'opérateur d'attribution de copie est sans doute le plus nuancé et le plus difficile. Comment devrait-il être fait? Quels pièges doivent être évités?

le copier-et-swap idiome est la solution, et assiste élégamment l'opérateur de mission dans la réalisation de deux choses: éviter duplication de codeet fournissant un forte garantie d'exception.

Comment ça marche?

Conceptuellement, il fonctionne en utilisant la fonctionnalité du constructeur de copie pour créer une copie locale des données, puis prend les données copiées avec un swap fonction, en échangeant les anciennes données avec les nouvelles données. La copie temporaire est ensuite détruite, en prenant les anciennes données avec elle. Il nous reste une copie des nouvelles données.

Pour utiliser l'idiome copy-and-swap, nous avons besoin de trois choses: un constructeur de copie de travail, un destructeur de travail (les deux sont la base de tout wrapper, donc devrait être complet de toute façon), et un swap fonction.

Une fonction d'échange est un non-lancer fonction qui permute deux objets d'une classe, membre pour membre. Nous pourrions être tentés d'utiliser std::swap au lieu de fournir les nôtres, mais cela serait impossible; std::swap utilise l'opérateur copy-constructor et copy-assignment dans sa mise en oeuvre, et nous essayerions finalement de définir l'opérateur d'affectation en termes de lui-même!

(Non seulement cela, mais les appels non qualifiés à swap utilisera notre opérateur d'échange personnalisé, en sautant par-dessus la construction inutile et la destruction de notre classe std::swap entraînerait.)


Une explication en profondeur

Le but

Considérons un cas concret. Nous voulons gérer, dans une classe autrement inutile, un tableau dynamique. Nous commençons par un constructeur, un constructeur de copie et un destructeur fonctionnant:

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

Cette classe gère presque le tableau avec succès, mais il a besoin operator= pour fonctionner correctement.

Une solution échouée

Voici comment une implémentation naïve peut ressembler:

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}

Et nous disons que nous sommes finis; cela gère maintenant un tableau, sans fuites. Cependant, il souffre de trois problèmes, marqués séquentiellement dans le code comme (n).

  1. Le premier est le test d'auto-affectation. Cette vérification sert à deux fins: c'est un moyen facile de nous empêcher d'exécuter du code inutile sur l'auto-affectation, et elle nous protège contre les bogues subtils (comme la suppression du tableau uniquement pour essayer de le copier). Mais dans tous les autres cas, cela sert simplement à ralentir le programme et à faire du bruit dans le code; l'auto-affectation se produit rarement, donc la plupart du temps cette vérification est un gaspillage. Il vaudrait mieux que l'opérateur puisse fonctionner correctement sans cela.

  2. Le second est qu'il ne fournit qu'une garantie d'exception de base. Si new int[mSize] échoue, *this aura été modifié. (À savoir, la taille est erronée et les données ont disparu!) Pour une garantie d'exception forte, il devrait être quelque chose de semblable à:

    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // get the new data ready before we replace the old
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // replace the old data (all are non-throwing)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
    
  3. Le code a été étendu! Ce qui nous amène au troisième problème: la duplication de code. Notre opérateur d'assignation duplique effectivement tout le code que nous avons déjà écrit ailleurs, et c'est une chose terrible.

Dans notre cas, le noyau n'est composé que de deux lignes (l'allocation et la copie), mais avec des ressources plus complexes, ce gonflement du code peut être très compliqué. Nous devrions nous efforcer de ne jamais nous répéter.

(On peut se demander: si ce code est nécessaire pour gérer correctement une ressource, que se passe-t-il si ma classe en gère plus d'une? Bien que cela puisse sembler être une préoccupation valable, try/catch clauses, c'est un non-problème. C'est parce qu'une classe devrait gérer une ressource seulement!)

Une solution réussie

Comme mentionné, l'idiome copy-and-swap va résoudre tous ces problèmes. Mais maintenant, nous avons toutes les exigences, sauf une: un swap fonction. Alors que The Rule of Three implique l'existence de notre constructeur de copie, opérateur d'assignation, et destructeur, il devrait vraiment s'appeler "The Big Three and A Half": chaque fois que votre classe gère une ressource, il est également logique de fournir un swap fonction.

Nous devons ajouter la fonctionnalité d'échange à notre classe, et nous le faisons comme suit †:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

(Ici est l'explication pourquoi public friend swap.) Maintenant, non seulement pouvons-nous échanger nos dumb_array, mais les swaps en général peuvent être plus efficaces; il échange simplement les pointeurs et les tailles, plutôt que d'allouer et de copier des tableaux entiers. Mis à part ce bonus de fonctionnalité et d'efficacité, nous sommes maintenant prêts à implémenter l'idiome copy-and-swap.

Sans plus tarder, notre opérateur d'affectation est:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

Et c'est tout! D'un seul coup, les trois problèmes sont traités avec élégance à la fois.

Pourquoi ça marche?

Nous remarquons d'abord un choix important: l'argument paramètre est pris valeur par. Alors que l'on pourrait tout aussi bien faire ce qui suit (et en effet, de nombreuses implémentations naïves de l'idiome faire):

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

Nous perdons un Opportunité d'optimisation importante. Non seulement cela, mais ce choix est critique dans C ++ 11, qui est discuté plus tard. (Sur une note générale, une directive remarquablement utile est la suivante: si vous faites une copie de quelque chose dans une fonction, laissez le compilateur le faire dans la liste des paramètres.)

Quoi qu'il en soit, cette méthode d'obtention de notre ressource est la clé pour éliminer la duplication de code: nous utilisons le code du constructeur de copie pour faire la copie, et nous n'avons jamais besoin de répéter quoi que ce soit. Maintenant que la copie est faite, nous sommes prêts à échanger.

Observez qu'en entrant dans la fonction, toutes les nouvelles données sont déjà attribuées, copiées et prêtes à être utilisées. C'est ce qui nous donne une garantie d'exception forte gratuitement: nous n'entrerons même pas dans la fonction si la construction de la copie échoue, et il n'est donc pas possible de modifier l'état de *this. (Ce que nous avons fait manuellement auparavant pour une garantie d'exception forte, le compilateur fait pour nous maintenant, comme c'est gentil.)

À ce stade, nous sommes à la maison, car swap est non-lancer. Nous échangeons nos données actuelles avec les données copiées, en modifiant en toute sécurité notre état, et les anciennes données sont placées dans le fichier temporaire. Les anciennes données sont ensuite libérées lorsque la fonction retourne. (Lorsque la portée du paramètre se termine et que son destructeur est appelé.)

Parce que l'idiome ne répète aucun code, nous ne pouvons pas introduire de bogues dans l'opérateur. Notez que cela signifie que nous sommes débarrassés de la nécessité d'une vérification de l'auto-affectation, permettant une mise en œuvre unique et uniforme operator=. (En outre, nous n'avons plus de pénalité de performance sur les non-auto-affectations.)

Et c'est l'idiome de copier-et-swap.

Qu'en est-il de C ++ 11?

La prochaine version de C ++, C ++ 11, apporte un changement très important à la façon dont nous gérons les ressources: la Règle des Trois est maintenant La règle des quatre (et demi). Pourquoi? Parce que non seulement nous devons être capables de copier-construire notre ressource, nous devons bouger, le construire aussi.

Heureusement pour nous, c'est facile:

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

Que se passe t-il ici? Rappelez-vous l'objectif de move-construction: prendre les ressources d'une autre instance de la classe, en le laissant dans un état garanti d'être assignable et destructible.

Donc ce que nous avons fait est simple: initialiser via le constructeur par défaut (une fonctionnalité C ++ 11), puis échanger avec other; nous savons qu'une instance construite par défaut de notre classe peut être affectée et détruite en toute sécurité, de sorte que nous savons other sera en mesure de faire de même, après l'échange.

(Notez que certains compilateurs ne supportent pas la délégation de constructeur, dans ce cas, nous devons manuellement construire la classe par défaut, ce qui est une tâche malheureuse mais heureusement triviale.)

Pourquoi cela fonctionne-t-il?

C'est le seul changement que nous devons apporter à notre classe, alors pourquoi cela fonctionne-t-il? Souvenez-vous de la décision importante que nous avons prise de faire du paramètre une valeur et non une référence:

dumb_array& operator=(dumb_array other); // (1)

Maintenant si other est en cours d'initialisation avec une valeur, il sera construit en mouvement. Parfait. De la même manière, C ++ 03 permet de réutiliser notre fonctionnalité de constructeur de copie en prenant l'argument par valeur, C ++ 11 automatiquement choisissez le constructeur de mouvement, le cas échéant. (Et, bien sûr, comme mentionné dans l'article précédemment lié, la copie / déplacement de la valeur peut simplement être élidé complètement.)

Et ainsi conclut l'idiome de copier-et-swap.


Notes de bas de page

* Pourquoi définissons-nous mArray à null? Parce que si un autre code dans l'opérateur jette, le destructeur de dumb_array pourrait être appelé; et si cela se produit sans le mettre à null, nous essayons de supprimer la mémoire qui a déjà été supprimée! Nous évitons cela en le mettant à null, comme la suppression de null est une non-opération.

† Il y a d'autres revendications que nous devrions spécialiser std::swap pour notre type, fournir un en classe swap à côté d'une fonction libre swapMais tout cela est inutile: toute utilisation appropriée de swap sera à travers un appel sans réserve, et notre fonction sera trouvée à travers ADL. Une fonction va faire.

‡ La raison est simple: une fois que vous avez la ressource pour vous, vous pouvez l'échanger et / ou la déplacer (C ++ 11) partout où elle doit être. Et en faisant la copie dans la liste des paramètres, vous maximisez l'optimisation.


1835
2017-07-19 08:43



L'affectation, en son cœur, est en deux étapes: démolir l'ancien état de l'objet et construire son nouvel état comme une copie de l'état d'un autre objet.

Fondamentalement, c'est ce que le destructeur et le constructeur de copie faire, donc la première idée serait de leur déléguer le travail. Cependant, puisque la destruction ne doit pas échouer, alors que la construction pourrait nous voulons vraiment le faire dans l'autre sens: d'abord effectuer la partie constructive et si cela a réussi, alors faites la partie destructrice. L'idiome copy-and-swap est un moyen de faire exactement cela: il appelle d'abord un constructeur de copie de classe pour créer un temporaire, puis échange ses données avec celles du temporaire, puis laisse le destructeur du temporaire détruire l'ancien état.
Depuis swap() est censé ne jamais échouer, la seule partie qui pourrait échouer est la copie-construction. Cela est effectué en premier, et s'il échoue, rien ne sera changé dans l'objet ciblé.

Dans sa forme affinée, copy-and-swap est implémenté en faisant exécuter la copie en initialisant le paramètre (non-référence) de l'opérateur d'affectation:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}

226
2017-07-19 08:55



Il y a déjà de bonnes réponses. Je vais me concentrer principalement sur ce que je pense qu'ils manquent - une explication du "contre" avec l'idiome copier-et-swap ....

Qu'est-ce que l'idiome copy-and-swap?

Une façon de mettre en œuvre l'opérateur d'affectation en termes de fonction d'échange:

X& operator=(X rhs)
{
    swap(rhs);
    return *this;
}

L'idée fondamentale est la suivante:

  • la partie la plus sujette aux erreurs de l'affectation à un objet assure que toutes les ressources dont le nouvel état a besoin sont acquises (par exemple, la mémoire, les descripteurs)

  • cette acquisition peut être tentée avant modifier l'état actuel de l'objet (c.-à-d. *this) si une copie de la nouvelle valeur est faite, c'est pourquoi rhs est accepté en valeur (c'est-à-dire copié) plutôt que par référence

  • échanger l'état de la copie locale rhs et *this est d'habitude relativement facile à faire sans défaillance / exception potentielle, étant donné que la copie locale n'a pas besoin d'un état particulier par la suite (il suffit d'un état pour que le destructeur fonctionne, comme pour un objet déplacé de dans> = C ++ 11)

Quand devrait-il être utilisé? (Quels problèmes résout-il? [/créer]?)

  • Lorsque vous voulez que l'assigné à objecte ne soit pas affecté par une affectation qui lève une exception, en supposant que vous avez ou pouvez écrire un swap avec une forte garantie d'exception, et idéalement celle qui ne peut pas échouer /throw.. †

  • Quand vous voulez un moyen propre, facile à comprendre, robuste pour définir l'opérateur d'affectation en termes de constructeur de copie (plus simple), swap et les fonctions destructeur.

    • L'auto-affectation effectuée en tant que copie-échange évite les cas de bords souvent négligés.

  • Quand une pénalité de performance ou une utilisation momentanément plus élevée de ressources créée en ayant un objet temporaire supplémentaire pendant l'affectation n'est pas importante pour votre application. ⁂

swap lancer: il est généralement possible de permuter de manière fiable les membres de données que les objets suivent par pointeur, mais les membres de données non-pointeurs qui n'ont pas de permutation libre, ou pour lesquels la permutation doit être implémentée comme X tmp = lhs; lhs = rhs; rhs = tmp; et la copie-construction ou l'attribution peut jeter, avoir encore le potentiel d'échouer en laissant certains membres de données échangés et d'autres pas. Ce potentiel s'applique même à C ++ 03 std::stringcomme James commente une autre réponse:

@wilhelmtell: En C ++ 03, il n'y a aucune mention d'exceptions potentiellement lancées par std :: string :: swap (qui est appelée par std :: swap). En C ++ 0x, std :: string :: swap est noexcept et ne doit pas lancer d'exceptions. - James McNellis 22 décembre 10 à 15:24


‡ L'implémentation de l'opérateur d'affectation qui semble raisonnable lors de l'affectation d'un objet distinct peut facilement échouer pour l'auto-affectation. Bien qu'il puisse sembler inimaginable que le code client puisse même tenter une auto-affectation, cela peut se produire relativement facilement lors des opérations sur les conteneurs, avec x = f(x); code où f est (peut-être seulement pour certains #ifdef branches) une macro ala #define f(x) x ou une fonction renvoyant une référence à x, ou même (probablement inefficace mais concis) comme le code x = c1 ? x * 2 : c2 ? x / 2 : x;). Par exemple:

struct X
{
    T* p_;
    size_t size_;
    X& operator=(const X& rhs)
    {
        delete[] p_;  // OUCH!
        p_ = new T[size_ = rhs.size_];
        std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
    }
    ...
};

En auto-affectation, le code ci-dessus supprime x.p_;, points p_ à une région de tas nouvellement allouée, puis tente de lire le non initialisé données (Undefined Behavior), si cela ne fait rien de trop bizarre, copy tente une auto-affectation à chaque «T» juste-détruit!


⁂ L'idiome copy-and-swap peut introduire des inefficacités ou des limitations dues à l'utilisation d'un extra temporaire (lorsque le paramètre de l'opérateur est construit en copie):

struct Client
{
    IP_Address ip_address_;
    int socket_;
    X(const X& rhs)
      : ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
    { }
};

Ici, écrit à la main Client::operator= pourrait vérifier si *this est déjà connecté au même serveur que rhs (peut-être en envoyant un code "reset" si c'est utile), alors que l'approche copy-and-swap invoquera le constructeur de copie qui serait probablement écrit pour ouvrir une connexion socket distincte puis fermer celle d'origine. Non seulement cela pourrait signifier une interaction réseau à distance au lieu d'une simple copie de variable in-process, mais cela pourrait aller à l'encontre des limites du client ou du serveur sur les ressources ou connexions de socket. (Bien sûr, cette classe a une interface assez horrible, mais c'est une autre affaire ;-P).


32
2018-03-06 14:51



Cette réponse est plus comme une addition et une légère modification aux réponses ci-dessus.

Dans certaines versions de Visual Studio (et éventuellement d'autres compilateurs), il y a un bug qui est vraiment ennuyeux et qui n'a pas de sens. Donc, si vous déclarez / définissez votre swap fonctionne comme ceci:

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}

... le compilateur vous criera quand vous appelez le swap fonction:

enter image description here

Cela a quelque chose à voir avec un friend fonction appelée et this objet étant passé en paramètre.


Un moyen de contourner cela est de ne pas utiliser friend mot-clé et redéfinir le swap fonction:

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}

Cette fois, vous pouvez simplement appeler swap et passer other, rendant ainsi le compilateur heureux:

enter image description here


Après tout, vous ne le faites pas avoir besoin utiliser un friend fonction pour échanger 2 objets. Il est tout aussi logique de faire swap une fonction membre qui a un other objet en tant que paramètre.

Vous avez déjà accès à this objet, donc le transmettre en tant que paramètre est techniquement redondant.


19
2017-09-04 04:50



Je voudrais ajouter un mot d'avertissement lorsque vous manipulez des conteneurs compatibles avec allocator de style C ++ 11. L'échange et l'affectation ont des sémantiques subtilement différentes.

Pour le concret, considérons un récipient std::vector<T, A>, où A est un type d'allocateur avec état, et nous allons comparer les fonctions suivantes:

void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{ 
    a.swap(b);
    b.clear(); // not important what you do with b
}

void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
    a = std::move(b);
}

Le but des deux fonctions fs et fm est de donner a l'état que b avait initialement. Cependant, il y a une question cachée: que se passe-t-il si a.get_allocator() != b.get_allocator()? La réponse est: Cela dépend. Écrivons AT = std::allocator_traits<A>.

  • Si AT::propagate_on_container_move_assignment est std::true_type, puis fm réaffecte l'allocateur de a avec la valeur de b.get_allocator()sinon ça ne l'est pas a continue d'utiliser son allocateur d'origine. Dans ce cas, les éléments de données doivent être échangés individuellement, car le stockage de a et b n'est pas compatible.

  • Si AT::propagate_on_container_swap est std::true_type, puis fs échange à la fois les données et les allocateurs de la manière attendue.

  • Si AT::propagate_on_container_swap est std::false_type, alors nous avons besoin d'un contrôle dynamique.

    • Si a.get_allocator() == b.get_allocator(), alors les deux conteneurs utilisent un stockage compatible, et l'échange se déroule comme d'habitude.
    • Toutefois, si a.get_allocator() != b.get_allocator(), le programme a comportement indéfini (cf. [container.requirements.general / 8].

Le résultat est que l'échange est devenu une opération non triviale dans C ++ 11 dès que votre conteneur commence à prendre en charge les allocateurs avec état. C'est un peu un "cas d'utilisation avancé", mais ce n'est pas tout à fait improbable, puisque les optimisations de déplacement ne deviennent généralement intéressantes qu'une fois que votre classe gère une ressource, et la mémoire est l'une des ressources les plus populaires.


10
2018-06-24 08:16