Perché copiamo e poi ci spostiamo?


98

Ho visto del codice da qualche parte in cui qualcuno ha deciso di copiare un oggetto e successivamente spostarlo in un membro dati di una classe. Questo mi ha lasciato confuso in quanto pensavo che il punto centrale dello spostamento fosse evitare di copiare. Ecco l'esempio:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Ecco le mie domande:

  • Perché non stiamo prendendo un rvalue-riferimento a str?
  • Una copia non sarà costosa, soprattutto se si considera qualcosa di simile std::string?
  • Quale sarebbe il motivo per cui l'autore decide di fare una copia e poi una mossa?
  • Quando dovrei farlo io stesso?

a me sembra uno stupido errore, ma sarò interessato a vedere se qualcuno con maggiori conoscenze sull'argomento ha qualcosa da dire al riguardo.
Dave



Risposte:


97

Prima di rispondere alle tue domande, sembra che tu stia sbagliando una cosa: prendere per valore in C ++ 11 non significa sempre copiare. Se viene passato un rvalue, verrà spostato (a condizione che esista un costruttore di mosse valido) anziché essere copiato. E std::stringha un costruttore di mosse.

A differenza di C ++ 03, in C ++ 11 è spesso idiomatico prendere i parametri per valore, per i motivi che spiegherò di seguito. Vedi anche questa domanda e risposta su StackOverflow per una serie più generale di linee guida su come accettare i parametri.

Perché non stiamo prendendo un rvalue-riferimento a str?

Perché ciò renderebbe impossibile passare i valori, come in:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

Se Ssolo avesse un costruttore che accetta rvalues, quanto sopra non verrebbe compilato.

Una copia non sarà costosa, soprattutto se si considera qualcosa di simile std::string?

Se si passa un rvalue, verrà spostato in stre verrà eventualmente spostato in data. Non verrà eseguita alcuna copia. Se si passa un lvalue, d'altra parte, che lvalue sarà copiato in str, per poi trasferirsi in data.

Quindi, per riassumere, due mosse per rvalues, una copia e una mossa per lvalues.

Quale sarebbe il motivo per cui l'autore decide di fare una copia e poi una mossa?

Prima di tutto, come ho detto sopra, il primo non è sempre una copia; e ciò detto, la risposta è: " Perché è efficiente (gli spostamenti degli std::stringoggetti sono economici) e semplice ".

Partendo dal presupposto che le mosse siano economiche (ignorando qui SSO), possono essere praticamente ignorate quando si considera l'efficienza complessiva di questo progetto. Se lo facciamo, abbiamo una copia per lvalues ​​(come avremmo se accettassimo un riferimento lvalue a const) e nessuna copia per rvalues ​​(mentre avremmo ancora una copia se accettassimo un riferimento lvalue a const).

Ciò significa che prendere per valore equivale a prendere per lvalue riferimento a constquando vengono forniti lvalori, e meglio quando vengono forniti rvalues.

PS: Per fornire un contesto, credo che questa sia la domanda e risposta a cui si riferisce l'OP.


2
Vale la pena ricordare che è un pattern C ++ 11 che sostituisce il const T&passaggio di argomenti: nel peggiore dei casi (lvalue) è lo stesso, ma in caso di temporaneo devi solo spostare il temporaneo. Win-win.
Syam

3
@ user2030677: non è possibile aggirare quella copia, a meno che tu non stia memorizzando un riferimento.
Benjamin Lindley

5
@ user2030677: A chi importa quanto è costosa la copia finché ne hai bisogno (e lo fai, se vuoi conservare una copia nel tuo datamembro)? Avresti una copia anche se prendessi per riferimento il valore diconst
Andy Prowl

3
@BenjaminLindley: Come preliminare, ho scritto: "Partendo dal presupposto che le mosse sono economiche, possono essere praticamente ignorate quando si considera l'efficienza complessiva di questo progetto. ". Quindi sì, ci sarebbe il sovraccarico di una mossa, ma dovrebbe essere considerato trascurabile a meno che non ci sia la prova che si tratta di una preoccupazione reale che giustifica la modifica di un semplice progetto in qualcosa di più efficiente.
Andy Prowl

1
@ user2030677: Ma questo è un esempio completamente diverso. Nell'esempio della tua domanda finisci sempre per tenere una copia data!
Andy Prowl

51

Per capire perché questo è un buon modello, dovremmo esaminare le alternative, sia in C ++ 03 che in C ++ 11.

Abbiamo il metodo C ++ 03 per prendere un std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

in questo caso verrà eseguita sempre una sola copia. Se costruisci da una stringa C non elaborata, std::stringverrà costruito a, quindi copiato di nuovo: due allocazioni.

Esiste il metodo C ++ 03 per prendere un riferimento a a std::string, quindi scambiarlo in un locale std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

questa è la versione C ++ 03 di "sposta semantica", e swapspesso può essere ottimizzata per essere molto economica (molto simile a a move). Dovrebbe anche essere analizzato nel contesto:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

