Hvad er move semantics?

Mit første svar var en ekstremt forenklet introduktion til move semantics, og mange detaljer blev udeladt med vilje for at holde det enkelt.Der er dog meget mere i move semantics, og jeg syntes, at det var på tide med endnu et svar for at udfylde hullerne.Det første svar er allerede ret gammelt, og det føltes ikke rigtigt at erstatte det med en helt anden tekst. Jeg synes stadig, at det fungerer godt som en første introduktion. Men hvis du vil grave dybere, så læs videre 🙂

Stephan T. Lavavej tog sig tid til at give værdifuld feedback. Mange tak, Stephan!

Indledning

Move-semantik gør det muligt for et objekt, under visse betingelser, at overtage ejerskabet af nogle andre objekters eksterne ressourcer. Dette er vigtigt på to måder:

  1. Dermed kan dyre kopier omdannes til billige flytninger. Se mit første svar for et eksempel. Bemærk, at hvis et objekt ikke administrerer mindst én ekstern ressource (enten direkte eller indirekte gennem dets medlemsobjekter), vil move-semantikken ikke give nogen fordele i forhold til kopisemantikken. I det tilfælde betyder kopiering af et objekt og flytning af et objekt nøjagtig det samme:

    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. Implementering af sikre “move-only”-typer; dvs. typer, for hvilke kopiering ikke giver mening, men flytning gør det. Eksempler omfatter låse, filhåndtag og smarte pointere med unik ejerskabssemantik. Bemærk: I dette svar diskuteres std::auto_ptr, en forældet skabelon fra C++98-standardbiblioteket, som blev erstattet af std::unique_ptr i C++11. Mellemliggende C++-programmører er sandsynligvis i det mindste nogenlunde bekendt med std::auto_ptr, og på grund af den “move-semantik”, den viser, virker den som et godt udgangspunkt for diskussionen af move-semantik i C++11. YMMV.

Hvad er et move?

C++98-standardbiblioteket tilbyder en smart pointer med en unik ejerskabssemantik kaldet std::auto_ptr<T>. Hvis du ikke er bekendt med auto_ptr, er dens formål at garantere, at et dynamisk allokeret objekt altid frigives, selv i tilfælde af undtagelser:

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

Det usædvanlige ved auto_ptr er dens “kopieringsadfærd”:

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

Bemærk, hvordan initialiseringen af b med a ikke kopierer trekanten, men i stedet overfører ejerskabet af trekanten fra a til b. Vi siger også “a er flyttet ind i b” eller “trekanten er flyttet fra a til b“. Dette kan lyde forvirrende, fordi selve trekanten altid forbliver det samme sted i hukommelsen.

At flytte et objekt betyder at overføre ejerskabet af en ressource, som det forvalter, til et andet objekt.

Kopieringskonstruktøren i auto_ptr ser sandsynligvis ud som følger (noget forenklet):

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

Farlige og harmløse flytninger

Den farlige ting ved auto_ptr er, at det, der syntaktisk ligner en kopi, i virkeligheden er en flytning. Hvis man forsøger at kalde en medlemsfunktion på en auto_ptr, der er flyttet fra, vil man påberåbe sig udefineret adfærd, så man skal være meget forsigtig med ikke at bruge en auto_ptr, efter at den er blevet flyttet fra:

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

Men auto_ptr er ikke altid farlig. Fabriksfunktioner er et helt fint anvendelsesområde for 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

Bemærk, hvordan begge eksempler følger det samme syntaktiske mønster:

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

Og alligevel påkalder den ene af dem udefineret adfærd, mens den anden ikke gør det. Hvad er så forskellen mellem udtrykkene a og make_triangle()? Er de ikke begge af samme type? Jo, det er de, men de har forskellige værdikategorier.

Værdikategorier

Der må åbenbart være en dybtgående forskel mellem udtrykket a, der betegner en auto_ptr-variabel, og udtrykket make_triangle(), der betegner kald af en funktion, der returnerer en auto_ptr som værdi og dermed skaber et nyt midlertidigt auto_ptr-objekt, hver gang den kaldes. a er et eksempel på en lvalue, mens make_triangle() er et eksempel på en rvalue.

Det er farligt at gå fra lværdier som a, fordi vi senere kan forsøge at kalde en medlemsfunktion via a og dermed påkalde udefineret adfærd. På den anden side er det helt sikkert at flytte fra rværdier som make_triangle(), for efter at kopikonstruktøren har gjort sit arbejde, kan vi ikke bruge det midlertidige igen. Der er intet udtryk, der betegner det nævnte midlertidige; hvis vi blot skriver make_triangle() igen, får vi et andet midlertidigt udtryk. Faktisk er det fraflyttede temporary allerede væk i den næste linje:

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

