Vad är move semantics?

Mitt första svar var en extremt förenklad introduktion till move semantics, och många detaljer utelämnades med flit för att hålla det enkelt.Det finns dock mycket mer att göra med move semantics, och jag tyckte att det var dags för ett andra svar för att fylla luckorna.Det första svaret är redan ganska gammalt, och det kändes inte rätt att helt enkelt ersätta det med en helt annan text. Jag tycker att det fortfarande fungerar bra som en första introduktion. Men om du vill gräva djupare, läs vidare 🙂

Stephan T. Lavavej tog sig tid att ge värdefull feedback. Tack så mycket, Stephan!

Introduktion

Förflyttningssemantiken gör det möjligt för ett objekt att under vissa förutsättningar ta över äganderätten till ett annat objekts externa resurser. Detta är viktigt på två sätt:

  1. Det gör dyra kopior till billiga moves. Se mitt första svar för ett exempel. Observera att om ett objekt inte hanterar minst en extern resurs (antingen direkt eller indirekt genom sina medlemsobjekt) kommer flyttsemantiken inte att erbjuda några fördelar jämfört med kopiesemantiken. I det fallet innebär kopiering av ett objekt och flyttning av ett objekt exakt samma sak:

    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. Införandet av säkra ”move-only”-typer; det vill säga typer för vilka kopiering inte är meningsfullt, men flyttning gör det. Exempel är lås, filhanteringar och smarta pekare med unik ägarsemantik. Anmärkning: I det här svaret diskuteras std::auto_ptr, en föråldrad mall för standardbiblioteket i C++98, som ersattes av std::unique_ptr i C++11. C++-programmerare på mellannivå är förmodligen åtminstone någorlunda bekanta med std::auto_ptr, och på grund av den ”flyttsemantik” som den uppvisar verkar den vara en bra utgångspunkt för att diskutera flyttsemantik i C++11. YMMV.

Vad är ett move?

Standardbiblioteket C++98 erbjuder en smart pekare med unik ägarsemantik kallad std::auto_ptr<T>. Om du inte är bekant med auto_ptr är dess syfte att garantera att ett dynamiskt allokerat objekt alltid släpps, även vid undantag:

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

Det ovanliga med auto_ptr är dess ”kopieringsbeteende”:

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

Bemärk hur initialiseringen av b med a inte kopierar triangeln, utan i stället överför äganderätten till triangeln från a till b. Vi säger också ”a flyttas till b” eller ”triangeln flyttas från a till b”. Detta kan låta förvirrande eftersom själva triangeln alltid stannar på samma plats i minnet.

Att flytta ett objekt innebär att överföra äganderätten till någon resurs som det förvaltar till ett annat objekt.

Kopieringskonstruktorn för auto_ptr ser förmodligen ut ungefär så här (något förenklat):

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

Farliga och ofarliga förflyttningar

Det farliga med auto_ptr är att det som syntaktiskt sett ser ut som en kopia i själva verket är en förflyttning. Att försöka anropa en medlemsfunktion på en auto_ptr som flyttats från kommer att åberopa odefinierat beteende, så du måste vara mycket försiktig med att använda en auto_ptr efter att den har flyttats från:

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

Men auto_ptr är inte alltid farligt. Fabriksfunktioner är ett alldeles utmärkt användningsområde för 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 hur båda exemplen följer samma syntaktiska mönster:

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

Och ändå åberopar ett av dem ett odefinierat beteende, medan det andra inte gör det. Vad är då skillnaden mellan uttrycken a och make_triangle()? Är de inte båda av samma typ? Jo, det är de, men de har olika värdekategorier.

Värdekategorier

Oppenbart måste det finnas någon djupgående skillnad mellan uttrycket a som betecknar en auto_ptr-variabel och uttrycket make_triangle() som betecknar anropet av en funktion som returnerar en auto_ptr som värde, och som därmed skapar ett nytt tillfälligt auto_ptr-objekt varje gång den anropas. a är ett exempel på ett lvärde, medan make_triangle() är ett exempel på ett rvärde.

Att gå från lvärden som a är farligt, eftersom vi senare skulle kunna försöka anropa en medlemsfunktion via a och därmed åberopa odefinierat beteende. Å andra sidan är det helt säkert att flytta från rvalues som make_triangle(), eftersom vi inte kan använda den tillfälliga funktionen igen efter att kopieringskonstruktören har gjort sitt jobb. Det finns inget uttryck som betecknar nämnda temporär; om vi helt enkelt skriver make_triangle() igen får vi en annan temporär. Faktum är att det flyttade temporära redan är borta på nästa rad:

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