e ti costringe a formare un non temporaneo std::string, quindi scartarlo. (Un temporaneo std::stringnon può legarsi a un riferimento non const). Tuttavia, viene eseguita una sola assegnazione. La versione C ++ 11 richiederebbe un &&e richiederebbe di chiamarlo con std::move, o con un temporaneo: questo richiede che il chiamante crei esplicitamente una copia al di fuori della chiamata e sposti quella copia nella funzione o nel costruttore.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Uso:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Successivamente, possiamo fare la versione completa C ++ 11, che supporta sia la copia che move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Possiamo quindi esaminare come viene utilizzato:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

È abbastanza chiaro che questa tecnica di sovraccarico 2 è almeno altrettanto efficiente, se non di più, dei due stili C ++ 03 sopra. Denominerò questa versione con 2 sovraccarichi la versione "più ottimale".

Ora esamineremo la versione take-by-copy:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

in ciascuno di questi scenari:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Se confronti questa versione fianco a fianco con la versione "ottimale", ne facciamo esattamente un'altra move! Non una volta facciamo un extra copy.

Quindi, se assumiamo che movesia economico, questa versione ci offre quasi le stesse prestazioni della versione più ottimale, ma 2 volte meno codice.

E se prendi da 2 a 10 argomenti, la riduzione del codice è esponenziale - 2x volte inferiore con 1 argomento, 4x con 2, 8x con 3, 16x con 4, 1024x con 10 argomenti.

Ora, possiamo aggirare questo problema tramite un inoltro perfetto e SFINAE, che consente di scrivere un singolo costruttore o modello di funzione che richiede 10 argomenti, esegue SFINAE per garantire che gli argomenti siano di tipi appropriati e quindi li sposta o li copia nel stato locale come richiesto. Sebbene ciò prevenga l'aumento di mille volte della dimensione del programma, può esserci ancora un intero mucchio di funzioni generate da questo modello. (le istanze di funzioni modello generano funzioni)

E molte funzioni generate significano dimensioni del codice eseguibile più grandi, che possono a sua volta ridurre le prestazioni.

Per il costo di pochi movesecondi, otteniamo codice più breve e quasi le stesse prestazioni, e spesso codice più facile da capire.

Ora, questo funziona solo perché sappiamo, quando viene chiamata la funzione (in questo caso, un costruttore), che avremo bisogno di una copia locale di quell'argomento. L'idea è che se sappiamo che ne faremo una copia, dovremmo far sapere al chiamante che stiamo facendo una copia inserendola nel nostro elenco di argomenti. Possono quindi ottimizzare il fatto che stanno per darcene una copia (entrando nel nostro argomento, per esempio).

Un altro vantaggio della tecnica "prendi per valore" è che spesso i costruttori di spostamento non fanno eccezione. Ciò significa che le funzioni che prendono per valore e si spostano fuori dal loro argomento spesso non possono essere eccetto, spostando qualsiasi throws fuori dal loro corpo e nello scope chiamante (chi può evitarlo tramite la costruzione diretta a volte, o costruire gli oggetti e movenell'argomento, per controllare dove avviene il lancio) .Rendere i metodi nothrow spesso vale la pena.


Aggiungerei anche se sappiamo che ne faremo una copia, dovremmo lasciarla fare al compilatore, perché il compilatore lo sa sempre meglio.
Rayniery

6
Da quando ho scritto questo, mi è stato segnalato un altro vantaggio: spesso i costruttori di copia possono lanciare, mentre i costruttori di spostamento lo sono spesso noexcept. Prendendo i dati per copia, è possibile creare la funzione noexcepte fare in modo che qualsiasi costruzione di copia abbia causato potenziali lanci (come la memoria insufficiente) al di fuori della chiamata della funzione.
Yakk - Adam Nevraumont

Perché è necessaria la versione "lvalue non-const, copy" nella tecnica di sovraccarico 3? "Lvalue const, copy" non gestisce anche il caso non const?
Bruno Martinez

@BrunoMartinez non lo facciamo!
Yakk - Adam Nevraumont

13

Questo è probabilmente intenzionale ed è simile al linguaggio di copia e scambio . Fondamentalmente poiché la stringa viene copiata prima del costruttore, il costruttore stesso è sicuro rispetto alle eccezioni poiché scambia (sposta) solo la stringa temporanea str.


+1 per il parallelo copia e scambio. In effetti ha molte somiglianze.
syam

11

Non vuoi ripeterti scrivendo un costruttore per lo spostamento e uno per la copia:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

Questo è molto codice standard, soprattutto se hai più argomenti. La tua soluzione evita quella duplicazione sul costo di una mossa non necessaria. (L'operazione di spostamento dovrebbe essere abbastanza economica, tuttavia.)

L'idioma in competizione è usare l'inoltro perfetto:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

Il template magic sceglierà di spostare o copiare a seconda del parametro che si passa. Fondamentalmente si espande alla prima versione, dove entrambi i costruttori sono stati scritti a mano. Per informazioni di base, vedere il post di Scott Meyer sui riferimenti universali .

Dal punto di vista delle prestazioni, la versione di inoltro perfetta è superiore alla tua versione in quanto evita le mosse non necessarie. Tuttavia, si può sostenere che la tua versione è più facile da leggere e scrivere. Il possibile impatto sulle prestazioni non dovrebbe avere importanza nella maggior parte delle situazioni, quindi alla fine sembra essere una questione di stile.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.