Che cos’è la semantica dei movimenti?

La mia prima risposta era un’introduzione estremamente semplificata alla semantica dei movimenti, e molti dettagli sono stati omessi di proposito per mantenerla semplice.Tuttavia, c’è molto di più sulla semantica dei movimenti, e ho pensato che fosse il momento di una seconda risposta per riempire le lacune.La prima risposta è già abbastanza vecchia, e non mi sembrava giusto sostituirla semplicemente con un testo completamente diverso. Penso che serva ancora bene come prima introduzione. Ma se vuoi scavare più a fondo, continua a leggere 🙂

Stephan T. Lavavej ha impiegato del tempo per fornire un prezioso feedback. Grazie mille, Stephan!

Introduzione

La semantica Move permette ad un oggetto, sotto certe condizioni, di prendere possesso delle risorse esterne di qualche altro oggetto. Questo è importante in due modi:

  1. Trasformando copie costose in mosse economiche. Vedere la mia prima risposta per un esempio. Si noti che se un oggetto non gestisce almeno una risorsa esterna (direttamente o indirettamente attraverso i suoi oggetti membri), la semantica move non offrirà alcun vantaggio rispetto alla semantica copy. In questo caso, copiare un oggetto e spostare un oggetto significa esattamente la stessa cosa:

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Implementare tipi sicuri “move-only”; cioè, tipi per cui copiare non ha senso, ma spostare sì. Gli esempi includono serrature, maniglie di file e puntatori intelligenti con semantica di proprietà unica. Nota: Questa risposta discute std::auto_ptr, un template deprecato della libreria standard C++98, che è stato sostituito da std::unique_ptr in C++11. I programmatori C++ intermedi hanno probabilmente almeno una certa familiarità con std::auto_ptr, e a causa della “semantica di spostamento” che mostra, sembra un buon punto di partenza per discutere la semantica di spostamento in C++11. YMMV.

Cos’è una mossa?

La libreria standard C++98 offre un puntatore intelligente con una semantica di proprietà unica chiamata std::auto_ptr<T>. Nel caso non abbiate familiarità con auto_ptr, il suo scopo è quello di garantire che un oggetto allocato dinamicamente sia sempre rilasciato, anche di fronte alle eccezioni:

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

La cosa insolita di auto_ptr è il suo comportamento “copiativo”:

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Nota come l’inizializzazione di b con a non copia il triangolo, ma invece trasferisce la proprietà del triangolo da a a b. Diciamo anche “a viene spostato in b” o “il triangolo viene spostato da a a b“. Questo può suonare confuso perché il triangolo stesso rimane sempre nello stesso posto in memoria.

Spostare un oggetto significa trasferire la proprietà di qualche risorsa che gestisce ad un altro oggetto.

Il costruttore di copia di auto_ptr probabilmente assomiglia a questo (un po’ semplificato):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Mosse pericolose e innocue

La cosa pericolosa di auto_ptr è che ciò che sintatticamente sembra una copia è in realtà uno spostamento. Cercare di chiamare una funzione membro su un auto_ptr spostato da invocherà un comportamento non definito, quindi devi stare molto attento a non usare un auto_ptr dopo che è stato spostato da:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Ma auto_ptr non è sempre pericoloso. Le funzioni di fabbrica sono un caso d’uso perfettamente corretto per auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Nota come entrambi gli esempi seguono lo stesso schema sintattico:

auto_ptr<Shape> variable(expression);double area = expression->area();

E tuttavia, uno di essi invoca un comportamento non definito, mentre l’altro no. Allora qual è la differenza tra le espressioni a e make_triangle()? Non sono entrambe dello stesso tipo? In effetti lo sono, ma hanno diverse categorie di valore.

Categorie di valore

Ovviamente, ci deve essere qualche profonda differenza tra l’espressione a che denota una variabile auto_ptr, e l’espressione make_triangle() che denota la chiamata di una funzione che restituisce una auto_ptr per valore, creando così un nuovo oggetto temporaneo auto_ptr ogni volta che viene chiamata. a è un esempio di un lvalue, mentre make_triangle() è un esempio di un rvalue.

Passare da lvalue come a è pericoloso, perché potremmo successivamente provare a chiamare una funzione membro tramite a, invocando un comportamento non definito. D’altra parte, spostarsi da valori r come make_triangle() è perfettamente sicuro, perché dopo che il costruttore di copie ha fatto il suo lavoro, non possiamo usare di nuovo il temporaneo. Non c’è un’espressione che denota detto temporaneo; se scriviamo semplicemente make_triangle() di nuovo, otteniamo un temporaneo diverso. Infatti, il temporaneo spostato-da è già sparito nella riga successiva:

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Nota che le lettere l e r hanno un’origine storica nel lato sinistro e destro di un’assegnazione. Questo non è più vero in C++, perché ci sono lvalori che non possono apparire sul lato sinistro di un’assegnazione (come gli array o i tipi definiti dall’utente senza un operatore di assegnazione), e ci sono rvalori che possono (tutti gli rvalori dei tipi di classe con un operatore di assegnazione).

