Ce este semantica de mutare?

Primul meu răspuns a fost o introducere extrem de simplificată în semantica de mutare și multe detalii au fost omise intenționat pentru a păstra simplitatea.Cu toate acestea, există mult mai multe lucruri legate de semantica de mutare și m-am gândit că a venit timpul pentru un al doilea răspuns pentru a umple golurile.Primul răspuns este deja destul de vechi și nu mi s-a părut corect să îl înlocuiesc pur și simplu cu un text complet diferit. Cred că încă servește bine ca o primă introducere. Dar dacă vrei să sapi mai adânc, citește mai departe 🙂

Stephan T. Lavavej și-a făcut timp să ofere un feedback valoros. Mulțumesc foarte mult, Stephan!

Introducere

Semantica de mișcare permite unui obiect, în anumite condiții, să preia proprietatea asupra resurselor externe ale unui alt obiect. Acest lucru este important în două moduri:

  1. Transformarea copiilor costisitoare în mutări ieftine. A se vedea primul meu răspuns pentru un exemplu. Rețineți că, dacă un obiect nu gestionează cel puțin o resursă externă (fie direct, fie indirect prin intermediul obiectelor sale membre), semantica de mutare nu va oferi niciun avantaj față de semantica de copiere. În acest caz, copierea unui obiect și mutarea unui obiect înseamnă exact același lucru:

    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. Implementarea tipurilor sigure „move-only”; adică, tipuri pentru care copierea nu are sens, dar mutarea are. Exemplele includ încuietori, mânere de fișiere și pointeri inteligenți cu semantică de proprietate unică. Notă: Acest răspuns discută std::auto_ptr, un șablon depreciat al bibliotecii standard C++98, care a fost înlocuit cu std::unique_ptr în C++11. Programatorii intermediari C++ sunt probabil cel puțin oarecum familiarizați cu std::auto_ptr și, datorită „semanticii de mutare” pe care o afișează, pare a fi un bun punct de plecare pentru a discuta despre semantica de mutare în C++11. YMMV.

Ce este o mutare?

Biblioteca standard C++98 oferă un pointer inteligent cu o semantică de proprietate unică numit std::auto_ptr<T>. În cazul în care nu sunteți familiarizați cu auto_ptr, scopul său este de a garanta că un obiect alocat dinamic este întotdeauna eliberat, chiar și în fața unor excepții:

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

Ceea ce este neobișnuit la auto_ptr este comportamentul său de „copiere”:

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

Rețineți cum inițializarea lui b cu a nu copiază triunghiul, ci în schimb transferă proprietatea triunghiului de la a la b. De asemenea, spunem „a este mutat în b” sau „triunghiul este mutat din a în b„. Acest lucru poate părea confuz, deoarece triunghiul în sine rămâne întotdeauna în același loc în memorie.

Mutarea unui obiect înseamnă transferul proprietății unor resurse pe care le gestionează către un alt obiect.

Constructorul de copiere al lui auto_ptr arată probabil ceva de genul acesta (oarecum simplificat):

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

Mutații periculoase și inofensive

Ceea ce este periculos la auto_ptr este că ceea ce sintactic pare a fi o copiere este de fapt o mutare. Încercarea de a apela o funcție membră pe un auto_ptr mutat din auto_ptr va invoca un comportament nedefinit, așa că trebuie să fiți foarte atenți să nu folosiți un auto_ptr după ce a fost mutat din:

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

Dar auto_ptr nu este întotdeauna periculos. Funcțiile de fabrică sunt un caz de utilizare perfect pentru 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

Rețineți cum ambele exemple urmează același model sintactic:

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

Și totuși, unul dintre ele invocă un comportament nedefinit, în timp ce celălalt nu o face. Așadar, care este diferența dintre expresiile a și make_triangle()? Nu sunt ambele de același tip? Într-adevăr, ele sunt, dar au categorii de valori diferite.

