Vraag Wat is het idioom van het kopiëren en ruilen?


Wat is dit idioom en wanneer moet het worden gebruikt? Welke problemen lost het op? Verandert het idioom wanneer C ++ 11 wordt gebruikt?

Hoewel het op veel plaatsen is genoemd, hadden we geen enkele "wat is het" vraag en antwoord, dus hier is het. Hier is een gedeeltelijke lijst met plaatsen waar het eerder werd genoemd:


1668
2017-07-19 08:42


oorsprong


antwoorden:


Overzicht

Waarom hebben we het copy-and-swap-idioom nodig?

Elke klasse die een bron beheert (a wikkel, zoals een slimme aanwijzer) moet implementeren The Big Three. Hoewel de doelen en implementatie van de copy-constructor en destructor duidelijk zijn, is de exploitant van de copy-assignment misschien wel de meest genuanceerde en moeilijkste. Hoe zou het moeten worden gedaan? Welke valkuilen moeten worden vermeden?

De idiom kopiëren en uitwisselen is de oplossing en helpt de opdrachtbeheerder elegant bij het realiseren van twee dingen: vermijden code duplicatieen het verstrekken van een sterke uitzonderingsgarantie.

Hoe werkt het?

conceptueel, het werkt door de functionaliteit van de copy-constructor te gebruiken om een ​​lokale kopie van de gegevens te maken, en neemt vervolgens de gekopieerde gegevens met een swap functie, waarbij de oude gegevens worden vervangen door de nieuwe gegevens. De tijdelijke kopie vernietigt vervolgens en neemt de oude gegevens mee. We blijven achter met een kopie van de nieuwe gegevens.

Om het copy-en-swap-idioom te gebruiken, hebben we drie dingen nodig: een werkende-copy-constructor, een werkende destructor (beide vormen de basis van elke wrapper, dus moet hoe dan ook compleet zijn), en een swap functie.

Een swap-functie is een non-werpen functie die twee objecten van een klasse verwisselt, lid voor lid. We komen misschien in de verleiding om te gebruiken std::swap in plaats van de onze te bieden, maar dit zou onmogelijk zijn; std::swap gebruikt de operator copy-constructor en copy-assignment bij de implementatie ervan, en uiteindelijk proberen we de toewijzingsoperator in termen van zichzelf te definiëren!

(Niet alleen dat, maar niet-gekwalificeerde oproepen voor swap zal onze custom swap operator gebruiken, overslaan van onnodige constructie en vernietiging van onze klasse std::swap zou met zich meebrengen.)


Een diepgaande uitleg

Het doel

Laten we een concreet geval overwegen. We willen in een anders nutteloze klasse een dynamische array beheren. We beginnen met een werkende constructor, copy-constructor en destructor:

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

Deze klasse beheert de array bijna met succes, maar hij heeft dit wel nodig operator= correct werken.

Een mislukte oplossing

Hier is hoe een naïeve implementatie eruit zou kunnen zien:

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

En we zeggen dat we klaar zijn; dit beheert nu een array, zonder lekken. Het lijdt echter aan drie problemen, gemarkeerd als volgt in de code (n).

  1. De eerste is de zelftoewijzingstest. Deze controle dient twee doelen: het is een gemakkelijke manier om te voorkomen dat we nodeloze code gebruiken voor zelftoewijzing en het beschermt ons tegen subtiele bugs (zoals het verwijderen van de array om het te proberen en te kopiëren). Maar in alle andere gevallen dient het slechts om het programma te vertragen en als ruis in de code te fungeren; zelf-toewijzing komt zelden voor, dus meestal is deze controle een verspilling. Het zou beter zijn als de operator zonder hem zou kunnen werken.

  2. De tweede is dat het slechts een standaard uitzonderingsgarantie biedt. Als new int[mSize] mislukt, *this zal zijn gewijzigd. (Namelijk, de maat is verkeerd en de gegevens zijn weg!) Voor een sterke uitzonderingsgarantie zou het iets moeten zijn dat lijkt op:

    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. De code is uitgebreid! Dat brengt ons bij het derde probleem: codeduplicatie. Onze opdrachtbeheerder kopieert effectief alle code die we al elders hebben geschreven en dat is vreselijk.

