Motivazione e utilizzo dei costruttori di mosse in C ++


17

Recentemente ho letto dei costruttori di mosse in C ++ (vedi ad esempio qui ) e sto cercando di capire come funzionano e quando dovrei usarli.

Per quanto ho capito, un costruttore di spostamento viene utilizzato per alleviare i problemi di prestazioni causati dalla copia di oggetti di grandi dimensioni. La pagina di Wikipedia dice: "Un problema di prestazioni croniche con C ++ 03 sono le copie profonde costose e inutili che possono accadere implicitamente quando gli oggetti vengono passati per valore".

Normalmente mi rivolgo a tali situazioni

  • passando gli oggetti per riferimento, oppure
  • usando i puntatori intelligenti (ad es. boost :: shared_ptr) per passare intorno all'oggetto (i puntatori intelligenti vengono copiati anziché l'oggetto).

Quali sono le situazioni in cui le due tecniche precedenti non sono sufficienti e l'utilizzo di un costruttore di mosse è più conveniente?


1
Oltre al fatto che la semantica di movimento può ottenere molto di più (come detto nelle risposte), non dovresti chiedere quali sono le situazioni in cui il passaggio per riferimento o tramite puntatore intelligente non è sufficiente, ma se quelle tecniche sono davvero il modo migliore e più pulito per fare ciò (guardi shared_ptrbene solo per motivi di copia veloce) e se spostare la semantica può ottenere lo stesso con quasi nessuna penalità di codifica, semantica e pulizia.
Chris dice di reintegrare Monica l'

Risposte:


16

Spostare la semantica introduce un'intera dimensione in C ++ - non è solo lì per permetterti di restituire valori a basso costo.

Ad esempio, senza mossa semantica std::unique_ptrnon funziona - guarda std::auto_ptr, che è stato deprecato con l'introduzione della mossa semantica e rimosso in C ++ 17. Spostare una risorsa è molto diverso dal copiarla. Permette il trasferimento della proprietà di un oggetto unico.

Ad esempio, non diamo un'occhiata std::unique_ptr, poiché è discusso abbastanza bene. Diamo un'occhiata, diciamo, a un oggetto buffer vertici in OpenGL. Un buffer di vertici rappresenta la memoria sulla GPU: deve essere allocato e deallocato utilizzando funzioni speciali, possibilmente con vincoli stretti su quanto tempo può vivere. È anche importante che solo un proprietario lo usi.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Ora, questo potrebbe essere fatto con un std::shared_ptr- ma questa risorsa non deve essere condivisa. Ciò rende confuso l'utilizzo di un puntatore condiviso. È possibile utilizzare std::unique_ptr, ma ciò richiede ancora la semantica di spostamento.

Ovviamente, non ho implementato un costruttore di mosse, ma hai capito.

La cosa rilevante qui è che alcune risorse non sono copiabili . Puoi passare i puntatori invece di spostarti, ma a meno che tu non usi unique_ptr, c'è il problema della proprietà. Vale la pena di essere il più chiaro possibile su quale sia l'intento del codice, quindi un costruttore di mosse è probabilmente l'approccio migliore.


Grazie per la risposta. Cosa succederebbe se si usasse un puntatore condiviso qui?
Giorgio,

Provo a rispondere da solo: l'uso di un puntatore condiviso non consentirebbe di controllare la durata dell'oggetto, mentre è necessario che l'oggetto possa vivere solo per un certo periodo di tempo.
Giorgio,

3
@Giorgio Si potrebbe usare un puntatore condiviso, ma sarebbe semanticamente sbagliato. Non è possibile condividere un buffer. Inoltre, ciò essenzialmente ti farebbe passare un puntatore a un puntatore (poiché il vbo è fondamentalmente un puntatore univoco alla memoria GPU). Qualcuno che visualizzerà il tuo codice in seguito potrebbe chiedersi 'Perché c'è un puntatore condiviso qui? È una risorsa condivisa? Potrebbe essere un bug! '. È meglio essere il più chiari possibile sull'intento originale.
Max

@Giorgio Sì, anche questo è un requisito. Quando il "renderer" in questo caso vuole deallocare alcune risorse (possibilmente memoria insufficiente per nuovi oggetti sulla GPU), non ci deve essere nessun altro handle nella memoria. L'uso di un shared_ptr che non rientra nell'ambito di applicazione funzionerebbe se non lo tieni in giro in qualsiasi altro luogo, ma perché non renderlo completamente ovvio quando puoi?
Max

@Giorgio Guarda la mia modifica per un altro tentativo di chiarimento.
Max

5

Spostare la semantica non è necessariamente un grande miglioramento quando si restituisce un valore - e quando / se si utilizza un shared_ptr(o qualcosa di simile) probabilmente si sta prematuramente pessimizzando. In realtà, quasi tutti i compilatori ragionevolmente moderni fanno ciò che viene chiamato Return Value Optimization (RVO) e Named Return Value Optimization (NRVO). Questo significa che quando si sta restituendo un valore, in realtà invece di copiare il valore a tutti, passano semplicemente un puntatore / riferimento nascosto a dove verrà assegnato il valore dopo il ritorno e la funzione lo utilizza per creare il valore dove sta per finire. Lo standard C ++ include disposizioni speciali per consentire ciò, quindi anche se (ad esempio) il costruttore di copie ha effetti collaterali visibili, non è necessario utilizzare il costruttore di copie per restituire il valore. Per esempio:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