Categorii de valori

Evident, trebuie să existe o diferență profundă între expresia a care denotă o variabilă auto_ptr și expresia make_triangle() care denotă apelarea unei funcții care returnează o auto_ptr prin valoare, creând astfel un nou obiect temporar auto_ptr de fiecare dată când este apelată. a este un exemplu de lvaloare, în timp ce make_triangle() este un exemplu de rvaloare.

Mutarea de la lvalori precum a este periculoasă, deoarece am putea încerca ulterior să apelăm o funcție membră prin intermediul lui a, invocând un comportament nedefinit. Pe de altă parte, mutarea de la rvalues, cum ar fi make_triangle(), este perfect sigură, deoarece, după ce constructorul de copiere și-a făcut treaba, nu mai putem utiliza temporar. Nu există nicio expresie care să denumească respectivul temporar; dacă scriem pur și simplu make_triangle() din nou, obținem un alt temporar. De fapt, temporarul mutat de la temporar a dispărut deja pe linia următoare:

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

Rețineți că literele l și r au o origine istorică în partea stângă și partea dreaptă a unei atribuiri. Acest lucru nu mai este valabil în C++, deoarece există lvalori care nu pot apărea în partea stângă a unei atribuiri (cum ar fi array-urile sau tipurile definite de utilizator fără operator de atribuire) și există rvalori care pot (toate rvalorile de tip clasă cu operator de atribuire).

O rvaloare de tip clasă este o expresie a cărei evaluare creează un obiect temporar. În condiții normale, nici o altă expresie din interiorul aceluiași domeniu de aplicare nu denotă același obiect temporar.

Referințe la rvalori

Acum înțelegem că trecerea de la lvalori este potențial periculoasă, dar trecerea de la rvalori este inofensivă. Dacă C++ ar avea suport de limbaj pentru a distinge argumentele lvaloare de argumentele rvaloare, am putea fie să interzicem complet mutarea de la lvaloare, fie cel puțin să facem ca mutarea de la lvaloare să fie explicită la locul apelului, astfel încât să nu ne mai mutăm din greșeală.

Răspunsul lui C++11 la această problemă sunt referințele rvaloare. O referință rvalue este un nou tip de referință care se leagă numai de rvalues, iar sintaxa este X&&. Buna și vechea referință X& este acum cunoscută sub numele de referință lvalue. (Rețineți că X&& nu este o referință la o referință; nu există așa ceva în C++.)

Dacă aruncăm const în amestec, avem deja patru tipuri diferite de referințe. La ce tipuri de expresii de tip X se pot lega acestea?

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

În practică, puteți uita de const X&&. A fi restricționat la citirea din rvalori nu este foarte util.

O referință rvaloare X&& este un nou tip de referință care se leagă numai de rvalori.

Conversii implicite

Referințele rvaloare au trecut prin mai multe versiuni. Începând cu versiunea 2.1, o referință rvalue X&& se leagă, de asemenea, de toate categoriile de valori de un tip diferit Y, cu condiția să existe o conversie implicită de la Y la X. În acest caz, se creează un temporar de tip X, iar referința rvalue este legată de acel temporar:

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

În exemplul de mai sus, "hello world" este o lvalue de tip const char. Deoarece există o conversie implicită de la const char prin const char* la std::string, se creează un temporar de tip std::string, iar r este legat de acest temporar. Acesta este unul dintre cazurile în care distincția dintre rvalori (expresii) și temporare (obiecte) este puțin neclară.

Constructorii de mutare

Un exemplu util de funcție cu parametru X&& este constructorul de mutare X::X(X&& source). Scopul său este de a transfera proprietatea resursei gestionate de la sursă în obiectul curent.

În C++11, std::auto_ptr<T> a fost înlocuit cu std::unique_ptr<T> care profită de referințele rvalue. Voi dezvolta și discuta o versiune simplificată a unique_ptr. Mai întâi, încapsulăm un pointer brut și supraîncărcăm operatorii -> și *, astfel încât clasa noastră să se simtă ca un pointer:

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