Bemärk att bokstäverna l och r har ett historiskt ursprung i vänster och höger sida av ett uppdrag. Detta gäller inte längre i C++, eftersom det finns lvärden som inte kan förekomma på vänster sida av ett uppdrag (som matriser eller användardefinierade typer utan en tilldelningsoperator) och det finns rvärden som kan (alla rvärden av klasstyper med en tilldelningsoperator).

Ett rvärde av klasstyp är ett uttryck vars utvärdering skapar ett tillfälligt objekt. Under normala omständigheter betecknar inget annat uttryck inom samma scope samma temporära objekt.

R-värdereferenser

Vi förstår nu att det är potentiellt farligt att flytta från lvalues, men att flytta från rvalues är ofarligt. Om C++ hade språkstöd för att skilja lvalue-argument från rvalue-argument skulle vi antingen helt kunna förbjuda flyttning från lvalues, eller åtminstone göra flyttning från lvalues explicit vid anropsstället, så att vi inte längre flyttar av misstag.

C++11:s svar på detta problem är rvalue-referenser. En rvalue-referens är en ny typ av referens som endast binder till rvalues, och syntaxen är X&&. Den gamla goda referensen X& är nu känd som en lvalue-referens. (Observera att X&& inte är en referens till en referens; det finns inget sådant i C++.)

Om vi slänger in const i mixen har vi redan fyra olika typer av referenser. Vilka typer av uttryck av typ X kan de binda till?

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

I praktiken kan du glömma const X&&. Att vara begränsad till att läsa från rvalues är inte särskilt användbart.

En rvalue-referens X&& är en ny typ av referens som endast binder till rvalues.

Implicita konverteringar

Rvalue-referenser gick igenom flera versioner. Sedan version 2.1 binder en rvalue-referens X&& också till alla värdekategorier av en annan typ Y, förutsatt att det finns en implicit konvertering från Y till X. I det fallet skapas ett temporärt värde av typen X och r-värdereferensen är bunden till det temporära värdet:

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

I exemplet ovan är "hello world" ett l-värde av typen const char. Eftersom det finns en implicit konvertering från const char via const char* till std::string skapas ett temporärt värde av typen std::string och r binds till det temporära värdet. Detta är ett av de fall där skillnaden mellan rvalues (uttryck) och temporärer (objekt) är lite suddig.

Förflyttningskonstruktörer

Ett användbart exempel på en funktion med en X&&-parameter är flyttningskonstruktören X::X(X&& source). Dess syfte är att överföra äganderätten till den hanterade resursen från källan till det aktuella objektet.

I C++11 har std::auto_ptr<T> ersatts av std::unique_ptr<T> som drar nytta av rvalue-referenser. Jag kommer att utveckla och diskutera en förenklad version av unique_ptr. Först kapslar vi in en råpekare och överbelastar operatörerna -> och *, så att vår klass känns som en pekare:

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

Konstruktören tar äganderätt till objektet och destruktorn raderar det:

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

Nu kommer den intressanta delen, move-konstruktören:

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

Denna move-konstruktör gör exakt samma sak som auto_ptr-kopieringskonstruktören gjorde, men den kan bara förses med r-värden:

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

Den andra raden går inte att kompilera, eftersom a är ett l-värde, men parametern unique_ptr&& source kan bara bindas till r-värden. Detta är precis vad vi ville; farliga förflyttningar bör aldrig vara implicita. Den tredje raden kompileras utan problem, eftersom make_triangle() är ett r-värde. Flyttkonstruktören kommer att överföra äganderätten från det tillfälliga värdet till c. Återigen är detta exakt vad vi ville.

Förflyttningskonstruktören överför äganderätten till en hanterad resurs till det aktuella objektet.

Förflyttningstilldelningsoperatorer

Den sista biten som saknas är flyttningstilldelningsoperatorn. Dess uppgift är att släppa den gamla resursen och förvärva den nya resursen från dess 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 hur denna implementering av move assignment-operatorn duplicerar logik från både destruktorn och move-konstruktorn. Är du bekant med copy-and-swap-idiomet? Det kan också appliceras på move-semantiken som move-and-swap-idiomet:

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

Nu när source är en variabel av typen unique_ptr kommer den att initialiseras av move-konstruktören; det vill säga argumentet kommer att flyttas in i parametern. Argumentet måste fortfarande vara ett r-värde, eftersom flyttkonstruktören själv har en r-värde-referensparameter. När kontrollflödet når den avslutande parentesen i operator= går source ur räckvidden, vilket frigör den gamla resursen automatiskt.

