Domanda Qual è l'idioma copy-and-swap?


Qual è questo idioma e quando dovrebbe essere usato? Quali problemi risolve? L'idioma cambia quando viene utilizzato C ++ 11?

Sebbene sia stato menzionato in molti posti, non abbiamo nessuna domanda e risposta "what is it", quindi eccola qui. Ecco un elenco parziale di luoghi in cui è stato precedentemente menzionato:


1667
2017-07-19 08:42


origine


risposte:


Panoramica

Perché abbiamo bisogno dell'idioma copy-and-swap?

Qualsiasi classe che gestisce una risorsa (a involucro, come un puntatore intelligente) deve implementare I tre grandi. Mentre gli obiettivi e l'implementazione di copy-constructor e destructor sono semplici, l'operatore di assegnazione delle copie è probabilmente il più sfumato e difficile. Come dovrebbe essere fatto? Quali sono le insidie ​​da evitare?

Il idioma di copia e scambio è la soluzione, ed assiste elegantemente l'operatore di assegnamento nel realizzare due cose: evitare duplicazione del codicee fornendo un forte garanzia di eccezione.

Come funziona?

concettualmente, funziona utilizzando la funzionalità del copy-constructor per creare una copia locale dei dati, quindi accetta i dati copiati con a swap funzione, scambiando i vecchi dati con i nuovi dati. La copia temporanea poi si distrugge, portando con sé i vecchi dati. Ci rimane una copia dei nuovi dati.

Per usare l'idioma copy-and-swap, abbiamo bisogno di tre cose: un copy-constructor funzionante, un distruttore funzionante (entrambi sono la base di ogni wrapper, quindi dovrebbe essere completato in ogni caso), e un swap funzione.

Una funzione di scambio è a non-lancio funzione che scambia due oggetti di una classe, membro per membro. Potremmo essere tentati di usare std::swap invece di fornire il nostro, ma questo sarebbe impossibile; std::swap usa l'operatore copy-constructor e copy-assignment all'interno della sua implementazione, e alla fine cercheremo di definire l'operatore di assegnazione in termini di se stesso!

(Non solo, ma chiamate non qualificate a swap useremo il nostro operatore di scambio personalizzato, saltando la costruzione e la distruzione non necessarie della nostra classe std::swap comporterebbe.)


Una spiegazione approfondita

L'obiettivo. il gol

Consideriamo un caso concreto. Vogliamo gestire, in una classe altrimenti inutile, un array dinamico. Iniziamo con un costruttore funzionante, un copy-constructor e un distruttore:

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

Questa classe gestisce quasi l'array con successo, ma ha bisogno operator= per funzionare correttamente.

Una soluzione fallita

Ecco come potrebbe apparire un'implementazione ingenua:

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

E diciamo che siamo finiti; questo ora gestisce un array, senza perdite. Tuttavia, soffre di tre problemi, contrassegnati sequenzialmente nel codice come (n).

  1. Il primo è il test di autoassegnazione. Questo controllo ha due scopi: è un modo semplice per impedirci di eseguire codice inutile sull'assegnazione automatica e ci protegge da bug sottili (come eliminare l'array solo per provarlo e copiarlo). Ma in tutti gli altri casi serve solo a rallentare il programma e ad agire come rumore nel codice; l'autoassegnazione si verifica raramente, quindi il più delle volte questo controllo è uno spreco. Sarebbe meglio se l'operatore potesse funzionare correttamente senza di esso.

  2. Il secondo è che fornisce solo una garanzia di base di eccezione. Se new int[mSize] non riesce, *this sarà stato modificato. (Vale a dire, la dimensione è sbagliata e i dati sono andati!) Per una forte garanzia di eccezione, dovrebbe essere qualcosa di simile a:

    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. Il codice è stato ampliato! Il che ci porta al terzo problema: la duplicazione del codice. Il nostro operatore incaricato duplica in modo efficace tutto il codice che abbiamo già scritto altrove, ed è una cosa terribile.

