Quel est cet idiome et quand doit-il être utilisé? Quels problèmes résout-il? L'idiome change-t-il lorsque C ++ 11 est utilisé?
Bien que cela ait été mentionné à de nombreux endroits, nous n'avions pas de question et de réponse «qu'est-ce que c'est», alors voici. Voici une liste partielle des endroits où il a été mentionné précédemment:
- Quels sont vos idiomes de style de codage C ++ préférés: Copy-swap
- Copie du constructeur et = surcharge de l'opérateur en C ++: une fonction commune est-elle possible?
- Qu'est-ce que la copie d'élision et comment elle optimise l'idiome de copie et d'échange
- C ++: allouer dynamiquement un tableau d'objets?
Réponses:
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:
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:
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 à: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 trivialescatch
, 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 uneswap
fonction .Nous devons ajouter des fonctionnalités de swap à notre classe, et nous le faisons comme suit †:
( Voici l'explication pourquoi
public friend swap
.) Maintenant, non seulement nous pouvons échanger les nôtresdumb_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:
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):
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 de
operator=
. (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:
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 savonsother
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:
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 dedumb_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 leswap
long de côté une zone de libre-fonctionswap
, etc. Mais tout cela est inutile: toute utilisation appropriée deswap
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, unestd::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.la source
swap
être trouvé lors de l'ADL si vous voulez qu'il fonctionne dans la plupart des codes génériques que vous rencontrerez, commeboost::swap
et dans diverses autres instances de swap. Le swap est un problème délicat en C ++, et en général, nous sommes tous tombés d'accord pour dire qu'un seul point d'accès est le meilleur (pour la cohérence), et la seule façon de le faire en général est une fonction libre (int
ne peut pas avoir de membre swap, par exemple). Voir ma question pour un peu de contexte.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 font le destructeur et le constructeur de copie , donc la première idée serait de leur déléguer le travail. Cependant, étant donné que la destruction ne doit pas échouer, alors que la construction pourrait le faire, nous voulons en fait procéder dans l'autre sens : effectuer d'abord la partie constructive et, si cela réussit, faire la partie destructrice . L'idiome de copie et d'échange est un moyen de faire exactement cela: il appelle d'abord le constructeur de copie d'une classe pour créer un objet temporaire, puis échange ses données avec le 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 modifié dans l'objet ciblé.Dans sa forme raffinée, la copie et l'échange sont implémentés en faisant effectuer la copie en initialisant le paramètre (non référence) de l'opérateur d'affectation:
la source
std::swap(this_string, that)
ne fournit pas de garantie anti-projection. Il offre une forte sécurité d'exception, mais pas une garantie anti-projection.std::string::swap
(qui est appelée parstd::swap
). En C ++ 0x,std::string::swap
estnoexcept
et ne doit pas lever d'exceptions.std::array
...)Il y a déjà de bonnes réponses. Je vais me concentrer principalement sur ce qui, selon moi, leur manque - une explication des «inconvénients» avec l'idiome de copie et d'échange ...
Une façon d'implémenter l'opérateur d'affectation en termes de fonction de swap:
L'idée fondamentale est que:
la partie la plus sujette aux erreurs de l'affectation à un objet consiste à 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 de modifier l'état actuel de l'objet (c.-à-d.
*this
) si une copie de la nouvelle valeur est effectuée, c'est pourquoi ellerhs
est acceptée par valeur (c.-à-d. copiée) plutôt que par référencepermuter l'état de la copie locale
rhs
et*this
est généralement relativement facile à faire sans échec / exceptions potentiels, étant donné que la copie locale n'a pas besoin d'un état particulier par la suite (a juste besoin d'un état approprié pour que le destructeur s'exécute, tout comme pour un objet déplacé de dans> = C ++ 11)Lorsque vous souhaitez que l'objet affecté ne soit pas affecté par une affectation qui lève une exception, en supposant que vous avez ou pouvez écrire une
swap
garantie d'exception forte, et idéalement une qui ne peut pas échouer /throw
.. †Lorsque vous voulez une façon claire, facile à comprendre et robuste de définir l'opérateur d'affectation en termes de constructeur de copie (plus simple)
swap
et de fonctions de destructeur.†
swap
lancer: il est généralement possible d'échanger de manière fiable des membres de données que les objets suivent par un pointeur, mais des membres de données non pointeurs qui n'ont pas un échange sans projection, ou pour lesquels l'échange doit être implémenté en tantX tmp = lhs; lhs = rhs; rhs = tmp;
que copie-construction ou affectation peuvent jeter, ont toujours le potentiel d'échouer en laissant certains membres de données échangés et d'autres non. Ce potentiel s'applique même aux C ++ 03std::string
comme James commente une autre réponse:‡ L'implémentation de l'opérateur d'affectation qui semble raisonnable lors de l'attribution à partir d'un objet distinct peut facilement échouer pour l'auto-affectation. Bien qu'il puisse sembler inimaginable que le code client tente même de s'auto-affecter, cela peut se produire relativement facilement pendant les opérations algo sur les conteneurs, avec du
x = f(x);
code oùf
est (peut-être seulement pour certaines#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 du codex = c1 ? x * 2 : c2 ? x / 2 : x;
). Par exemple:Lors de l'auto-affectation, les suppressions de code ci-dessus
x.p_;
pointentp_
vers une région de tas nouvellement allouée, puis tentent de lire les données non initialisées qui s'y trouvent (Comportement non défini), si cela ne fait rien de trop bizarre,copy
tente une auto-affectation à chaque 'T' détruit!⁂ L'idiome de copie et d'échange peut introduire des inefficacités ou des limitations en raison de l'utilisation d'un temporaire supplémentaire (lorsque le paramètre de l'opérateur est construit en copie):
Ici, une écriture manuscrite
Client::operator=
pourrait vérifier si elle*this
est déjà connectée au même serveur querhs
(peut-être en envoyant un code "reset" si utile), tandis que l'approche copier-et-échanger invoquerait le constructeur de copie qui serait probablement écrit pour s'ouvrir une connexion socket distincte puis fermez l'original. Non seulement cela pourrait signifier une interaction réseau à distance au lieu d'une simple copie de variable en cours de traitement, mais cela pourrait également 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 affaire ;-P).la source
Client
est que l'affectation n'est pas interdite.Cette réponse ressemble plus à un ajout et à une légère modification des réponses ci-dessus.
Dans certaines versions de Visual Studio (et éventuellement d'autres compilateurs), il y a un bogue qui est vraiment ennuyeux et n'a pas de sens. Donc, si vous déclarez / définissez votre
swap
fonction comme ceci:... le compilateur vous crie dessus lorsque vous appelez la
swap
fonction:Cela a quelque chose à voir avec l'
friend
appel d' une fonction et lathis
transmission d'un objet en tant que paramètre.Un moyen de contourner cela est de ne pas utiliser de
friend
mot-clé et de redéfinir laswap
fonction:Cette fois, vous pouvez simplement appeler
swap
et passerother
, rendant ainsi le compilateur heureux:Après tout, vous n'avez pas besoin d'utiliser une
friend
fonction pour échanger 2 objets. Il est tout aussi logique de créerswap
une fonction membre ayant unother
objet comme paramètre.Vous avez déjà accès à l'
this
objet, donc le transmettre en tant que paramètre est techniquement redondant.la source
friend
fonction est appelée avec un*this
paramètreJe voudrais ajouter un mot d'avertissement lorsque vous traitez avec des conteneurs compatibles avec l'allocateur de style C ++ 11. L'échange et l'affectation ont une sémantique subtilement différente.
Pour être concret, considérons un conteneur
std::vector<T, A>
, où seA
trouve un type d'allocateur avec état, et nous comparerons les fonctions suivantes:Le but des deux fonctions
fs
etfm
est de donnera
l'état quib
avait initialement. Cependant, il y a une question cachée: que se passe-t-il sia.get_allocator() != b.get_allocator()
? La réponse est: cela dépend. ÉcrivonsAT = std::allocator_traits<A>
.Si
AT::propagate_on_container_move_assignment
eststd::true_type
,fm
réaffecte alors l'allocateur dea
avec la valeur deb.get_allocator()
, sinon il ne le fait pas eta
continue d'utiliser son allocateur d'origine. Dans ce cas, les éléments de données doivent être échangés individuellement, car le stockage dea
etb
n'est pas compatible.Si
AT::propagate_on_container_swap
c'est le casstd::true_type
,fs
échange les données et les allocateurs de la manière attendue.Si
AT::propagate_on_container_swap
c'est le casstd::false_type
, nous avons besoin d'une vérification dynamique.a.get_allocator() == b.get_allocator()
, alors les deux conteneurs utilisent un stockage compatible et l'échange se déroule de la manière habituelle.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 en C ++ 11 dès que votre conteneur commence à prendre en charge les allocateurs avec état. C'est un cas d'utilisation quelque peu avancé, mais ce n'est pas tout à fait improbable, car 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.
la source