Operatorn move assignment överför äganderätten till en hanterad resurs till det aktuella objektet, vilket frigör den gamla resursen. Idiomet move-and-swap förenklar implementeringen.

Förflyttning från lvalues

Ibland vill vi flytta från lvalues. Det vill säga, ibland vill vi att kompilatorn ska behandla ett lvärde som om det vore ett rvärde, så att den kan åberopa move-konstruktorn, även om det kan vara potentiellt osäkert. för det här ändamålet erbjuder C++11 en funktionsmall i standardbiblioteket som heter std::move inne i header <utility>. det här namnet är lite olyckligt, eftersom std::move helt enkelt kastar ett lvärde till ett rvärde; den flyttar inte något i sig själv. Den gör det bara möjligt att flytta. Kanske borde den ha hetat std::cast_to_rvalue eller std::enable_move, men vi har fastnat för namnet vid det här laget.

Här ser du hur du explicit flyttar från ett l-värde:

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

Bemärk att efter den tredje raden äger a inte längre en triangel. Det är okej, för genom att explicit skriva std::move(a) gjorde vi våra avsikter tydliga: ”Kära konstruktör, gör vad du vill med a för att initialisera c; jag bryr mig inte om a längre. Feel free to have your way with a.”

std::move(some_lvalue) kastar ett lvärde till ett rvärde, vilket möjliggör en efterföljande förflyttning.

Xvalues

Bemärk att även om std::move(a) är ett rvärde, skapar dess utvärdering inte ett tillfälligt objekt. Denna gåta tvingade kommittén att införa en tredje värdekategori. Något som kan bindas till en rvalue-referens, även om det inte är ett rvalue i traditionell mening, kallas xvalue (eXpiring value). De traditionella rvalues döptes om till prvalues (Pure rvalues).

Både prvalues och xvalues är rvalues. Xvalues och lvalues är båda glvalues (Generalized lvalues). Relationerna är lättare att förstå med ett diagram:

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

Bemärk att endast xvalues är riktigt nya; resten beror bara på omdöpning och gruppering.

C++98 rvalues är kända som prvalues i C++11. Ersätt mentalt alla förekomster av ”rvalue” i föregående stycken med ”prvalue”.

Förflyttning ut ur funktioner

Sedan tidigare har vi sett förflyttning till lokala variabler och till funktionsparametrar. Men flyttning är också möjlig i motsatt riktning. Om en funktion returnerar med ett värde, initialiseras något objekt på anropsplatsen (troligen en lokal variabel eller en temporär, men det kan vara vilken typ av objekt som helst) med uttrycket efter return-angivelsen som ett argument till flyttkonstruktören:

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

Kanske överraskande nog kan automatiska objekt (lokala variabler som inte är deklarerade som static) också implicit flyttas ut ur funktioner:

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

Hur kommer det sig att flyttkonstruktorn accepterar lvärdet result som ett argument? Omfattningen av result är på väg att ta slut, och den kommer att förstöras under avvecklingen av stapeln. Ingen kan möjligen klaga i efterhand på att result hade förändrats på något sätt; när kontrollflödet är tillbaka hos anroparen existerar result inte längre! Av den anledningen har C++11 en särskild regel som gör det möjligt att returnera automatiska objekt från funktioner utan att behöva skriva std::move. Faktum är att du aldrig bör använda std::move för att flytta automatiska objekt ut ur funktioner, eftersom detta hämmar ”named return value optimization” (NRVO).

Använd aldrig std::move för att flytta automatiska objekt ut ur funktioner.

Bemärk att i de båda fabriksfunktionerna är returtypen ett värde, inte en rvalue-referens. Rvalue-referenser är fortfarande referenser, och som alltid bör du aldrig returnera en referens till ett automatiskt objekt; anroparen skulle få en hängande referens om du lurade kompilatorn att acceptera din kod, som här:

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

Returnera aldrig automatiska objekt med rvalue-referens. Flyttning utförs uteslutande av move-konstruktören, inte av std::move, och inte heller genom att bara binda ett r-värde till en r-värdereferens.

Flyttning in i medlemmar

Förr eller senare kommer du att skriva kod som denna:

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

I grund och botten kommer kompilatorn att klaga på att parameter är ett l-värde. Om du tittar på dess typ ser du en rvalue-referens, men en rvalue-referens betyder helt enkelt ”en referens som är bunden till ett rvalue”; det betyder inte att själva referensen är ett rvalue! I själva verket är parameter bara en vanlig variabel med ett namn. Du kan använda parameter hur ofta som helst i konstruktorkroppen, och den betecknar alltid samma objekt. Att implicit flytta från den skulle vara farligt, därför förbjuder språket det.