Nel nostro caso, il nucleo di esso è solo due righe (l'allocazione e la copia), ma con risorse più complesse questo codice gonfia può essere piuttosto una seccatura. Dovremmo sforzarci di non ripeterci mai.

(Ci si potrebbe chiedere: se questo codice è necessario per gestire correttamente una risorsa, cosa succede se la mia classe ne gestisce più di una? Anche se può sembrare una preoccupazione valida, e in effetti richiede non banalità try/catch clausole, questo è un non-problema. Questo perché una classe dovrebbe gestire una sola risorsa!)

Una soluzione di successo

Come accennato, l'idioma copy-and-swap risolverà tutti questi problemi. Ma al momento, abbiamo tutti i requisiti tranne uno: a swap funzione. Sebbene la Regola del Tre comporti con successo l'esistenza del nostro costruttore di copie, operatore di assegnazione e distruttore, dovrebbe essere davvero chiamato "I tre grandi e mezzo": ogni volta che la classe gestisce una risorsa ha anche senso fornire un swap funzione.

Abbiamo bisogno di aggiungere funzionalità di swap alla nostra classe, e lo facciamo come segue †:

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

    // ...
};

(Qui è la spiegazione del perché public friend swap.) Ora non solo possiamo scambiare il nostro dumb_arrayma gli swap in generale possono essere più efficienti; scambia semplicemente puntatori e dimensioni, piuttosto che allocare e copiare interi array. Oltre a questo bonus in termini di funzionalità ed efficienza, ora siamo pronti per implementare l'idioma copy-and-swap.

Senza ulteriori indugi, il nostro operatore di assegnazione è:

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

    return *this;
}

E questo è tutto! Con un colpo solo, tutti e tre i problemi vengono affrontati elegantemente insieme.

Perché funziona?

Per prima cosa notiamo una scelta importante: l'argomento parametro è preso by-value. Mentre uno potrebbe altrettanto facilmente fare quanto segue (e in effetti, molte implementazioni ingenue dell'idioma fanno):

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

    return *this;
}

Perdiamo un importante opportunità di ottimizzazione. Non solo, ma questa scelta è fondamentale in C ++ 11, che è discusso più avanti. (In linea generale, una linea guida molto utile è la seguente: se vuoi fare una copia di qualcosa in una funzione, lascia che sia il compilatore a farlo nella lista dei parametri. ‡)

In ogni caso, questo metodo per ottenere la nostra risorsa è la chiave per eliminare la duplicazione del codice: usiamo il codice dal copy-constructor per creare la copia e non abbiamo mai bisogno di ripeterne alcuno. Ora che la copia è stata creata, siamo pronti per lo scambio.

Osservare che al momento dell'inserimento della funzione tutti i nuovi dati sono già allocati, copiati e pronti per essere utilizzati. Questo è ciò che ci offre una forte garanzia di eccezione gratuita: non entreremo nemmeno nella funzione se la costruzione della copia fallisce, e quindi non è possibile modificare lo stato di *this. (Quello che abbiamo fatto manualmente prima per una forte garanzia di eccezione, il compilatore sta facendo per noi ora, quanto gentile.)

