three - move semantic c++>




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

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:


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 implémenter The Big Three . 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?

L' idiome copy-and-swap est la solution, et assiste élégamment l'opérateur d'assignation dans la réalisation de deux choses: éviter la duplication de code , et fournir une garantie d'exception forte .

Comment ça marche?

Conceptually , cela 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 une fonction d'échange, 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 complet) et une fonction d' swap .

Une fonction de swap est une fonction non-throw qui permute deux objets d'une classe, member for member. 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 à l' swap utiliseront notre opérateur d'échange personnalisé, en ignorant la construction et la destruction inutiles de notre classe que std::swap impliquerait.)

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 faut que operator= fonctionne 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 valide, et en effet elle nécessite des clauses try / catch non triviales un non-problème, car une classe devrait gérer une seule ressource !)

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: une fonction d' swap . Bien 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);
    }

    // ...
};

( Here 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 en valeur . 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 une 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 d'en modifier l'état. (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 sans domicile, parce que l' 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 d'auto-affectation, permettant une implémentation uniforme unique de l' 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 aussi la déplacer-la construire .

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é, donc nous savons que d' other pourront 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 un other est initialisé avec une valeur, il sera construit en mouvement . Parfait. De la même manière que C ++ 03 permet de réutiliser notre fonctionnalité de constructeur de copie en prenant la valeur de l'argument, C ++ 11 choisira automatiquement le constructeur de déplacement, 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 code supplémentaire dans l'opérateur se lève, 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 swap classe le long d'un swap fonction libre, etc. Mais tout cela est inutile: toute utilisation correcte de swap se fera par un non qualifié appel, et notre fonction sera trouvée via 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.


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 fonction d' 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 criera quand vous appelez la fonction d' swap :

Cela a quelque chose à voir avec l'appel d'une fonction friend et la transmission de this objet en paramètre.

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

void swap(A& other) {

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

}

Cette fois, vous pouvez simplement appeler swap et passer à other chose, ce qui rend le compilateur heureux:

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 other objet en paramètre.

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


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 conteneur std::vector<T, A> , où A est un type d'allocateur avec état, 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 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 , alors fm réaffecte l'allocateur de a avec la valeur de b.get_allocator() , sinon il ne le fait pas, et 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 , alors fs échange les données et les allocateurs comme prévu.

  • Si AT::propagate_on_container_swap est std::false_type , nous avons besoin d'une vérification dynamique.

    • Si a.get_allocator() == b.get_allocator() , les deux conteneurs utilisent un stockage compatible, et l'échange se déroule normalement.
    • Cependant, si a.get_allocator() != b.get_allocator() , le programme a un 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ée", 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 que la mémoire est l'une des ressources les plus populaires.


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 font, donc la première idée serait de leur déléguer le travail. Cependant, comme la destruction ne doit pas échouer, alors que la construction pourrait le faire, nous voulons en fait faire l'inverse : d' abord effectuer la partie constructive et si cela réussit, alors faire 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.
Puisque swap() est supposé n'échouer jamais, la seule partie qui pourrait échouer est la copy-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;
}






copy-and-swap