Qu'est-ce que l'idiome "copier-échanger"?

Quel est cet idiome et quand doit-il être utilisé? Les problèmes qui permet-il de résoudre? Est-ce que l'idiome change lorsque C++11 est utilisé?

bien que cela ait été mentionné dans de nombreux endroits, nous n'avions pas de question et de réponse singulière "qu'est-ce que c'est?", alors voilà. Voici une liste partielle des endroits où il a été précédemment mentionné:

1709
demandé sur Community 2010-07-19 12:42:09

5 réponses

vue d'ensemble

Pourquoi avons-nous besoin de l'idiome de copie et d'échange?

toute classe qui gère une ressource (un wrapper , comme un pointeur intelligent) doit implémenter les trois grands . Alors que les objectifs et la mise en œuvre du copy-constructor et du destructor sont simples, l'opérateur copy-assignment est sans doute le plus nuancé et le plus difficile. Comment devrait-il être fait? Quels pièges doivent être éviter?

le langage de copie et d'échange est la solution, et aide élégamment l'opérateur de tâche à réaliser deux choses: éviter la duplication de code , et fournir une forte garantie d'exception .

comment ça marche?

conceptuellement , il fonctionne en utilisant la fonctionnalité de la copie-constructeur pour créer un local copie des données, puis prend les données copiées avec un swap de la fonction, échangeant les anciennes données par les nouvelles données. La copie temporaire puis détruit, en prenant les anciennes données. Il nous reste une copie des nouvelles données.

pour utiliser l'idiome de copie-et-échange, nous avons besoin de trois choses: une copie de travail-constructeur, un destructeur de travail (les deux sont la base de n'importe quel wrapper, donc devrait être complet de toute façon), et une fonction swap .

Un swap la fonction est une fonction qui change deux objets d'une classe, membre pour Membre. Nous pourrions être tentés d'utiliser std::swap au lieu de fournir le nôtre, mais cela serait impossible; std::swap utilise le copy-constructor et l'opérateur copy-assignment dans le cadre de sa mise en œuvre, et nous essayerions finalement de définir l'opérateur assignment en termes de lui-même!

(non seulement cela, mais des appels sans réserve à swap opérateur de swap personnalisé, en sautant sur la construction inutile et la destruction de notre classe que std::swap entraînerait.)


une explication en profondeur

le but

considérons un cas concret. Nous voulons gérer autrement inutile de classe, un tableau dynamique. Nous commençons par un constructeur, Un copy-constructor et un destructeur:

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

This classe presque gère le tableau avec succès, mais il a besoin de "1519140920 pour fonctionner correctement.

l'échec d'Une solution

voici à quoi pourrait ressembler une implémentation naïve:

// 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 réseau, 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-assignation. Cette vérification sert à deux fins: c'est un moyen facile de nous empêcher d'exécuter du code inutile sur l'auto-assignation, et elle nous protège des bogues subtils (comme supprimer le tableau uniquement pour essayer de le copier). Mais dans tous les autres cas, il sert simplement à ralentir le programme, et agir comme bruit dans le code; auto-assignation se produit rarement, donc la plupart du temps cette vérification est un gaspillage. Il serait préférable que l'opérateur puisse travailler correctement sans elle.

  2. la seconde est qu'elle ne fournit qu'une garantie d'exception de base. Si new int[mSize] échoue, *this aura été modifié. (À savoir, la taille est fausse et les données ont disparu!) Pour une garantie d'exception forte, il faudrait qu'elle s'apparente à:

    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é élargi! Ce qui nous amène au troisième problème: la duplication de code. Notre opérateur de mission duplique efficacement tout le code que nous avons déjà écrit ailleurs, et c'est une chose terrible.