A questo punto siamo a casa libera, perché swap è non-lancio. Scambiamo i nostri dati attuali con i dati copiati, alterando in modo sicuro il nostro stato e i vecchi dati vengono messi temporaneamente. I vecchi dati vengono quindi rilasciati quando la funzione ritorna. (Quando l'ambito del parametro termina e viene chiamato il suo distruttore.)

Poiché l'idioma non ripete alcun codice, non possiamo introdurre bug all'interno dell'operatore. Si noti che questo significa che ci stiamo liberando della necessità di un controllo di autoassegnazione, consentendo una singola implementazione uniforme di operator=. (Inoltre, non abbiamo più una penalità per le prestazioni in caso di mancata assegnazione di auto.)

E questo è l'idioma copy-and-swap.

Che ne dici di C ++ 11?

La prossima versione di C ++, C ++ 11, apporta un cambiamento molto importante al modo in cui gestiamo le risorse: la regola del tre è ora La regola del quattro (e mezzo). Perché? Perché non solo dobbiamo essere in grado di copiare-costruire la nostra risorsa, dobbiamo spostarci, costruirlo pure.

Fortunatamente per noi, questo è facile:

class dumb_array
{
public:
    // ...

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

    // ...
};

Cosa sta succedendo qui? Ricorda l'obiettivo della costruzione delle mosse: prendere le risorse da un'altra istanza della classe, lasciandola in uno stato garantito per essere assegnabile e distruttibile.

Quindi, ciò che abbiamo fatto è semplice: inizializzare tramite il costruttore predefinito (una funzione C ++ 11), quindi scambiare con other; sappiamo che un'istanza costruita in modo predefinito della nostra classe può essere assegnata e distrutta in modo sicuro, quindi sappiamo other sarà in grado di fare lo stesso, dopo lo scambio.

(Si noti che alcuni compilatori non supportano la delega del costruttore, in questo caso, per impostazione predefinita dobbiamo costringere la classe. Questo è un compito sfortunato, ma per fortuna banale.)

Perché funziona?

Questo è l'unico cambiamento che dobbiamo apportare alla nostra classe, quindi perché funziona? Ricorda la decisione sempre importante che abbiamo preso per rendere il parametro un valore e non un riferimento:

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

Ora se other viene inizializzato con un valore, sarà spostato-costruito. Perfezionare. Allo stesso modo in C ++ 03 riusciamo a riutilizzare la nostra funzionalità copy-constructor prendendo l'argomento in base al valore, C ++ 11 automaticamente selezionare il costruttore di movimento anche quando appropriato. (E, naturalmente, come menzionato nell'articolo precedentemente collegato, la copia / lo spostamento del valore può essere semplicemente eliso del tutto.)

E così conclude l'idioma copy-and-swap.


Le note

* Perché lo impostiamo mArray a null? Perché se un altro codice nell'operatore lancia, il distruttore di dumb_array potrebbe essere chiamato; e se ciò accade senza impostarlo su null, tentiamo di cancellare la memoria che è già stata cancellata! Evitiamo ciò impostandolo su null, poiché l'eliminazione di null è un'operazione non operativa.

† Ci sono altre affermazioni che dovremmo specializzare std::swap per il nostro tipo, fornire una classe swap lungo una funzione libera swapecc. Ma tutto ciò non è necessario: un uso corretto di swap sarà attraverso una chiamata non qualificata, e la nostra funzione sarà trovata attraverso ADL. Una funzione lo farà.

‡ La ragione è semplice: una volta che hai la risorsa per te, puoi scambiarla e / o spostarla (C ++ 11) ovunque sia necessario. E facendo la copia nell'elenco dei parametri, massimizzi l'ottimizzazione.


1834
2017-07-19 08:43



L'incarico, nel suo cuore, è di due passi: abbattere il vecchio stato dell'oggetto e costruendo il suo nuovo stato come una copia dello stato di qualche altro oggetto.

Fondamentalmente, questo è ciò che distruttore e il copia costruttore fare, quindi la prima idea sarebbe quella di delegare il lavoro a loro. Tuttavia, poiché la distruzione non deve fallire, mentre la costruzione potrebbe, in realtà vogliamo farlo al contrario: prima eseguire la parte costruttiva e se ciò è riuscito, quindi fai la parte distruttiva. L'idioma copy-and-swap è un modo per fare proprio questo: prima chiama un costruttore di copie di classe per creare un temporaneo, quindi scambia i suoi dati con quelli del temporaneo e quindi lascia che il distruttore del temporaneo distrugga il vecchio stato.
Da swap() si suppone che non fallirà mai, l'unica parte che potrebbe fallire è la costruzione della copia. Questo viene eseguito per primo, e se fallisce, nulla sarà cambiato nell'oggetto mirato.