In ons geval bestaat de kern ervan uit slechts twee regels (de toewijzing en de kopie), maar met complexere bronnen kan deze code-bloat een behoorlijk gedoe zijn. We moeten ernaar streven om onszelf nooit te herhalen.

(Men kan zich afvragen: als er zoveel code nodig is om één bron correct te beheren, wat als mijn klas er meer dan één beheert? Hoewel dit misschien een geldige zorg lijkt, en het inderdaad niet-triviale vereist try/catch clausules, dit is een non-issue. Dat komt omdat een klas zou moeten beheren slechts één hulpbron!)

Een succesvolle oplossing

Zoals vermeld, zal het copy-and-swap-idioom al deze problemen oplossen. Maar op dit moment hebben we alle vereisten behalve één: a swap functie. Hoewel The Rule of Three met succes het bestaan ​​van onze copy-constructor, assignment-operator en destructor inhoudt, zou het eigenlijk "The Big Three and A Half" moeten worden genoemd: wanneer je klas een resource beheert, is het ook logisch om een swap functie.

We moeten swap-functionaliteit aan onze klas toevoegen, en dat doen we als volgt †:

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

    // ...
};

(Hier is de verklaring waarom public friend swap.) Nu kunnen we niet alleen onze ruilen dumb_arrayis, maar swaps in het algemeen kunnen efficiënter zijn; het verwisselt alleen aanwijzers en formaten, in plaats van het toewijzen en kopiëren van volledige arrays. Afgezien van deze bonus in functionaliteit en efficiëntie, zijn we nu klaar om het copy-en-swap-idioom te implementeren.

Zonder verdere omhaal is onze opdrachtbeheerder:

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

    return *this;
}

En dat is het! Met één klap worden alle drie problemen in één keer elegant aangepakt.

Waarom werkt het?

We merken eerst een belangrijke keuze: het parameterargument wordt genomen by-value. Terwijl je net zo gemakkelijk de volgende (en trouwens vele naïeve implementaties van het idioom) kunt doen:

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

    return *this;
}

We verliezen een belangrijke optimalisatiemogelijkheid. Niet alleen dat, maar deze keuze is cruciaal in C ++ 11, dat later wordt besproken. (Over het algemeen is een opmerkelijk bruikbare richtlijn als volgt: als u iets in een functie gaat kopiëren, laat de compiler dit dan in de parameterlijst doen. ‡)

Hoe dan ook, deze methode om onze bron te verkrijgen, is de sleutel tot het elimineren van codeduplicatie: we mogen de code van de kopie-constructor gebruiken om de kopie te maken en hoeven nooit meer iets te herhalen. Nu de kopie is gemaakt, zijn we klaar om te ruilen.

Houd er rekening mee dat bij het invoeren van de functie alle nieuwe gegevens al zijn toegewezen, gekopieerd en klaar om te worden gebruikt. Dit is wat ons een sterke uitzonderingsgarantie geeft voor gratis: we zullen zelfs niet naar de functie gaan als de constructie van de kopie mislukt, en het is daarom niet mogelijk om de staat van *this. (Wat we eerder handmatig hebben gedaan voor een sterke uitzonderingsgarantie, doet de compiler nu voor ons, hoe vriendelijk.)

Op dit punt zijn we thuis, omdat swap is niet-werpend. We wisselen onze huidige gegevens om met de gekopieerde gegevens, veranderen veilig onze staat en de oude gegevens worden tijdelijk opgeslagen. De oude gegevens worden vervolgens vrijgegeven wanneer de functie terugkeert. (Waar op de scope van de parameter eindigt en de destructor wordt genoemd.)

Omdat het idioom geen code herhaalt, kunnen we geen bugs binnen de operator introduceren. Merk op dat dit betekent dat we geen behoefte hebben aan een controle van de eigen toewijzing, waardoor een uniforme implementatie van operator=. (Bovendien hebben we geen prestatietoeslag meer op niet-zelfopdrachten.)

En dat is het copy-and-swap-idioom.

Hoe zit het met C ++ 11?