Constructorul preia proprietatea obiectului, iar destructorul îl șterge:

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

Acum vine partea interesantă, constructorul de mutare:

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

Acest constructor de mutare face exact ceea ce a făcut constructorul de copiere auto_ptr, dar nu poate fi furnizat decât cu valori r:

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

A doua linie nu se compilează, deoarece a este o valoare l, dar parametrul unique_ptr&& source poate fi legat numai de valori r. Acest lucru este exact ceea ce am dorit; mișcările periculoase nu ar trebui să fie niciodată implicite. A treia linie se compilează foarte bine, deoarece make_triangle() este o valoare r. Constructorul de mutare va transfera proprietatea de la temporar la c. Din nou, acest lucru este exact ceea ce am vrut.

Constructorul de mutare transferă proprietatea unei resurse gestionate în obiectul curent.

Operatori de atribuire a mutării

Ultima piesă care lipsește este operatorul de atribuire a mutării. Sarcina sa este de a elibera vechea resursă și de a achiziționa noua resursă din argumentul său:

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

Rețineți cum această implementare a operatorului de atribuire a mutării dublează logica atât a destructorului, cât și a constructorului de mutare. Sunteți familiarizați cu idiomul copy-and-swap? Acesta poate fi aplicat și la semantica de mutare sub forma idiomului move-and-swap:

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

Acum că source este o variabilă de tip unique_ptr, aceasta va fi inițializată de constructorul move; adică argumentul va fi mutat în parametru. Argumentul trebuie să fie în continuare o valoare r, deoarece constructorul de mutare are el însuși un parametru de referință rvalue. Când fluxul de control ajunge la breteaua de închidere a operator=, source iese din sfera de cuprindere, eliberând automat vechea resursă.

Operatorul de atribuire move transferă proprietatea unei resurse gestionate în obiectul curent, eliberând vechea resursă. Idiomul move-and-swap simplifică implementarea.

Mutarea de la lvalori

Cîteodată, dorim să mutăm de la lvalori. Adică, uneori dorim ca compilatorul să trateze o lvaloare ca și cum ar fi o rvaloare, astfel încât să poată invoca constructorul de mutare, chiar dacă ar putea fi potențial nesigur.În acest scop, C++11 oferă un șablon de funcție de bibliotecă standard numit std::move în interiorul antetului <utility>.Acest nume este puțin nefericit, deoarece std::move pur și simplu transformă o lvaloare într-o rvaloare; nu mută nimic de la sine. Pur și simplu permite mutarea. Poate că ar fi trebuit să se numească std::cast_to_rvalue sau std::enable_move, dar deocamdată suntem blocați cu acest nume.

Iată cum se face deplasarea explicită de la o valoare l:

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

Rețineți că, după a treia linie, a nu mai deține un triunghi. Este în regulă, pentru că, scriind explicit std::move(a), ne-am exprimat clar intențiile: „Dragă constructorule, fă ce vrei cu a pentru a inițializa c; mie nu-mi mai pasă de a. Simțiți-vă liber să faceți ce vreți cu a.”

std::move(some_lvalue) transformă o valoare l într-o valoare r, permițând astfel o mutare ulterioară.

Xvalues

Rețineți că, deși std::move(a) este o valoare r, evaluarea sa nu creează un obiect temporar. Această enigmă a forțat comitetul să introducă o a treia categorie de valori. Ceva care poate fi legat de o referință rvalue, chiar dacă nu este o rvalue în sensul tradițional, se numește xvalue (eXpiring value). Valorile r tradiționale au fost redenumite prvalues (Pure rvalues).

Atât prvalues cât și xvalues sunt rvalues. Xvalues și lvalues sunt ambele glvalues (Generalized lvalues). Relațiile sunt mai ușor de înțeles cu ajutorul unei diagrame:

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