Nella sua forma raffinata, copy-and-swap viene implementato eseguendo la copia inizializzando il parametro (non di riferimento) dell'operatore di assegnazione:

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

226
2017-07-19 08:55



Ci sono già alcune buone risposte. Mi concentrerò principalmente su quello che penso che manchi - una spiegazione dei "contro" con l'idioma copy-and-swap ....

Qual è l'idioma copy-and-swap?

Un modo per implementare l'operatore di assegnazione in termini di una funzione di scambio:

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

L'idea fondamentale è che:

  • la parte più soggetta a errori nell'assegnare a un oggetto è garantire che tutte le risorse richieste dal nuovo stato siano acquisite (ad es. memoria, descrittori)

  • questa acquisizione può essere tentata prima modificare lo stato corrente dell'oggetto (ad es. *this) se viene eseguita una copia del nuovo valore, ecco perché rhs è accettato in base al valore (cioè copiato) piuttosto che come riferimento

  • scambiando lo stato della copia locale rhs e *this è generalmente relativamente facile da fare senza potenziali errori / eccezioni, dato che la copia locale non ha bisogno di uno stato particolare in seguito (serve solo lo stato per il distruttore da eseguire, proprio come per un oggetto mosso da in> = C ++ 11)

Quando dovrebbe essere usato? (Quali problemi risolve [/creare]?)

  • Quando si desidera che l'oggetto assegnato non sia influenzato da un compito che genera un'eccezione, assumendo che si abbia o si possa scrivere un swap con una forte garanzia di eccezione, idealmente quella che non può fallire /throw.. †

  • Quando si desidera un modo pulito, facile da capire e robusto per definire l'operatore di assegnazione in termini di costruttore di copia (più semplice), swap e funzioni distruttore.

    • L'autoassegnazione eseguita come copia-e-scambio evita i casi limite ignorati. ‡

  • Quando una penalizzazione delle prestazioni o un utilizzo momentaneo di risorse più elevato creato da un oggetto temporaneo extra durante l'assegnazione non è importante per la tua applicazione. ⁂

swap lancio: è generalmente possibile scambiare in modo affidabile i membri dei dati che gli oggetti tracciano per puntatore, ma i membri dei dati non puntatore che non hanno uno scambio senza throw-free o per i quali lo swap deve essere implementato come X tmp = lhs; lhs = rhs; rhs = tmp; e la copia-costruzione o il compito possono essere lanciati, hanno ancora il potenziale per fallire lasciando alcuni membri dei dati scambiati e altri no. Questo potenziale si applica anche a C ++ 03 std::stringcome commenta James in un'altra risposta:

@wilhelmtell: In C ++ 03, non si fa menzione di eccezioni potenzialmente lanciate da std :: string :: swap (che è chiamato da std :: swap). In C ++ 0x, std :: string :: swap è noexcept e non deve generare eccezioni. - James McNellis, 22 dicembre 10 alle 15:24