De volgende versie van C ++, C ++ 11, maakt een zeer belangrijke verandering in de manier waarop we middelen beheren: de Rule of Three is nu De regel van vier (en een half). Waarom? Omdat we niet alleen in staat moeten zijn om onze bron te kopiëren-construeren, we moeten ook verplaatsen - maak het ook.

Gelukkig voor ons is dit eenvoudig:

class dumb_array
{
public:
    // ...

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

    // ...
};

Wat is hier aan de hand? Herinner het doel van verplaatsingsconstructie: om de middelen uit een andere instantie van de klas te halen, waardoor deze in een staat blijft die gegarandeerd toewijsbaar en vernietigbaar is.

Dus wat we hebben gedaan is eenvoudig: initialiseren via de standaardconstructor (een C ++ 11-functie), en vervolgens omwisselen met other; we weten dat een standaard geconstrueerd exemplaar van onze klasse veilig kan worden toegewezen en vernietigd, dus we weten het other zal hetzelfde kunnen doen, na ruilen.

(Merk op dat sommige compilers geen constructorafvaardiging ondersteunen, in dit geval moeten we de klasse handmatig instellen, dit is een ongelukkige maar gelukkig triviale taak.)

Waarom werkt dat?

Dat is de enige verandering die we in onze klas moeten aanbrengen, dus waarom werkt het? Denk aan de altijd belangrijke beslissing die we hebben genomen om de parameter een waarde te maken en geen referentie:

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

Nu als other wordt geïnitialiseerd met een rwaarde, het zal worden verplaatst. Perfect. Op dezelfde manier laat C ++ 03 ons onze copy-constructor functionaliteit hergebruiken door het argument by-value te nemen, C ++ 11 zal automatisch kies de move-constructor ook als dat nodig is. (En natuurlijk, zoals vermeld in eerder gekoppeld artikel, kan het kopiëren / verplaatsen van de waarde eenvoudigweg worden weggelaten.)

En zo besluit het copy-and-swap-idioom.


voetnoten

* Waarom stellen we in mArray te nul? Omdat als een verdere code in de operator gooit, de destructor van dumb_array kan worden genoemd; en als dat gebeurt zonder het op null in te stellen, proberen we geheugen te wissen dat al is verwijderd! We vermijden dit door het op nul te zetten, omdat het verwijderen van null een bewerking is.

† Er zijn andere claims die we zouden moeten specialiseren std::swap voor ons type, geef een in-class swap naast een vrije functie swap, etc. Maar dit is allemaal overbodig: elk goed gebruik van swap zal een goedkeurende oproep doorstaan, en onze functie zal doorkomen ADL. Eén functie is voldoende.

‡ De reden is simpel: als u eenmaal de bron voor uzelf hebt, kunt u deze (C ++ 11) overal waar deze moet worden geruild en / of verplaatsen. En door de kopie in de parameterlijst te maken maximaliseert u de optimalisatie.


1835
2017-07-19 08:43



De toewijzing, in het hart, bestaat uit twee stappen: de oude staat van het object afbreken en zijn nieuwe staat opbouwen als een kopie van de staat van een ander object.

Kortom, dat is wat de destructor en de kopieer constructor wel, dus het eerste idee zou zijn om het werk aan hen te delegeren. Omdat de vernietiging echter niet mag mislukken, terwijl de bouw zou kunnen, we willen het eigenlijk andersom doen: voer eerst het constructieve deel uit en als dat gelukt is, doe dan het destructieve deel. Het copy-and-swap-idioom is een manier om dat te doen: het roept eerst een klasse-kopie-constructor aan om een ​​tijdelijke aan te maken, dan zijn gegevens om te wisselen met de tijdelijke bestanden en laat de destructor van de tijdelijke gebruiker de oude staat vernietigen.
Sinds swap() zou nooit falen, het enige deel dat zou kunnen falen is de kopie-constructie. Dat wordt eerst uitgevoerd en als het mislukt, verandert er niets in het doelobject.

In zijn verfijnde vorm wordt copy-and-swap geïmplementeerd door de kopie te laten uitvoeren door de (niet-referentie) parameter van de toewijzingsoperator te initialiseren:

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