dans notre cas, le cœur de celui-ci est seulement deux lignes (l'allocation et la copie), mais avec des ressources plus complexes ce code bloat peut être tout à fait un tracas. Nous devrions nous efforcer de ne jamais nous répéter.

(On peut se demander: si ce code est nécessaire pour gérer une ressource correctement, et si ma classe gère plus d'un? Bien que cela puisse sembler être une préoccupation valable, et en effet, il exige des clauses non triviales try / catch , il s'agit d'une non-question. C'est parce qu'une classe devrait gérer une seule ressource !)

une solution réussie

comme mentionné, l'idiome de copie-et-échange réglera tous ces problèmes. Mais pour l'instant, nous avons toutes les exigences sauf une: une fonction swap . Alors que la règle des trois implique avec succès l'existence de notre copy-constructor, assignment operator, et destructor, 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 une fonction swap .

Nous avons besoin d'ajouter la fonctionnalité de démontage à 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 de pourquoi public friend swap .) Non seulement pouvons-nous échanger nos dumb_array 's, mais les échanges en général peut être plus efficace; il échange simplement des pointeurs et des tailles, plutôt que d'allouer et de copier des tableaux entiers. En plus de ce bonus de fonctionnalité et d'efficacité, nous sommes maintenant prêts à mettre en œuvre l'idiome copie-échange.

sans plus attendre, notre opérateur d'affectation est:

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

    return *this;
}

Et c'est tout! Avec un seul coup, tous les trois problèmes sont élégamment abordé à la fois.

pourquoi ça marche?

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

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

    return *this;
}

Nous perdons un important de l'optimisation des chances . Non seulement cela, mais ce choix est critique en C++11, qui est discuté plus tard. (Sur une note générale, une ligne directrice remarquablement utile est la suivante: si vous allez faire une copie de quelque chose dans une fonction, laisser le compilateur 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 obtenons d'utiliser le code du copy-constructor pour faire la copie, et n'avons jamais besoin de répéter un peu de celui-ci. Maintenant que la copie est faite, nous sommes prêts à échanger.

Observer qu'en entrant dans la fonction que toutes les nouvelles données sont déjà attribués, copié, et prêt à être utilisé. C'est ce qui nous donne une forte garantie d'exception gratuite: nous n'entrerons même pas 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 forte garantie d'exception, le compilateur le fait pour nous maintenant; comment aimable.)

à ce point nous sommes à la maison libre, parce que swap est non-lanceur. Nous échangeons nos données actuelles avec les données copiées, en toute sécurité changer notre état, et les vieilles données sont mises dans le temporaire. Les anciennes données sont ensuite libérés lorsque la fonction retourne. (Où sur la portée du paramètre se termine et 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 d'auto-assignation, permettant une mise en œuvre uniforme unique de operator= . (En outre, nous n'avons plus les performances sur non-auto-affectations.)

et c'est l'idiome de la copie et de l'échange.

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

la prochaine version de C++, C++11, apporte un changement très important à la gestion des ressources: la règle des trois est maintenant la règle des quatre (et demi). Pourquoi? Parce que non seulement nous devons être en mesure de copier-construire notre ressource, nous devons aller au-construire ainsi .

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? Rappelons le but de move-construction: prendre les ressources d'une autre instance de la classe, la laissant dans un État garanti pour ê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 assignée en toute sécurité et détruit, donc nous savons que other sera capable de faire la même chose, après l'échange.

(notez que certains compilateurs ne prennent pas en charge la délégation du constructeur; dans ce cas, nous devons construire manuellement la classe par défaut. C'est regrettable, mais heureusement tâche triviale.)

pourquoi ça marche?

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

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

Maintenant, si other est initialisé avec une rvalue, il sera construit . Parfait. De la même manière C++03 permet de réutiliser notre fonctionnalité de copy-constructor en prenant l'argument by-value, C++11 va automatiquement choisir le move-constructor quand approprié aussi bien. (Et, bien sûr, comme mentionné dans l'article lié la copie / le déplacement de la valeur peut tout simplement être effacé.)

et conclut ainsi l'idiome de copie et d'échange.


notes

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