• L'implementazione dell'operatore di assegnazione che sembra sana quando si assegna da un oggetto distinto può facilmente fallire per l'autoassegnazione. Anche se potrebbe sembrare inimmaginabile che il codice cliente possa anche tentare l'autoassegnazione, può accadere relativamente facilmente durante le operazioni algo sui container, con x = f(x); codice dove f è (forse solo per alcuni #ifdef rami) una macro ala #define f(x) x o una funzione che restituisce un riferimento a xo anche (probabilmente inefficiente ma conciso) come il codice x = c1 ? x * 2 : c2 ? x / 2 : x;). Per esempio:

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

Per l'autoassegnazione, il codice sopra riportato è cancellato x.p_;, punti p_ in una regione di heap appena assegnata, quindi tenta di leggere il Non inizializzato dati al suo interno (comportamento indefinito), se ciò non fa nulla di strano, copy tenta un autoassegnazione per ogni "T" appena distrutta!


⁂ L'idioma copy-and-swap può introdurre inefficienze o limitazioni dovute all'uso di un temporaneo extra (quando il parametro dell'operatore è copiato):

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

Qui, una scritta a mano Client::operator= potrebbe controllare se *this è già connesso allo stesso server di rhs (forse inviando un codice "reset" se utile), mentre l'approccio copy-and-swap invocerebbe il copy-constructor che probabilmente verrebbe scritto per aprire una connessione socket distinta e chiudere quella originale. Non solo ciò potrebbe significare un'interazione di rete remota invece di una semplice copia di variabile in-process, ma potrebbe eseguire un controllo dei limiti del client o del server sulle risorse socket o sulle connessioni. (Naturalmente questa classe ha un'interfaccia piuttosto orribile, ma questa è un'altra questione ;-P).


32
2018-03-06 14:51



Questa risposta è più come un'aggiunta e una leggera modifica alle risposte sopra.

In alcune versioni di Visual Studio (e probabilmente anche in altri compilatori) c'è un bug che è davvero fastidioso e non ha senso. Quindi se dichiari / definisci il tuo swap funzione come questa:

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

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

}

... il compilatore ti urlerà quando chiamerai il swap funzione:

enter image description here

Questo ha qualcosa a che fare con a friend funzione chiamata e this oggetto passato come parametro.


Un modo per aggirare questo è non usare friend parola chiave e ridefinire il swap funzione:

void swap(A& other) {

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

}

Questa volta, puoi semplicemente chiamare swap e passare other, rendendo felice il compilatore:

enter image description here


Dopo tutto, non lo fai bisogno usare a friend funzione per scambiare 2 oggetti. Ha tanto senso da fare swap una funzione membro che ne ha uno other oggetto come parametro.

Hai già accesso a this oggetto, quindi passarlo come parametro è tecnicamente ridondante.


19
2017-09-04 04:50



Vorrei aggiungere una parola di avvertimento quando si ha a che fare con contenitori in grado di riconoscere l'allocatore in stile C ++ 11. Lo scambio e l'assegnazione hanno semantica sottilmente diversa.

Per concretezza, consideriamo un contenitore std::vector<T, A>, dove A è un tipo di allocatore stateful e confronteremo le seguenti funzioni:

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

Lo scopo di entrambe le funzioni fs e fm è dare a lo stato quello b inizialmente. Tuttavia, c'è una domanda nascosta: cosa succede se a.get_allocator() != b.get_allocator()? La risposta è, dipende. Scriviamo AT = std::allocator_traits<A>.

  • Se AT::propagate_on_container_move_assignment è std::true_type, poi fm riassegna l'allocatore di a con il valore di b.get_allocator(), altrimenti non lo fa, e a continua a utilizzare il suo allocatore originale. In tal caso, gli elementi dei dati devono essere scambiati singolarmente, dal momento che l'archiviazione di a e b non è compatibile

  • Se AT::propagate_on_container_swap è std::true_type, poi fs scambia sia i dati che gli allocatori nel modo previsto.

  • Se AT::propagate_on_container_swap è std::false_type, quindi abbiamo bisogno di un controllo dinamico.

    • Se a.get_allocator() == b.get_allocator(), quindi i due contenitori utilizzano la memoria compatibile e scambiano i proventi nel modo consueto.
    • Tuttavia, se a.get_allocator() != b.get_allocator(), il programma ha comportamento indefinito (cfr. [container.requirements.general / 8].

Il risultato è che lo swapping è diventato un'operazione non banale in C ++ 11 non appena il contenitore inizia a supportare gli allocatori di stato. Si tratta di un caso d'uso un po '"avanzato", ma non è del tutto improbabile, dal momento che le ottimizzazioni delle mosse di solito diventano interessanti solo quando la classe gestisce una risorsa e la memoria è una delle risorse più popolari.


10
2018-06-24 08:16