Bemærk, at bogstaverne l og r har en historisk oprindelse i venstre og højre side af en tildeling. Dette gælder ikke længere i C++, fordi der er lværdier, der ikke kan optræde på venstre side af en tildeling (som f.eks. arrays eller brugerdefinerede typer uden en tildelingsoperator), og der er rværdier, der kan (alle rværdier af klassetyper med en tildelingsoperator).

En rværdi af klassetype er et udtryk, hvis evaluering skaber et midlertidigt objekt. Under normale omstændigheder betegner intet andet udtryk inden for samme scope det samme midlertidige objekt.

Rvalue-referencer

Vi forstår nu, at flytning fra lvalues er potentielt farlig, men flytning fra rvalues er ufarlig. Hvis C++ havde sprogstøtte til at skelne lvalue-argumenter fra rvalue-argumenter, kunne vi enten helt forbyde flytning fra lvalues, eller i det mindste gøre flytning fra lvalues eksplicit på opkaldsstedet, så vi ikke længere flytter ved et uheld.

C++11’s svar på dette problem er rvalue-referencer. En rvalue-reference er en ny slags reference, der kun binder til rvalues, og syntaksen er X&&. Den gode gamle reference X& er nu kendt som en lvalue-reference. (Bemærk, at X&& ikke er en reference til en reference; sådan noget findes ikke i C++.)

Hvis vi smider const ind i blandingen, har vi allerede fire forskellige former for referencer. Hvilke slags udtryk af typen X kan de binde til?

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

I praksis kan du glemme alt om const X&&. Det er ikke særlig nyttigt at være begrænset til at læse fra rværdier.

En rværdi-reference X&& er en ny slags reference, der kun binder til rværdier.

Implicitte konverteringer

Rværdi-referencer har gennemgået flere versioner. Siden version 2.1 har en rvalue-reference X&& også bundet til alle værdikategorier af en anden type Y, forudsat at der er en implicit konvertering fra Y til X. I så fald oprettes der en midlertidig af typen X, og r-værdireferencen er bundet til denne midlertidige:

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

I ovenstående eksempel er "hello world" en l-værdi af typen const char. Da der er en implicit konvertering fra const char via const char* til std::string, oprettes der en midlertidig værdi af typen std::string, og r er bundet til denne midlertidige værdi. Dette er et af de tilfælde, hvor sondringen mellem rværdier (udtryk) og temporære (objekter) er en smule sløret.

Flyttekonstruktører

Et nyttigt eksempel på en funktion med en X&&-parameter er flyttekonstruktøren X::X(X&& source). Dens formål er at overføre ejerskabet af den administrerede ressource fra kilden til det aktuelle objekt.

I C++11 er std::auto_ptr<T> blevet erstattet af std::unique_ptr<T>, som udnytter rvalue-referencer. Jeg vil udvikle og diskutere en forenklet version af unique_ptr. Først indkapsler vi en rå pointer og overloader operatørerne -> og *, så vores klasse føles som en pointer:

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

Konstruktøren tager ejerskab af objektet, og destruktøren sletter det:

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

Nu kommer den interessante del, nemlig move-konstruktøren:

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

Denne move-konstruktør gør nøjagtigt det samme som auto_ptr kopikonstruktøren gjorde, men den kan kun leveres med rværdier:

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

Den anden linje kan ikke kompileres, fordi a er en lvalue, men parameteren unique_ptr&& source kan kun bindes til rværdier. Dette er præcis, hvad vi ønskede; farlige træk bør aldrig være implicitte. Den tredje linje kompileres helt fint, fordi make_triangle() er en r-værdi. Move-konstruktøren overfører ejerskabet fra den midlertidige til c. Igen er det præcis, hvad vi ønskede.

Fremflytningskonstruktøren overfører ejerskabet af en administreret ressource til det aktuelle objekt.

Fremflytningstildelingsoperatorer

Den sidste manglende brik er fremflytningstildelingsoperatoren. Dens opgave er at frigive den gamle ressource og erhverve den nye ressource fra dens argument:

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

Bemærk, hvordan denne implementering af move assignment-operatoren duplikerer logikken i både destruktoren og move-konstruktøren. Er du bekendt med copy-and-swap-idiomet? Det kan også anvendes på move-semantik som move-and-swap-idiom:

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

Nu da source er en variabel af typen unique_ptr, vil den blive initialiseret af move-konstruktøren; det vil sige, at argumentet vil blive flyttet ind i parameteren. Argumentet skal stadig være en r-værdi, fordi move-konstruktøren selv har en r-værdi-referenceparameter. Når kontrolstrømmen når den lukkende parentes i operator=, går source ud af anvendelsesområdet, hvorved den gamle ressource frigives automatisk.