†il y a d'autres revendications que nous devrions spécialiser std::swap pour notre type, fournir un dans la classe swap le long d'une fonction libre swap , etc. Mais cela n'est pas nécessaire: toute utilisation correcte de swap sera par un appel sans réserve, et notre fonction sera trouvée par ADL . Une fonction ne se.

‡la raison est simple: une fois que vous avoir la ressource pour vous - même, vous pouvez l'échanger et/ou la déplacer (C++11) n'importe où il doit être. Et en faire la copie dans la liste des paramètres, vous maximisez l'optimisation.

1874
répondu GManNickG 2017-05-23 12:10:54

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

au fond, qu'est ce que la destructeur et le constructeur de copie faire, de sorte que la première idée serait de déléguer le travail. Cependant, puisque la destruction ne doit pas échouer, tandis que la construction pourrait, nous voulons en fait le faire dans l'autre sens : d'abord effectuer la partie constructive et si cela a réussi, puis faire la partie destructive . L'idiome copie-et-échange est une façon de faire cela: il appelle d'abord un constructeur de copie de classe pour créer un temporaire, puis échange ses données avec les temporaires, et puis laisse le destructeur du temporaire détruire l'ancien état.

Puisque swap() est supposé ne jamais échouer, la seule partie qui pourrait échouer est la copie-construction. C'est d'abord effectué, et si elle échoue, rien ne sera changé dans l'objet cible.

sous sa forme raffinée, copy-and-swap est implémentée en faisant effectuer la copie en initialisant le paramètre (non-référence) de l'opérateur de cession:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}
234
répondu sbi 2010-12-22 14:26:50

il y a déjà de bonnes réponses. Je vais concentrer principalement sur ce qui leur manque - une explication des "contre" avec l'idiome de la copie et de l'échange....

Qu'est-ce que l'idiome de la copie et de l'échange?

Une façon de mettre de l'opérateur d'affectation en termes d'un swap de la fonction:

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

L'idée fondamentale est que:

  • la partie la plus sujette à erreur de l'assignation à un objet est de s'assurer 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 modifiant l'état actuel de l'objet (i.e. *this ) si une copie de la nouvelle valeur est faite, ce qui est pourquoi rhs est accepté par la valeur (i.e. copié) plutôt que par référence

  • changement de l'état de la copie locale rhs et *this est habituellement relativement facile à faire sans échec potentiel/exceptions, compte tenu de la copie locale n'a pas besoin d'un état en particulier à la suite (faut juste adapter l'etat pour le destructeur à terme, comme pour un objet d'être déplacé en >= C++11)

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

  • "lorsque vous voulez que la assignée-à objecté non affecté par une tâche qui jette une exception, en supposant que vous avez ou pouvez écrire un swap avec forte garantie d'exception, et idéalement un qui ne peut pas échouer/ throw ..†

  • quand vous vous voulez une façon propre, facile à comprendre et robuste de définir l'opérateur d'affectation en termes de "copy constructor" (plus simple), swap et de fonctions de destructeur.

    • l'Auto-attribution de fait comme une copie-et-swap évite souvent négligé, des cas limites.‡

  • Lorsqu'une pénalité de rendement ou une utilisation de ressources momentanément plus élevée créée par le fait d'avoir un objet temporaire supplémentaire pendant la cession est ce n'est pas important pour votre demande.

swap lancer: il est généralement possible d'échanger de manière fiable les membres de données que les objets tracent par pointeur, mais les membres de données non pointeurs qui n'ont pas de swap libre, ou pour lesquels le swap doit être implémenté comme X tmp = lhs; lhs = rhs; rhs = tmp; et la construction de copie ou la tâche peut lancer, ont toujours le potentiel de échouer laissant certains membres de données échangés et d'autres non. Ce potentiel s'applique même à C++03 std::string 's comme 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é par std:: swap). Dans C++0x, std:: string:: swap est noexcept et ne doit pas jeter d'exceptions. - James McNellis déc 22 ' 10 à 15: 24