Rețineți că numai xvalues sunt cu adevărat noi; restul se datorează doar redenumirii și grupării.

C++98 rvalues sunt cunoscute ca prvalues în C++11. Înlocuiți mental toate aparițiile lui „rvalue” din paragrafele precedente cu „prvalue”.

Mutarea în afara funcțiilor

Până acum, am văzut mișcarea în variabilele locale și în parametrii funcțiilor. Dar deplasarea este posibilă și în direcția opusă. Dacă o funcție se returnează prin valoare, un obiect la locul apelului (probabil o variabilă locală sau una temporară, dar poate fi orice tip de obiect) este inițializat cu expresia de după instrucțiunea return ca argument pentru constructorul de mutare:

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

Poate surprinzător, obiectele automate (variabilele locale care nu sunt declarate ca static) pot fi, de asemenea, mutate implicit în afara funcțiilor:

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

Cum se face că constructorul move acceptă ca argument lvaloarea result? Domeniul de aplicare al lui result este pe cale să se termine și va fi distrus în timpul derulării stivei. Nimeni nu s-ar putea plânge după aceea că result s-a schimbat cumva; când fluxul de control se întoarce la apelant, result nu mai există! Din acest motiv, C++11 are o regulă specială care permite returnarea obiectelor automate din funcții fără a fi nevoie să se scrie std::move. De fapt, nu ar trebui să folosiți niciodată std::move pentru a muta obiecte automate în afara funcțiilor, deoarece acest lucru inhibă „optimizarea valorii de retur numite” (NRVO).

Nu folosiți niciodată std::move pentru a muta obiecte automate în afara funcțiilor.

Rețineți că în ambele funcții fabrică, tipul de retur este o valoare, nu o referință rvalue. Referințele rvalue sunt tot referințe și, ca întotdeauna, nu ar trebui să returnați niciodată o referință la un obiect automat; apelantul s-ar trezi cu o referință atârnată dacă ați păcăli compilatorul să vă accepte codul, astfel:

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

Nu returnați niciodată obiecte automate prin referință rvalue. Mutarea este efectuată exclusiv de constructorul move, nu de std::move, și nu prin simpla legare a unei valori r la o referință de valoare r.

Mutarea în membri

Mai devreme sau mai târziu, veți scrie un cod ca acesta:

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

În principiu, compilatorul se va plânge că parameter este o valoare l. Dacă vă uitați la tipul său, veți vedea o referință rvalue, dar o referință rvalue înseamnă pur și simplu „o referință care este legată de o rvalue”; nu înseamnă că referința în sine este o rvalue! Într-adevăr, parameter este doar o variabilă obișnuită cu un nume. Puteți utiliza parameter ori de câte ori doriți în interiorul corpului constructorului, iar aceasta denotă întotdeauna același obiect. Mutarea implicită din ea ar fi periculoasă, de aceea limbajul o interzice.

O referință cu nume de rvaloare este o lvaloare, la fel ca orice altă variabilă.

Soluția este de a activa manual mutarea:

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

Ați putea argumenta că parameter nu mai este folosită după inițializarea lui member. De ce nu există o regulă specială pentru a introduce silențios std::move la fel ca în cazul valorilor de retur? Probabil pentru că ar fi o povară prea mare pentru implementatorii de compilatoare. De exemplu, ce s-ar întâmpla dacă corpul constructorului s-ar afla într-o altă unitate de traducere? În schimb, regula privind valoarea de returnare trebuie pur și simplu să verifice tabelele de simboluri pentru a determina dacă identificatorul de după cuvântul cheie return denotă sau nu un obiect automat.

De asemenea, puteți trece parameter prin valoare. Pentru tipurile de tip „move-only”, cum ar fi unique_ptr, se pare că nu există încă un idiom stabilit. Personal, prefer să trec prin valoare, deoarece provoacă mai puțină dezordine în interfață.