Operatoren move assignment overfører ejerskabet af en administreret ressource til det aktuelle objekt, hvorved den gamle ressource frigives. Move-and-swap-idiomet forenkler implementeringen.

Flytning fra lvalues

I nogle tilfælde ønsker vi at flytte fra lvalues. Det vil sige, at vi nogle gange ønsker, at compileren skal behandle en lvalue som om det var en rvalue, så den kan påkalde move-konstruktøren, selv om det kan være potentielt usikkert. til dette formål tilbyder C++11 en skabelon for en standardbiblioteksfunktion kaldet std::move inde i header <utility>. dette navn er lidt uheldigt, fordi std::move blot caster en lvalue til en rvalue; den flytter ikke noget i sig selv. Den gør det blot muligt at flytte. Måske skulle den have heddet std::cast_to_rvalue eller std::enable_move, men vi sidder fast med navnet nu.

Her er hvordan du eksplicit flytter fra en lvalue:

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

Bemærk, at efter den tredje linje ejer a ikke længere en trekant. Det er i orden, for ved eksplicit at skrive std::move(a) gjorde vi vores intentioner klare: “Kære konstruktør, gør hvad du vil med a for at initialisere c; jeg er ligeglad med a længere. Feel free to have your way with a.”

std::move(some_lvalue) caster en lvalue til en rvalue, hvilket muliggør et efterfølgende træk.

Xvalues

Bemærk, at selv om std::move(a) er en rvalue, skaber dens evaluering ikke et midlertidigt objekt. Denne gåde tvang udvalget til at indføre en tredje værdikategori. Noget, der kan være bundet til en rvalue-reference, selv om det ikke er en rvalue i traditionel forstand, kaldes en xvalue (eXpiring value). De traditionelle rværdier blev omdøbt til prvalues (Pure rvalues).

Både prvalues og xvalues er rvalues. Xvalues og lvalues er begge glvalues (Generalized lvalues). Relationerne er nemmere at forstå med et diagram:

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

Bemærk, at kun xvalues er virkelig nye; resten skyldes blot omdøbning og gruppering.

C++98 rvalues er kendt som prvalues i C++11. Udskift mentalt alle forekomster af “rvalue” i de foregående afsnit med “prvalue”.

Bevægelse ud af funktioner

Så vidt vi har set bevægelse ind i lokale variabler og ind i funktionsparametre. Men flytning er også mulig i den modsatte retning. Hvis en funktion returnerer med værdi, initialiseres et eller andet objekt på kaldsstedet (sandsynligvis en lokal variabel eller et midlertidigt, men det kan være en hvilken som helst slags objekt) med udtrykket efter return-angivelsen som et argument til flyttekonstruktøren:

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

Måske overraskende kan automatiske objekter (lokale variabler, der ikke er deklareret som static) også implicit flyttes ud af funktioner:

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

Hvordan kan det være, at move-konstruktøren accepterer l-værdien result som argument? Anvendelsesområdet for result er ved at slutte, og den vil blive ødelagt under afviklingen af stakken. Ingen kunne bagefter klage over, at result havde ændret sig på en eller anden måde; når kontrolstrømmen er tilbage hos den, der kalder, eksisterer result ikke længere! Af den grund har C++11 en særlig regel, der gør det muligt at returnere automatiske objekter fra funktioner uden at skulle skrive std::move. Faktisk bør du aldrig bruge std::move til at flytte automatiske objekter ud af funktioner, da dette hæmmer “named return value optimization” (NRVO).

Brug aldrig std::move til at flytte automatiske objekter ud af funktioner.

Bemærk, at i begge fabriksfunktioner er returneringstypen en værdi og ikke en rvalue-reference. Rvalue-referencer er stadig referencer, og som altid bør du aldrig returnere en reference til et automatisk objekt; opkalderen ville ende med en dinglende reference, hvis du narrede compileren til at acceptere din kode, som her:

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

Returner aldrig automatiske objekter ved hjælp af en rvalue-reference. Flytning udføres udelukkende af move-konstruktøren, ikke af std::move, og ikke ved blot at binde en rvalue til en rvalue-reference.

Flytning til medlemmer

Før eller senere kommer du til at skrive kode som denne:

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

Grundlæggende vil compileren klage over, at parameter er en lvalue. Hvis du kigger på dens type, ser du en rvalue-reference, men en rvalue-reference betyder blot “en reference, der er bundet til en rvalue”; det betyder ikke, at referencen selv er en rvalue! Faktisk er parameter bare en almindelig variabel med et navn. Du kan bruge parameter lige så ofte du vil inden for konstruktorkroppen, og det betegner altid det samme objekt. Implicit at flytte fra den ville være farligt, og derfor forbyder sproget det.