‡ mise en œuvre de l'opérateur d'affectation qui semble saine lorsque l'affectation d'un objet distincts peut facilement échouer pour de l'auto-attribution. Bien qu'il puisse sembler inimaginable que le code client tente même l'auto-assignation, il peut se produire relativement facilement pendant les opérations d'algo sur les conteneurs, avec le code x = f(x);f est (peut-être seulement pour quelques #ifdef branches) une macro ala #define f(x) x ou une fonction retournant une référence à x , ou même (probablement inefficace mais concis code comme x = c1 ? x * 2 : c2 ? x / 2 : x; ). 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_);
    }
    ...
};

sur l'auto-assignation , le code ci-dessus supprimer x.p_; , points p_ à une région nouvellement attribuée tas, puis tente de lire les données Uninitialized là-dedans (comportement non défini), si cela ne fait rien de trop bizarre, copy tente une auto-assignation à chaque 'T'juste-détruit!


⁂ l'idiome de copie-et-échange peut introduire des inefficacités ou limitations dues à l'utilisation d'un supplément temporaire (lorsque l'opérateur paramètre est exemplaire construit):

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

ici, un Client::operator= écrit à la main pourrait vérifier si *this est déjà connecté au même serveur que rhs (peut-être en envoyant un code de "réinitialisation" si nécessaire), alors que l'approche de copie-et-échange invoquerait le constructeur de copie qui serait probablement écrit pour ouvrir une connexion de socket distincte puis fermer l'original. Non seulement pourrait-il que signifie une interaction réseau à distance au lieu d'une simple copie variable en cours de traitement, elle pourrait aller à l'encontre des limites du client ou du serveur sur les ressources ou les connexions de socket. (Bien sûr cette classe a une interface assez horrible, mais c'est une autre question ;-P).

33
répondu Tony Delroy 2014-08-07 06:46:09

cette réponse est plus comme un ajout et une légère modification aux réponses ci-dessus.

dans certaines versions de Visual Studio (et peut-être 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 fonction swap comme ceci:

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

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

}

... le compilateur vous hurlera dessus quand vous appellerez la fonction swap :

enter image description here

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


une façon de contourner cela est de ne pas utiliser friend mot-clé et de redéfinir la 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 dans other , rendant ainsi le compilateur heureux:

enter image description here


après tout, vous n'avez pas besoin d'utiliser une fonction friend pour échanger 2 objets. Il est tout aussi logique de faire swap une fonction membre qui a un objet other comme paramètre.

vous avez déjà accès à l'objet this , donc le passer en paramètre est techniquement redondant.

20
répondu Oleksiy 2013-09-04 05:34:49

je voudrais ajouter un mot d'avertissement lorsque vous traitez avec C++11-style allocateur-connaissance des conteneurs. Les échanges et les affectations ont une sémantique subtilement différente.

pour concretess, considérons un conteneur std::vector<T, A> , où A est un type d'allocateur stateful, et nous comparerons 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 le précisez 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: Ça dépend. Ecrivons AT = std::allocator_traits<A> .

  • si AT::propagate_on_container_move_assignment est std::true_type , alors fm réattribue l'allocateur de a avec la valeur de b.get_allocator() , sinon il ne le fait pas, et a continue à utiliser son allocateur original. Dans ce cas, les éléments de données doivent être remplacées individuellement, depuis le stockage de a et b n'est pas compatible.

  • si AT::propagate_on_container_swap est std::true_type , puis fs échange les données et les allocateurs de la façon prévue.

  • 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 produit dans la manière habituelle.
    • cependant, si a.get_allocator() != b.get_allocator() , le programme a comportement non défini (cf. [conteneur.exigence.général/8].

le résultat est que l'échange est devenu une opération non triviale en C++11 dès que votre conteneur commence à supporter des allocateurs stateful. C'est un "cas d'utilisation un peu avancé", mais ce n'est pas tout à fait improbable, puisque les optimisations de mouvements devenir intéressant une fois que votre classe gère une ressource, et la mémoire est l'une des ressources les plus populaires.

10
répondu Kerrek SB 2014-06-24 08:16:06