Un rvalore di tipo classe è un’espressione la cui valutazione crea un oggetto temporaneo. In circostanze normali, nessun’altra espressione all’interno dello stesso scope denota lo stesso oggetto temporaneo.

Riferimenti a valori r

Ora abbiamo capito che passare da lvalori è potenzialmente pericoloso, ma passare da rvalori è innocuo. Se il C++ avesse un supporto linguistico per distinguere gli argomenti lvalue dagli argomenti rvalue, potremmo o proibire completamente lo spostamento da lvalue, o almeno rendere esplicito lo spostamento da lvalue al sito della chiamata, in modo da non spostarsi più per caso.

La risposta di C++11 a questo problema sono i riferimenti rvalue. Un riferimento rvalue è un nuovo tipo di riferimento che si lega solo agli rvalori, e la sintassi è X&&. Il buon vecchio riferimento X& è ora conosciuto come un riferimento lvalue. (Si noti che X&& non è un riferimento a un riferimento; non esiste una cosa del genere in C++.)

Se buttiamo const nel mix, abbiamo già quattro diversi tipi di riferimento. A quali tipi di espressioni di tipo X possono legarsi?

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

In pratica, ci si può dimenticare di const X&&. Essere limitati a leggere da rvalori non è molto utile.

Un riferimento rvalue X&& è un nuovo tipo di riferimento che si lega solo a rvalori.

Conversioni implicite

I riferimenti rvalue sono passati attraverso diverse versioni. Dalla versione 2.1, un riferimento rvalue X&& si lega anche a tutte le categorie di valori di tipo diverso Y, purché ci sia una conversione implicita da Y a X. In questo caso, viene creato un temporaneo di tipo X, e il riferimento rvalue è legato a quel temporaneo:

void some_function(std::string&& r);some_function("hello world");

Nell’esempio precedente, "hello world" è un lvalue di tipo const char. Poiché c’è una conversione implicita da const char attraverso const char* a std::string, viene creato un temporaneo di tipo std::string, e r è legato a quel temporaneo. Questo è uno dei casi in cui la distinzione tra rvalori (espressioni) e temporanei (oggetti) è un po’ sfocata.

Costruttori Move

Un utile esempio di funzione con un parametro X&& è il costruttore move X::X(X&& source). Il suo scopo è quello di trasferire la proprietà della risorsa gestita dalla fonte all’oggetto corrente.

In C++11, std::auto_ptr<T> è stato sostituito da std::unique_ptr<T> che sfrutta i riferimenti rvalue. Svilupperò e discuterò una versione semplificata di unique_ptr. Per prima cosa, incapsuliamo un puntatore grezzo e sovraccarichiamo gli operatori -> e *, così la nostra classe si sente come un puntatore:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

Il costruttore prende la proprietà dell’oggetto, e il distruttore lo cancella:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Ora arriva la parte interessante, il costruttore move:

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Questo costruttore di spostamento fa esattamente quello che faceva il costruttore di copia auto_ptr, ma può essere fornito solo con valori r:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

La seconda linea non riesce a compilare, perché a è un valore l, ma il parametro unique_ptr&& source può essere legato solo a valori r. Questo è esattamente ciò che volevamo; i movimenti pericolosi non dovrebbero mai essere impliciti. La terza linea si compila bene, perché make_triangle() è un valore r. Il costruttore di mosse trasferirà la proprietà dal temporaneo a c. Di nuovo, questo è esattamente quello che volevamo.

Il costruttore move trasferisce la proprietà di una risorsa gestita nell’oggetto corrente.

Operatori di assegnazione move

L’ultimo pezzo mancante è l’operatore di assegnazione move. Il suo compito è quello di rilasciare la vecchia risorsa e acquisire la nuova risorsa dal suo argomento:

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Nota come questa implementazione dell’operatore di assegnazione move duplica la logica sia del distruttore che del costruttore move. Avete familiarità con l’idioma copy-and-swap? Può anche essere applicato alla semantica del movimento come l’idioma move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Ora che source è una variabile di tipo unique_ptr, sarà inizializzata dal costruttore move; cioè, l’argomento sarà spostato nel parametro. L’argomento deve ancora essere un rvalore, perché il costruttore move stesso ha un parametro di riferimento rvalue. Quando il flusso di controllo raggiunge la parentesi graffa di chiusura di operator=, source esce dallo scope, rilasciando automaticamente la vecchia risorsa.