En navngiven rvalue-reference er en lvalue, ligesom enhver anden variabel.

Løsningen er at aktivere flytningen manuelt:

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

Du kan argumentere for, at parameter ikke længere bruges efter initialiseringen af member. Hvorfor er der ikke en særlig regel til lydløst at indsætte std::move ligesom med returværdier? Sandsynligvis fordi det ville være en for stor byrde for compiler-implementatorerne. Hvad nu, hvis konstruktorkroppen f.eks. var i en anden oversættelsesenhed? I modsætning hertil skal reglen for returværdi blot kontrollere symboltabellerne for at afgøre, om identifikatoren efter nøgleordet return betegner et automatisk objekt.

Du kan også overdrage parameter som en værdi. For flytbare typer som unique_ptr ser det ud til, at der endnu ikke er noget etableret idiom. Personligt foretrækker jeg at overdrage ved værdi, da det giver mindre rod i grænsefladen.

Specielle medlemsfunktioner

C++98 deklarerer implicit tre specielle medlemsfunktioner på forespørgsel, dvs. når der er brug for dem et eller andet sted: kopi-konstruktøren, kopi-tildelingsoperatoren og destruktoren.

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

R-værdihenvisninger har gennemgået flere versioner. Siden version 3.0 har C++11 erklæret yderligere to specielle medlemsfunktioner on demand: move-konstruktøren og move-tildelingsoperatoren. Bemærk, at hverken VC10 eller VC11 er i overensstemmelse med version 3.0 endnu, så du bliver nødt til selv at implementere dem.

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

Disse to nye specielle medlemsfunktioner er kun implicit deklareret, hvis ingen af de specielle medlemsfunktioner er deklareret manuelt. Hvis du også deklarerer din egen move-konstruktør eller move-tildelingsoperator, vil hverken copy-konstruktøren eller copy-tildelingsoperatoren blive deklareret implicit.

Hvad betyder disse regler i praksis?

Hvis du skriver en klasse uden unmanaged resources, er det ikke nødvendigt at deklarere nogen af de fem specielle medlemsfunktioner selv, og du får korrekt copy-semantik og move-semantik gratis. Ellers skal du selv implementere de særlige medlemsfunktioner. Hvis din klasse ikke har gavn af move-semantik, er det naturligvis ikke nødvendigt at implementere de specielle move-operationer.

Bemærk, at kopitildelingsoperatoren og move-tildelingsoperatoren kan smeltes sammen til en enkelt, samlet tildelingsoperator, der tager sit argument som værdi:

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

Derved falder antallet af specielle medlemsfunktioner, der skal implementeres, fra fem til fire. Der er en afvejning mellem undtagelsessikkerhed og effektivitet her, men jeg er ikke ekspert i dette spørgsmål.

Forwarding-referencer (tidligere kendt som Universal-referencer)

Se på følgende funktionsskabelon:

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

Du forventer måske, at T&& kun binder til rværdier, fordi det ved første øjekast ligner en rværdi-reference. Det viser sig imidlertid, at T&& også binder til lværdier:

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

Hvis argumentet er en r-værdi af typen X, udledes det, at T er X, og derfor betyder T&& X&&. Men hvis argumentet er en l-værdi af typen X, så udledes T på grund af en særlig regel til at være X&, og derfor ville T&& betyde noget i retning af X& &&. Men da C++ stadig ikke har noget begreb om referencer til referencer, bliver typen X& && sammenklappet til X&. Dette kan i første omgang lyde forvirrende og ubrugeligt, men referencekollaps er afgørende for perfekt forwarding (som ikke vil blive diskuteret her).

T&& er ikke en rvalue-reference, men en forwarding-reference. Den binder også til lværdier, i hvilket tilfælde T og T&& begge er lværdi-referencer.

Hvis du ønsker at begrænse en funktionsskabelon til r-værdier, kan du kombinere SFINAE med type traits:

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

Implementering af move

Nu da du forstår reference-kollapsning, er her hvordan std::move er implementeret:

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

Som du kan se, accepterer move enhver form for parameter takket være videresendelsesreferencen T&&, og den returnerer en rvalue-reference. Meta-funktionskaldet std::remove_reference<T>::type er nødvendigt, fordi returneringstypen for lværdier af typen X ellers ville være X& && for lværdier af typen X, hvilket ville kollapse til X&. Da t altid er en lvalue (husk, at en navngiven rvalue-reference er en lvalue), men vi ønsker at binde t til en rvalue-reference, skal vi eksplicit kaste t til den korrekte returtype. kald af en funktion, der returnerer en rvalue-reference, er selv en xvalue. Nu ved du, hvor xvalues kommer fra 😉

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.