226
2017-07-19 08:55



Er zijn al enkele goede antwoorden. Ik zal focussen hoofdzakelijk op wat ik denk dat ze missen - een uitleg van de "nadelen" met het copy-and-swap idioom ....

Wat is het idioom van het kopiëren en ruilen?

Een manier om de toewijzingsoperator te implementeren in termen van een swapfunctie:

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

Het fundamentele idee is dat:

  • het meest foutgevoelige deel van het toewijzen aan een object is het waarborgen van alle bronnen die de nieuwe status nodig heeft (bijvoorbeeld geheugen, descriptors)

  • die verwerving kan worden geprobeerd voor het modificeren van de huidige toestand van het object (d.w.z. *this) als een kopie van de nieuwe waarde wordt gemaakt, dat is waarom rhs is geaccepteerd op waarde (d.w.z. gekopieerd) in plaats van door verwijzing

  • de status van de lokale kopie omwisselen rhs en *this is doorgaans relatief gemakkelijk te doen zonder potentiële fouten / uitzonderingen, aangezien de lokale kopie achteraf geen specifieke status nodig heeft (er is alleen een staat nodig om de destructor te laten draaien, net als voor een object dat verhuisd van in> = C ++ 11)

Wanneer moet het worden gebruikt? (Welke problemen lost het op? [/ Creëren]?)

  • Wanneer u wilt dat het toegewezen object niet wordt beïnvloed door een toewijzing die een uitzondering genereert, ervan uitgaande dat u een a hebt of kunt schrijven swap met sterke uitzonderingsgarantie, en idealiter een die niet kan falen /throw.. †

  • Wanneer u een schone, eenvoudig te begrijpen, robuuste manier wilt om de toewijzingsoperator te definiëren in termen van (eenvoudiger) kopieerconstructor, swap en destructorfuncties.

    • Zelftoewijzing gedaan als een copy-and-swap vermijdt vaak over het hoofd gezien randgevallen. ‡

  • Wanneer een prestatievergoeding of een tijdelijk hoger resourcegebruik gecreëerd door het hebben van een extra tijdelijk object tijdens de toewijzing, niet belangrijk is voor uw toepassing. ⁂

swap throwing: het is over het algemeen mogelijk om op betrouwbare wijze datamijlers te verwisselen die de objecten volgen met de aanwijzer, maar niet-pointer data-leden die geen throw-free swap hebben, of waarvoor swapping geïmplementeerd moet worden als X tmp = lhs; lhs = rhs; rhs = tmp; en copy-constructie of toewijzing kan gooien, nog steeds het potentieel hebben om te mislukken waardoor sommige data-leden worden uitgewisseld en andere niet. Dit potentieel is zelfs van toepassing op C ++ 03 std::stringis zoals James commentaar geeft op een ander antwoord:

@wilhelmtell: In C ++ 03 wordt er geen melding gemaakt van uitzonderingen die mogelijk worden gegenereerd door std :: string :: swap (die wordt aangeroepen door std :: swap). In C ++ 0x is std :: string :: swap geen uitzondering en mag geen uitzonderingen maken. - James McNellis 22 december '10 om 15:24 uur


‡ opdrachtoperatorimplementatie die gezond lijkt wanneer het toewijzen van een bepaald object gemakkelijk kan mislukken voor zelftoewijzing. Hoewel het ondenkbaar lijkt dat de clientcode zelfs probeert om zichzelf toe te wijzen, kan het relatief gemakkelijk gebeuren tijdens algo-operaties op containers, met x = f(x); code waar f is (misschien alleen voor sommigen #ifdef takken) een macro-ala #define f(x) x of een functie die een verwijzing retourneert naar x, of zelfs (waarschijnlijk inefficiënt maar beknopt) zoals code x = c1 ? x * 2 : c2 ? x / 2 : x;). Bijvoorbeeld:

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_);
    }
    ...
};

Bij zelfbestemming worden de bovenstaande verwijderde codes verwijderd x.p_;, punten p_ bij een nieuw toegewezen heap-regio, probeert vervolgens de ongeinitialiseerde data daarin (Undefined Behavior), als dat niets vreemds doet, copy probeert een zelfbestemming aan elke zojuist vernietigde 'T'!