L’operatore di assegnazione move trasferisce la proprietà di una risorsa gestita nell’oggetto corrente, rilasciando la vecchia risorsa. L’idioma move-and-swap semplifica l’implementazione.

Moving from lvalues

A volte, vogliamo spostare da lvalues. Cioè, a volte vogliamo che il compilatore tratti un lvalue come se fosse un rvalue, in modo da poter invocare il costruttore move, anche se potrebbe essere potenzialmente non sicuro.Per questo scopo, C++11 offre un template di funzione di libreria standard chiamato std::move all’interno dell’intestazione <utility>.Questo nome è un po’ sfortunato, perché std::move semplicemente lancia un lvalue in un rvalue; non sposta nulla da solo. Si limita a permettere lo spostamento. Forse avrebbe dovuto chiamarsi std::cast_to_rvalue o std::enable_move, ma ormai siamo bloccati con questo nome.

Ecco come ci si sposta esplicitamente da un valore l:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Nota che dopo la terza riga, a non possiede più un triangolo. Questo va bene, perché scrivendo esplicitamente std::move(a), abbiamo chiarito le nostre intenzioni: “Caro costruttore, fai quello che vuoi con a per inizializzare c; non mi interessa più a. Sentiti libero di fare quello che vuoi con a.”

std::move(some_lvalue) fa il cast di un lvalue in un rvalue, permettendo così un successivo spostamento.

Xvalues

Nota che anche se std::move(a) è un rvalue, la sua valutazione non crea un oggetto temporaneo. Questo enigma ha costretto il comitato a introdurre una terza categoria di valori. Qualcosa che può essere legato ad un riferimento rvalue, anche se non è un rvalue nel senso tradizionale, è chiamato un xvalue (eXpiring value). Gli rvalori tradizionali sono stati rinominati in prvalori (Pure rvalues).

Sia i prvalori che gli xvalori sono rvalori. Xvalori e lvalori sono entrambi glvalori (Generalized lvalues). Le relazioni sono più facili da capire con un diagramma:

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Nota che solo gli xvalori sono veramente nuovi; il resto è solo dovuto alla rinominazione e al raggruppamento.

Gli rvalori di C++98 sono conosciuti come prvalori in C++11. Sostituite mentalmente tutte le occorrenze di “rvalue” nei paragrafi precedenti con “prvalue”.

Spostamento fuori dalle funzioni

Finora abbiamo visto lo spostamento in variabili locali e in parametri di funzione. Ma lo spostamento è possibile anche nella direzione opposta. Se una funzione ritorna per valore, qualche oggetto nel luogo della chiamata (probabilmente una variabile locale o un temporaneo, ma potrebbe essere qualsiasi tipo di oggetto) viene inizializzato con l’espressione dopo la dichiarazione return come argomento del costruttore del movimento:

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Forse sorprendentemente, gli oggetti automatici (variabili locali che non sono dichiarate come static) possono anche essere implicitamente spostati fuori dalle funzioni:

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

Come mai il costruttore move accetta il valore l result come argomento? Lo scopo di result sta per finire, e sarà distrutto durante lo svolgimento dello stack. Nessuno potrebbe lamentarsi in seguito che result sia cambiato in qualche modo; quando il flusso di controllo torna al chiamante, result non esiste più! Per questo motivo, C++11 ha una regola speciale che permette di restituire oggetti automatici dalle funzioni senza dover scrivere std::move. Infatti, non dovreste mai usare std::move per spostare gli oggetti automatici fuori dalle funzioni, poiché questo inibisce la “named return value optimization” (NRVO).

Non usate mai std::move per spostare gli oggetti automatici fuori dalle funzioni.

Nota che in entrambe le funzioni factory, il tipo di ritorno è un valore, non un riferimento rvalue. I riferimenti rvalue sono ancora riferimenti, e come sempre, non dovreste mai restituire un riferimento ad un oggetto automatico; il chiamante si ritroverebbe con un riferimento penzolante se ingannaste il compilatore ad accettare il vostro codice, come questo:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Non restituite mai oggetti automatici tramite un riferimento rvalue. Lo spostamento viene eseguito esclusivamente dal costruttore move, non da std::move, e non legando semplicemente un rvalue a un riferimento rvalue.

Spostamento nei membri

Prima o poi, scriverete codice come questo:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Fondamentalmente, il compilatore si lamenterà che parameter è un lvalue. Se guardate il suo tipo, vedete un riferimento rvalue, ma un riferimento rvalue significa semplicemente “un riferimento che è legato a un rvalue”; non significa che il riferimento stesso sia un rvalue! Infatti, parameter è solo una normale variabile con un nome. Potete usare parameter tutte le volte che volete all’interno del corpo del costruttore, e denota sempre lo stesso oggetto. Spostarsi implicitamente da essa sarebbe pericoloso, quindi il linguaggio lo proibisce.