Funcții membre speciale

C++98 declară implicit trei funcții membre speciale la cerere, adică atunci când sunt necesare undeva: constructorul de copiere, operatorul de atribuire de copiere și destructorul.

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

Referințele la valorile R au trecut prin mai multe versiuni. Începând cu versiunea 3.0, C++11 declară două funcții membre speciale suplimentare la cerere: constructorul de mutare și operatorul de atribuire de mutare. Rețineți că nici VC10, nici VC11 nu sunt încă conforme cu versiunea 3.0, așa că va trebui să le implementați singur.

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

Aceste două noi funcții membre speciale sunt declarate implicit numai dacă niciuna dintre funcțiile membre speciale nu este declarată manual. De asemenea, dacă vă declarați propriul constructor de mutare sau operator de atribuire de mutare, nici constructorul de copiere și nici operatorul de atribuire de copiere nu vor fi declarate implicit.

Ce înseamnă aceste reguli în practică?

Dacă scrieți o clasă fără resurse neadministrate, nu este nevoie să declarați dumneavoastră niciuna dintre cele cinci funcții membre speciale și veți obține semantica corectă de copiere și semantica de mutare în mod gratuit. În caz contrar, va trebui să implementați singur funcțiile membre speciale. Desigur, dacă clasa dvs. nu beneficiază de semantica de mutare, nu este nevoie să implementați operațiile speciale de mutare.

Rețineți că operatorul de atribuire de copiere și operatorul de atribuire de mutare pot fi fuzionate într-un singur operator de atribuire unificat, luându-și argumentul prin valoare:

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

În acest fel, numărul de funcții membre speciale de implementat scade de la cinci la patru. Există aici un compromis între siguranța excepțiilor și eficiență, dar nu sunt expert în această problemă.

Referințe de redirecționare (cunoscute anterior sub numele de referințe universale)

Considerați următorul șablon de funcție:

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

Ați putea să vă așteptați ca T&& să se lege numai la rvalori, deoarece la prima vedere, arată ca o referință la rvalori. Se pare însă că T&& se leagă și de lvalorile:

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>&

Dacă argumentul este o rvaloare de tip X, se deduce că T este X, deci T&& înseamnă X&&. Este ceea ce oricine s-ar aștepta.Dar dacă argumentul este o valoare l de tip X, datorită unei reguli speciale, T se deduce că T este X&, prin urmare T&& ar însemna ceva de genul X& &&. Dar, deoarece C++ nu are încă noțiunea de referințe la referințe, tipul X& && este transformat în X&. Acest lucru poate părea confuz și inutil la început, dar colapsarea referințelor este esențială pentru o redirecționare perfectă (care nu va fi discutată aici).

T&& nu este o referință de rvaloare, ci o referință de redirecționare. De asemenea, se leagă la lvalori, caz în care T și T&& sunt ambele referințe la lvalori.

Dacă doriți să constrângeți un șablon de funcție la rvalori, puteți combina SFINAE cu trăsături de tip:

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

Implementarea mutării

Acum că ați înțeles colapsul referințelor, iată cum este implementat std::move:

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

După cum puteți vedea, move acceptă orice tip de parametru datorită referinței de redirecționare T&& și returnează o referință rvaloare. Apelul la metafuncția std::remove_reference<T>::type este necesar deoarece altfel, pentru lvalorile de tip X, tipul de returnare ar fi X& &&, care s-ar prăbuși în X&. Deoarece t este întotdeauna o lvaloare (rețineți că o referință numită rvaloare este o lvaloare), dar dorim să legăm t de o referință rvaloare, trebuie să distribuim în mod explicit t în tipul de retur corect.Apelul unei funcții care returnează o referință rvaloare este el însuși o xvaloare. Acum știți de unde vin valorile x 😉

.

Lasă un răspuns

Adresa ta de email nu va fi publicată.