Panoramica
Perché abbiamo bisogno del linguaggio copia-e-scambia?
Qualsiasi classe che gestisce una risorsa (un wrapper , come un puntatore intelligente) deve implementare The Big Three . Mentre gli obiettivi e l'implementazione del costruttore di copie e del distruttore sono semplici, l'operatore di assegnazione delle copie è senza dubbio il più sfumato e difficile. Come dovrebbe essere fatto? Quali insidie devono essere evitate?
Il linguaggio copia-e-scambia è la soluzione e assiste con eleganza l'operatore di assegnazione nel realizzare due cose: evitare la duplicazione del codice e fornire una forte garanzia di eccezione .
Come funziona?
Concettualmente , funziona utilizzando la funzionalità del costruttore di copie per creare una copia locale dei dati, quindi accetta i dati copiati con una swap
funzione, scambiando i vecchi dati con i nuovi dati. La copia temporanea viene quindi distrutta, portando con sé i vecchi dati. Ci resta una copia dei nuovi dati.
Per usare il linguaggio copia-e-scambia, abbiamo bisogno di tre cose: un costruttore di copia funzionante, un distruttore funzionante (entrambi sono la base di qualsiasi wrapper, quindi dovrebbe essere completo comunque) e una swap
funzione.
Una funzione di scambio è una funzione non di lancio che scambia due oggetti di una classe, membro per membro. Potremmo essere tentati di usare std::swap
invece di fornire il nostro, ma ciò sarebbe impossibile; std::swap
usa l'operatore di costruzione e copia-incarico all'interno della sua implementazione e alla fine proveremo a definire l'operatore di assegnazione in termini di se stesso!
(Non solo, ma le chiamate non qualificate swap
utilizzeranno il nostro operatore di scambio personalizzato, saltando la costruzione e la distruzione non necessarie della nostra classe che 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 costruttore di copie 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 correttamente l'array, ma deve operator=
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 abbiamo finito; questo ora gestisce un array, senza perdite. Tuttavia, soffre di tre problemi, contrassegnati in sequenza nel codice come (n)
.
Il primo è il test di autoassegnazione. Questo controllo ha due scopi: è un modo semplice per impedirci di eseguire codice inutile in caso di autoassegnazione e ci protegge da bug sottili (come eliminare l'array solo per provare a copiarlo). Ma in tutti gli altri casi serve semplicemente 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 essa.
Il secondo è che fornisce solo una garanzia di eccezione di base. Se new int[mSize]
fallisce, *this
sarà stato modificato. (Vale a dire, la dimensione è errata e i dati sono spariti!) 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;
}
Il codice si è espanso! Il che ci porta al terzo problema: la duplicazione del codice. Il nostro operatore di assegnazione 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 gonfio può essere 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? Mentre questa può sembrare una preoccupazione valida e in effetti richiede clausole try
/ non banali catch
, questa è una non -issue. Questo perché una classe dovrebbe gestire una sola risorsa !)
Una soluzione di successo
Come accennato, il linguaggio copia-e-scambia risolverà tutti questi problemi. Ma in questo momento, abbiamo tutti i requisiti tranne uno: una swap
funzione. Mentre The Rule of Three implica con esistenza l'esistenza del nostro costruttore di copie, operatore di assegnazione e distruttore, in realtà dovrebbe essere chiamato "I tre e mezzo grandi": ogni volta che la tua classe gestisce una risorsa ha anche senso fornire una swap
funzione .
Dobbiamo aggiungere funzionalità di scambio 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);
}
// ...
};
( Ecco la spiegazione del perché public friend swap
.) Ora non solo possiamo scambiare i nostri dumb_array
, ma gli swap in generale possono essere più efficienti; scambia semplicemente puntatori e dimensioni, piuttosto che allocare e copiare interi array. A parte questo vantaggio in termini di funzionalità ed efficienza, siamo ora pronti a implementare il linguaggio copia-e-scambia.
Senza ulteriori indugi, il nostro operatore di incarichi è:
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 elegantemente affrontati contemporaneamente.
Perché funziona
Per prima cosa notiamo una scelta importante: l'argomento parametro è preso per valore . Mentre uno potrebbe altrettanto facilmente fare quanto segue (e in effetti, molte implementazioni ingenue dell'idioma lo 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 verrà discussa più avanti. (In linea generale, una linea guida notevolmente utile è la seguente: se hai intenzione di fare una copia di qualcosa in una funzione, lascia che il compilatore lo faccia nell'elenco dei parametri. ‡)
In entrambi i casi, questo metodo per ottenere la nostra risorsa è la chiave per eliminare la duplicazione del codice: possiamo usare il codice dal costruttore di copie per fare la copia, e non abbiamo mai bisogno di ripeterlo. Ora che la copia è stata fatta, siamo pronti per lo scambio.
Osservare che inserendo la funzione tutti i nuovi dati sono già allocati, copiati e pronti per essere utilizzati. Questo è ciò che ci dà una forte garanzia eccezionale gratuitamente: 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; che gentile.)
A questo punto siamo liberi da casa, perché swap
non lancia. Scambiamo i nostri dati attuali con i dati copiati, alterando in sicurezza il nostro stato e i vecchi dati vengono inseriti nel temporaneo. I vecchi dati vengono quindi rilasciati quando la funzione ritorna. (Dove finisce l'ambito del parametro e viene chiamato il suo distruttore.)
Poiché il linguaggio non ripete nessun codice, non possiamo introdurre bug all'interno dell'operatore. Si noti che questo significa che ci siamo liberati della necessità di un controllo di auto-assegnazione, consentendo un'unica implementazione uniforme di operator=
. (Inoltre, non prevediamo più una penalità per prestazioni non assegnabili).
E questo è l'idioma copia-e-scambia.
Che dire di C ++ 11?
La prossima versione di C ++, C ++ 11, apporta una modifica 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 costruire-copia la nostra risorsa, ma dobbiamo anche spostarla-costruirla .
Fortunatamente per noi, questo è 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);
}
// ...
};
Cosa sta succedendo qui? Ricorda l'obiettivo della costruzione di mosse: prendere le risorse da un'altra istanza della classe, lasciandole in uno stato garantito per essere assegnabile e distruttibile.
Quindi quello che abbiamo fatto è semplice: inizializzare tramite il costruttore predefinito (una funzionalità C ++ 11), quindi scambiare con other
; sappiamo che un'istanza predefinita della nostra classe può essere assegnata e distrutta in modo sicuro, quindi sappiamo che 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, è necessario creare manualmente manualmente la classe predefinita. Si tratta di un'attività sfortunata ma fortunatamente 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, verrà costruito in modo movimento . Perfetto. Allo stesso modo C ++ 03 riutilizziamo la nostra funzionalità di costruzione di copia prendendo l'argomento per valore, C ++ 11 sceglierà automaticamente anche il costruttore di mosse quando appropriato. (E, naturalmente, come menzionato nell'articolo precedentemente collegato, la copia / spostamento del valore può essere semplicemente elusa del tutto.)
E così conclude il linguaggio copia-e-scambia.
Le note
* Perché impostiamo mArray
su null? Perché se viene lanciato un ulteriore codice nell'operatore, il distruttore di dumb_array
potrebbe essere chiamato; e se ciò accade senza impostarlo su null, proviamo a eliminare la memoria che è già stata eliminata! Lo evitiamo impostandolo su null, poiché l'eliminazione di null è un'operazione nulla.
† Ci sono altre affermazioni che dovremmo specializzarci std::swap
per il nostro tipo, fornire un servizio in classe swap
a fianco di una funzione libera swap
, ecc. Ma tutto ciò non è necessario: qualsiasi uso corretto swap
avverrà attraverso una chiamata non qualificata e la nostra funzione sarà trovato attraverso ADL . Una funzione farà.
‡ Il motivo è 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.
†† Il costruttore di mosse dovrebbe essere generalmente noexcept
, altrimenti un codice (ad es. std::vector
Logica di ridimensionamento) utilizzerà il costruttore di copie anche quando una mossa avrebbe senso. Naturalmente, contrassegnalo solo se il codice all'interno non genera eccezioni.