Un riferimento rvalue con nome è un lvalue, proprio come qualsiasi altra variabile.

La soluzione è abilitare manualmente lo spostamento:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

Si potrebbe sostenere che parameter non è più usato dopo l’inizializzazione di member. Perché non c’è una regola speciale per inserire silenziosamente std::move come per i valori di ritorno? Probabilmente perché sarebbe troppo pesante per gli implementatori del compilatore. Per esempio, cosa succederebbe se il corpo del costruttore fosse in un’altra unità di traduzione? Al contrario, la regola del valore di ritorno deve semplicemente controllare le tabelle dei simboli per determinare se l’identificatore dopo la parola chiave return denota o meno un oggetto automatico.

Si può anche passare il parameter per valore. Per i tipi solo movimento come unique_ptr, sembra che non ci sia ancora un idioma stabilito. Personalmente, preferisco passare per valore, poiché causa meno disordine nell’interfaccia.

Funzioni membro speciali

C++98 dichiara implicitamente tre funzioni membro speciali su richiesta, cioè quando sono necessarie da qualche parte: il costruttore di copia, l’operatore di assegnazione di copia e il distruttore.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

I riferimenti ai valori R hanno attraversato diverse versioni. Dalla versione 3.0, C++11 dichiara due ulteriori funzioni membro speciali su richiesta: il costruttore di spostamento e l’operatore di assegnazione di spostamento. Notate che né VC10 né VC11 sono ancora conformi alla versione 3.0, quindi dovrete implementarle voi stessi.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Queste due nuove funzioni membro speciali sono dichiarate implicitamente solo se nessuna delle funzioni membro speciali è dichiarata manualmente. Inoltre, se dichiarate il vostro costruttore di spostamento o l’operatore di assegnazione di spostamento, né il costruttore di copia né l’operatore di assegnazione di copia saranno dichiarati implicitamente.

Cosa significano queste regole in pratica?

Se scrivete una classe senza risorse non gestite, non c’è bisogno di dichiarare nessuna delle cinque funzioni membro speciali, e avrete la corretta semantica di copia e di spostamento gratuitamente. Altrimenti, dovrete implementare voi stessi le funzioni membro speciali. Naturalmente, se la vostra classe non beneficia della semantica di spostamento, non c’è bisogno di implementare le operazioni speciali di spostamento.

Nota che l’operatore di assegnazione di copia e l’operatore di assegnazione di spostamento possono essere fusi in un singolo operatore di assegnazione unificato, prendendo il suo argomento per valore:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

In questo modo, il numero di funzioni membro speciali da implementare scende da cinque a quattro. C’è un compromesso tra sicurezza delle eccezioni ed efficienza qui, ma non sono un esperto in materia.

Forwarding references (precedentemente noti come riferimenti universali)

Considerate il seguente template di funzione:

template<typename T>void foo(T&&);

Vi potreste aspettare che T&& si leghi solo a rvalori, perché a prima vista, sembra un riferimento a rvalori. Come si scopre però, T&& si lega anche a lvalori:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Se l’argomento è un rvalore di tipo X, T è dedotto essere X, quindi T&& significa X&&. Ma se l’argomento è un valore l di tipo X, a causa di una regola speciale, T è dedotto essere X&, quindi T&& significa qualcosa come X& &&. Ma poiché il C++ non ha ancora la nozione di riferimenti a riferimenti, il tipo X& && è collassato in X&. Questo può sembrare confuso e inutile all’inizio, ma il collasso dei riferimenti è essenziale per un perfetto inoltro (che non sarà discusso qui).

T&& non è un riferimento rvalue, ma un riferimento di inoltro. Si lega anche a lvalori, nel qual caso T e T&& sono entrambi riferimenti lvalue.

Se volete vincolare un template di funzione agli rvalori, potete combinare SFINAE con i type traits:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Implementazione di move

Ora che avete capito il collasso dei riferimenti, ecco come viene implementato std::move:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Come potete vedere, move accetta qualsiasi tipo di parametro grazie al riferimento di inoltro T&&, e restituisce un riferimento rvalue. La chiamata alla meta-funzione std::remove_reference<T>::type è necessaria perché altrimenti, per valori di tipo X, il tipo di ritorno sarebbe X& &&, che collasserebbe in X&. Poiché t è sempre un lvalue (ricordate che un riferimento ad un rvalue nominato è un lvalue), ma noi vogliamo legare t ad un riferimento ad un rvalue, dobbiamo esplicitamente lanciare t al tipo di ritorno corretto.La chiamata di una funzione che ritorna un riferimento ad un rvalue è essa stessa un xvalue. Ora sapete da dove vengono gli xvalori 😉

.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.