En namngiven r-värdereferens är ett l-värde, precis som vilken annan variabel som helst.

Lösningen är att manuellt aktivera flytten:

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

Du skulle kunna argumentera för att parameter inte längre används efter initialiseringen av member. Varför finns det ingen särskild regel för att tyst infoga std::move precis som med returvärden? Förmodligen för att det skulle bli en alltför stor börda för kompilatorernas implementerare. Vad händer till exempel om konstruktorkroppen ligger i en annan översättningsenhet? Däremot behöver regeln för returvärde helt enkelt kontrollera symboltabellerna för att avgöra om identifieraren efter nyckelordet return betecknar ett automatiskt objekt eller inte.

Du kan också lämna över parameter som värde. För flyttbara typer som unique_ptr verkar det inte finnas något etablerat idiom ännu. Personligen föredrar jag att passera genom värde, eftersom det orsakar mindre oreda i gränssnittet.

Speciella medlemsfunktioner

C++98 deklarerar implicit tre speciella medlemsfunktioner på begäran, det vill säga när de behövs någonstans: kopiekonstruktören, kopietilldelningsoperatorn och destruktorn.

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

R-värdereferenser genomgick flera versioner. Sedan version 3.0 deklarerar C++11 ytterligare två speciella medlemsfunktioner på begäran: move-konstruktören och move-tilldelningsoperatorn. Observera att varken VC10 eller VC11 överensstämmer med version 3.0 ännu, så du måste implementera dem själv.

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

Dessa två nya speciella medlemsfunktioner deklareras endast implicit om ingen av de speciella medlemsfunktionerna deklareras manuellt. Om du dessutom deklarerar din egen flyttkonstruktör eller flytttilldelningsoperator kommer varken kopieringskonstruktören eller kopieringstilldelningsoperatorn att deklareras implicit.

Vad innebär dessa regler i praktiken?

Om du skriver en klass utan icke hanterade resurser finns det inget behov av att själv deklarera någon av de fem särskilda medlemsfunktionerna, och du får korrekt kopieringssemantik och flyttningssemantik gratis. I annat fall måste du implementera de speciella medlemsfunktionerna själv. Om din klass inte drar nytta av flyttsemantiken behöver du naturligtvis inte implementera de speciella flyttoperationerna.

Bemärk att kopieringsoperatorn och flyttningsoperatorn kan smältas samman till en enda, enhetlig tilldelningsoperator, som tar sitt argument som värde:

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

På så sätt sjunker antalet speciella medlemsfunktioner som ska implementeras från fem till fyra. Det finns en avvägning mellan undantagssäkerhet och effektivitet här, men jag är ingen expert på den här frågan.

Forwarding referenser (tidigare kända som Universal referenser)

Tänk på följande funktionsmall:

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

Du kanske förväntar dig att T&& endast ska binda till rvalues, eftersom det vid första anblicken ser ut som en rvalue-referens. Det visar sig dock att T&& även binder till lvalues:

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

Om argumentet är ett r-värde av typen X, härleds T till X, och därför betyder T&& X&&. Men om argumentet är ett l-värde av typen X, kan T på grund av en särskild regel härledas till X&, vilket innebär att T&& skulle betyda något i stil med X& &&. Men eftersom C++ fortfarande inte har något begrepp om referenser till referenser, så kollapsas typen X& && till X&. Detta kan först låta förvirrande och onödigt, men referenskollapsning är viktigt för perfekt vidarebefordran (vilket inte kommer att diskuteras här).

T&& är inte en rvalue-referens, utan en vidarebefordringsreferens. Den binder också till lvärden, i vilket fall T och T&& båda är lvärdereferenser.

Om du vill begränsa en funktionsmall till r-värden kan du kombinera SFINAE med type traits:

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

Implementation av move

Nu när du förstår referens-kollapsning, här är hur std::move implementeras:

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 accepterar move alla typer av parametrar tack vare vidarebefordringsreferensen T&&, och returnerar en rvalue-referens. Metafunktionskallelsen std::remove_reference<T>::type är nödvändig eftersom returtypen för lvalues av typen X annars skulle vara X& &&, vilket skulle kollapsa till X&. Eftersom t alltid är ett l-värde (kom ihåg att en namngiven r-värdereferens är ett l-värde), men vi vill binda t till en r-värdereferens, måste vi uttryckligen kasta t till rätt returtyp. anropet av en funktion som returnerar en r-värdereferens är i sig självt ett x-värde. Nu vet du varifrån xvalues kommer 😉

Lämna ett svar

Din e-postadress kommer inte publiceras.