Aperçu
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 The Big Three . Alors que les objectifs et la mise en œuvre du constructeur et du destructeur de copie sont simples, l'opérateur d'affectation de copie est sans doute le plus nuancé et le plus difficile. Comment faut-il procéder? Quels pièges faut-il éviter?
le idiome de copie et d'échange est la solution et aide élégamment l'opérateur d'affectation à réaliser deux choses: éviter la duplication de code et fournir une garantie d'exception forte .
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 une swap
fonction, échangeant les anciennes données avec les nouvelles données. La copie temporaire se détruit ensuite, emportant les anciennes données avec elle. Il nous reste une copie des nouvelles données.
Pour utiliser l'idiome de copie et d'échange, 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 devraient être complets de toute façon) et une swap
fonction.
Une fonction d'échange est un non-lancement 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 le constructeur de copie et l'opérateur d'affectation de copie dans son implémentation, et nous essaierions finalement de définir l'opérateur d'affectation en fonction de lui-même!
(Non seulement cela, mais les appels non qualifiés à swap
utiliseront notre opérateur d'échange personnalisé, ignorant la construction et la destruction inutiles de notre classe quistd::swap
impliqueraient.)
Une explication approfondie
Le but
Prenons un cas concret. Nous voulons gérer, dans une classe par ailleurs inutile, un tableau dynamique. Nous commençons avec un constructeur, un constructeur de copie et un destructeur qui fonctionnent:
#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 elle a besoin operator=
fonctionner correctement.
Une solution ratée
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 avons terminé; cela gère désormais un tableau, sans fuites. Cependant, il souffre de trois problèmes, marqués séquentiellement dans le code comme (n)
.
Le premier est le test d'auto-affectation. Cette vérification sert deux objectifs: c'est un moyen facile de nous empêcher d'exécuter du code inutile lors de l'auto-affectation, et il nous protège des 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 à agir comme du bruit dans le code; l'auto-affectation se produit rarement, donc la plupart du temps ce contrôle est un gaspillage. Il serait préférable que l'opérateur puisse fonctionner correctement sans lui.
Le second est qu'il ne fournit qu'une garantie d'exception de base. En cas d' new int[mSize]
échec, *this
aura été modifié. (À savoir, la taille est incorrecte et les données ont disparu!) Pour une garantie d'exception forte, il faudrait que cela ressemble à:
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;
}
Le code s'est étendu! Ce qui nous amène au troisième problème: la duplication de code. Notre opérateur d'affectation 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 n'est que de deux lignes (l'allocation et la copie), mais avec des ressources plus complexes, ce ballonnement de code peut être assez compliqué. Nous devons nous efforcer de ne jamais nous répéter.
(On pourrait se demander: si autant de 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 cela nécessite des clauses try
/ non triviales catch
, c'est un non C'est parce qu'une classe ne doit gérer qu'une seule ressource !)
Une solution réussie
Comme mentionné, l'idiome de copie et d'échange résoudra tous ces problèmes. Mais en ce moment, nous avons toutes les exigences sauf une: une swap
fonction. Bien que la règle des trois implique avec succès l'existence de notre constructeur de copie, opérateur d'affectation et destructeur, elle devrait vraiment s'appeler "Les trois grands et demi": chaque fois que votre classe gère une ressource, il est également logique de fournir une swap
fonction .
Nous devons ajouter des fonctionnalités de swap à 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);
}
// ...
};
( Voici l'explication pourquoi public friend swap
.) Maintenant, non seulement nous pouvons échanger les nôtres dumb_array
, mais les échanges en général peuvent être plus efficaces; il échange simplement des pointeurs et des 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 de copie et d'échange.
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 résolus avec élégance à la fois.
Pourquoi ça marche?
On remarque d'abord un choix important: l'argument paramètre est pris par valeur . Alors que l'on pourrait tout aussi facilement 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 une importante opportunité d'optimisation . Non seulement cela, mais ce choix est critique en C ++ 11, qui est discuté plus tard. (D'une manière générale, une directive remarquablement utile est la suivante: si vous allez faire 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 pouvons utiliser le code du constructeur de copie pour faire la copie, et nous n'avons jamais besoin de répéter la moindre partie. 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à allouées, copiées et prêtes à être utilisées. C'est ce qui nous donne une forte garantie d'exception gratuite: 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 le fait pour nous maintenant; quelle gentillesse.)
À ce stade, nous sommes sans domicile, car il swap
ne lance pas. Nous échangeons nos données actuelles avec les données copiées, modifiant en toute sécurité notre état, et les anciennes données sont mises dans le temporaire. Les anciennes données sont ensuite libérées lorsque la fonction revient. (Où se termine la portée du paramètre et où 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'un contrôle d'auto-affectation, permettant une seule mise en œuvre uniforme deoperator=
. (De plus, nous n'avons plus de pénalité de performance sur les non-auto-affectations.)
Et c'est l'idiome de copie et d'échange.
Et 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 de trois est désormais la règle de quatre (et demi). Pourquoi? Parce que non seulement nous devons être capables de copier-construire notre ressource, nous devons aussi la déplacer-construire .
Heureusement pour nous, c'est facile:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Que se passe t-il ici? Rappelez-vous l'objectif de la construction de mouvements: prendre les ressources d'une autre instance de la classe, en la laissant dans un état garanti affectable 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é, nous savons other
donc qu'elle pourra faire de même après l'échange.
(Notez que certains compilateurs ne prennent pas en charge la délégation de constructeur; dans ce cas, nous devons manuellement construire par défaut la classe. C'est une tâche malheureuse mais heureusement triviale.)
Pourquoi ça marche?
C'est le seul changement que nous devons apporter à notre classe, alors pourquoi ça marche? Rappelez-vous la décision toujours importante que nous avons prise pour faire 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 valeur r, il sera construit par déplacement . Parfait. De la même manière que C ++ 03 réutilisons notre fonctionnalité de constructeur de copie en prenant la valeur d'argument, C ++ 11 choisira automatiquement le constructeur de déplacement le cas échéant. (Et, bien sûr, comme mentionné dans un article précédemment lié, la copie / le déplacement de la valeur peut simplement être complètement évité.)
Et ainsi conclut l'idiome de copie et d'échange.
Notes de bas de page
* Pourquoi définissons-nous la valeur mArray
null? Parce que si un autre code de l'opérateur est lancé, 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 définissant sur null, car la suppression de null est une non-opération.
† Il y a d' autres demandes que nous devons spécialiser std::swap
pour notre type, fournir une en classe le swap
long de côté une zone de libre-fonction swap
, etc. Mais tout cela est inutile: toute utilisation appropriée de swap
se fera par un appel non qualifié, et notre fonction sera trouvé via ADL . Une fonction fera l'affaire.
‡ 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 optimisez l'optimisation.
†† Le constructeur de déplacement doit généralement l'être noexcept
, sinon un code (par exemple, une std::vector
logique de redimensionnement) utilisera le constructeur de copie même lorsqu'un déplacement est logique. Bien sûr, ne le marquez pas sauf si le code qu'il contient ne lève pas d'exceptions.