L'idea di base qui è abbastanza semplice: creare una classe con abbastanza contenuto che preferiremmo evitare di copiarlo, se possibile ( std::vectorriempiamo con 32767 ints casuali). Abbiamo un ctor esplicito di copia che ci mostrerà quando / se verrà copiato. Abbiamo anche un po 'più di codice per fare qualcosa con i valori casuali nell'oggetto, quindi l'ottimizzatore non eliminerà (almeno facilmente) tutto ciò che riguarda la classe solo perché non fa nulla.

Abbiamo quindi un po 'di codice per restituire uno di questi oggetti da una funzione e quindi utilizzare la somma per garantire che l'oggetto sia realmente creato, non solo ignorato completamente. Quando lo eseguiamo, almeno con i compilatori più recenti / moderni, scopriamo che il costruttore di copie che abbiamo scritto non funziona mai - e sì, sono abbastanza sicuro che anche una copia veloce con un shared_ptrsia ancora più lenta rispetto a non fare alcuna copia affatto.

Lo spostamento ti consente di fare un discreto numero di cose che semplicemente non potresti fare (direttamente) senza di esse. Considera la parte "unisci" di un ordinamento di unione esterno: hai, diciamo, 8 file che unirai insieme. Idealmente ti piacerebbe mettere tutti e 8 questi file in un vector- ma poiché vector(a partire da C ++ 03) deve essere in grado di copiare elementi, e ifstreamnon è possibile copiarli, sei bloccato con alcuni unique_ptr/ shared_ptr, o qualcosa in quell'ordine per poterli mettere in un vettore. Si noti che anche se (per esempio) abbiamo reservespazio in vectormodo siamo sicuri che i nostri ifstreams sarà mai veramente essere copiati, il compilatore non sa che, in modo che il codice non verrà compilato anche se noi conosciamo il costruttore di copia non sarà mai usato comunque.

Anche se non può ancora essere copiato, in C ++ 11 è ifstream possibile spostarlo. In questo caso, gli oggetti probabilmente non verranno mai spostati, ma il fatto che possano esserlo, se necessario, rende felice il compilatore, in modo da poter mettere i nostri ifstreamoggetti in modo vectordiretto, senza alcun hack di puntatore intelligente.

Un vettore che si espande è un esempio abbastanza decente di un tempo in cui spostare la semantica può essere / sono comunque utili. In questo caso, RVO / NRVO non aiuterà, perché non stiamo trattando il valore di ritorno da una funzione (o qualcosa di molto simile). Abbiamo un vettore che contiene alcuni oggetti e vogliamo spostare quegli oggetti in un nuovo pezzo di memoria più grande.

In C ++ 03, ciò è stato creato creando copie degli oggetti nella nuova memoria, quindi distruggendo i vecchi oggetti nella vecchia memoria. Fare tutte quelle copie solo per buttare via quelle vecchie, tuttavia, era piuttosto una perdita di tempo. In C ++ 11, invece, puoi aspettarti che vengano spostati. Questo in genere ci consente, in sostanza, di fare una copia superficiale anziché una copia profonda (generalmente molto più lenta). In altre parole, con una stringa o un vettore (solo per un paio di esempi) copiamo solo i puntatori negli oggetti, invece di fare copie di tutti i dati a cui fanno riferimento questi puntatori.


Grazie per la spiegazione molto dettagliata. Se ho capito bene, tutte le situazioni in cui in movimento entra in gioco potrebbero essere gestiti da puntatori normali, ma sarebbe pericoloso (complesso e soggetto a errori) per programmare tutto il puntatore giocoleria ogni volta. Quindi, invece, c'è qualche unique_ptr (o meccanismo simile) sotto il cofano e la semantica di spostamento assicura che alla fine della giornata ci sia solo una copia del puntatore e nessuna copia dell'oggetto.
Giorgio,

@Giorgio: Sì, è praticamente corretto. La lingua in realtà non aggiunge la semantica di spostamento; aggiunge riferimenti di valore. Un riferimento al valore (ovviamente abbastanza) può legarsi a un valore, nel qual caso sai che è sicuro "rubare" la sua rappresentazione interna dei dati e semplicemente copiare i suoi puntatori invece di fare una copia profonda.
Jerry Coffin,

4

Ritenere:

vector<string> v;

Quando si aggiungono le stringhe a v, si espanderà secondo necessità e su ogni riallocazione le stringhe dovranno essere copiate. Con i costruttori di mosse, questo è fondamentalmente un problema.

Certo, potresti anche fare qualcosa del tipo:

vector<unique_ptr<string>> v;

Ma funzionerà bene solo perché gli std::unique_ptrattrezzi muovono il costruttore.

L'uso std::shared_ptrha senso solo in (rare) situazioni in cui hai effettivamente la proprietà condivisa.


ma cosa succede se invece di stringavere un'istanza in Foocui ha 30 membri di dati? La unique_ptrversione non sarebbe più efficiente?
Vassilis,

2

I valori di ritorno sono i punti in cui mi piacerebbe spesso passare per valore anziché un tipo di riferimento. Essere in grado di restituire rapidamente un oggetto "in pila" senza una grossa penalità di prestazione sarebbe bello. D'altra parte, non è particolarmente difficile aggirare il problema (i puntatori condivisi sono così facili da usare ...), quindi non sono sicuro che valga la pena fare un lavoro extra sui miei oggetti solo per poterlo fare.


Normalmente utilizzo anche i puntatori intelligenti per avvolgere oggetti restituiti da una funzione / metodo.
Giorgio,

1
@Giorgio: è decisamente offuscato e lento.
DeadMG

I compilatori moderni dovrebbero eseguire una mossa automatica se si restituisce un semplice oggetto in pila, quindi non sono necessari ptr condivisi, ecc.
Christian Severin,
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.