⁂ Het copy-and-swap-idioom kan inefficiënties of beperkingen introduceren als gevolg van het gebruik van een extra tijdelijke (wanneer de parameter van de operator is samengesteld):

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

Hier, een handgeschreven Client::operator= zou kunnen controleren of *this is al verbonden met dezelfde server als rhs (misschien een "reset" -code verzenden indien nuttig), terwijl de methode copy-and-swap de kopie-constructor zou aanroepen die waarschijnlijk zou worden geschreven om een ​​afzonderlijke socketverbinding te openen en vervolgens de originele te sluiten. Dit zou niet alleen een externe netwerkinteractie kunnen betekenen in plaats van een eenvoudig in-proces variabel exemplaar, het zou in conflict kunnen komen met client- of serverlimieten op socketbronnen of verbindingen. (Natuurlijk heeft deze klasse een behoorlijk afschuwelijke interface, maar dat is een andere kwestie ;-P).


32
2018-03-06 14:51



Dit antwoord is meer een toevoeging en een kleine wijziging van de bovenstaande antwoorden.

In sommige versies van Visual Studio (en mogelijk andere compilers) is er een bug die echt irritant is en niet klopt. Dus als u uw declareert / definieert swap functioneer als volgt:

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

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

}

... de compiler zal tegen je schreeuwen als je de swap functie:

enter image description here

Dit heeft iets te maken met een friend functie wordt aangeroepen en this object wordt doorgegeven als een parameter.


Een manier om dit te omzeilen is om het niet te gebruiken friend sleutelwoord en herdefinieer de swap functie:

void swap(A& other) {

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

}

Deze keer kun je gewoon bellen swap en pas erin other, waardoor de compiler gelukkig is:

enter image description here


Dat doe je tenslotte niet nodig hebben om een ​​te gebruiken friend functie om 2 objecten te verwisselen. Het is net zo verstandig om te maken swap een ledenfunctie die er een heeft other object als een parameter.

U hebt al toegang tot this object, dus het doorgeven als parameter is technisch overbodig.


19
2017-09-04 04:50



Ik zou een woord van waarschuwing willen toevoegen wanneer u te maken hebt met houders die de C ++ 11-stijl toewijzen aan toewijzingen. Swappen en toewijzen hebben een subtiel verschillende semantiek.

Laten we voor concretess een container overwegen std::vector<T, A>, waar A is een stateful allocator type, en we zullen de volgende functies vergelijken:

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

Het doel van beide functies fs en fm is geven a de staat dat b had aanvankelijk. Er is echter een verborgen vraag: wat gebeurt er als a.get_allocator() != b.get_allocator()? Het antwoord is: het hangt ervan af. Laten we schrijven AT = std::allocator_traits<A>.

  • Als AT::propagate_on_container_move_assignment is std::true_type, dan fm wijst de toewijzer van opnieuw toe a met de waarde van b.get_allocator(), anders niet, en a blijft de oorspronkelijke toewijzer gebruiken. In dat geval moeten de gegevenselementen afzonderlijk worden geruild, aangezien de opslag van a en b is niet compatibel.

  • Als AT::propagate_on_container_swap is std::true_type, dan fs ruilt zowel data als allocators op de verwachte manier.

  • Als AT::propagate_on_container_swap is std::false_type, dan hebben we een dynamische controle nodig.

    • Als a.get_allocator() == b.get_allocator(), dan maken de twee containers gebruik van compatibele opslag en gaat het verwisselen op de gebruikelijke manier verder.
    • Echter, als a.get_allocator() != b.get_allocator(), het programma heeft ongedefinieerd gedrag (zie [container.eisen.algemene / 8].

Het resultaat is dat swapping een niet-triviale bewerking is geworden in C ++ 11 zodra uw container stateful allocators gaat ondersteunen. Dat is een ietwat "geavanceerd gebruik", maar het is niet helemaal onwaarschijnlijk, omdat verplaatsingsoptimalisaties meestal pas interessant worden als uw klasse een bron beheert en geheugen een van de populairste bronnen is.


10
2018-06-24 08:16