Wat is move semantics?

Mijn eerste antwoord was een extreem vereenvoudigde introductie tot move semantics, en veel details waren expres weggelaten om het simpel te houden.Er komt echter veel meer kijken bij move semantics, en ik vond dat het tijd werd voor een tweede antwoord om de gaten op te vullen.Het eerste antwoord is al vrij oud, en het voelde niet goed om het simpelweg te vervangen door een compleet andere tekst. Ik denk dat het nog steeds goed dient als een eerste inleiding. Maar als je dieper wilt graven, lees dan verder 🙂

Stephan T. Lavavej heeft de tijd genomen om waardevolle feedback te geven. Hartelijk dank, Stephan!

Inleiding

Move semantics staat een object toe om, onder bepaalde voorwaarden, eigendom te nemen van de externe bronnen van een ander object. Dit is op twee manieren belangrijk:

  1. Dure kopieën veranderen in goedkope moves. Zie mijn eerste antwoord voor een voorbeeld. Merk op dat indien een object niet tenminste één externe hulpbron beheert (direct, of indirect via zijn lid-objecten), de move semantiek geen voordelen zal bieden boven de copy semantiek. In dat geval betekent het kopiëren van een object en het verplaatsen van een object precies hetzelfde:

    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. Het implementeren van veilige “move-only” types; dat wil zeggen, types waarvoor kopiëren geen zin heeft, maar verplaatsen wel. Voorbeelden hiervan zijn “locks”, “file handles” en “smart pointers” met unieke eigendomssemantiek. Opmerking: Dit antwoord bespreekt std::auto_ptr, een verouderd C++98 standaard bibliotheek sjabloon, dat is vervangen door std::unique_ptr in C++11. Gemiddelde C++ programmeurs zijn waarschijnlijk op zijn minst enigszins bekend met std::auto_ptr, en vanwege de “move semantiek” die het weergeeft, lijkt het een goed startpunt voor het bespreken van move semantiek in C++11. YMMV.

Wat is een “move”?

De C++98 standaard bibliotheek biedt een slimme pointer met unieke eigendom semantiek genaamd std::auto_ptr<T>. Voor het geval u niet bekend bent met auto_ptr, het doel ervan is om te garanderen dat een dynamisch gealloceerd object altijd wordt vrijgegeven, zelfs in het geval van uitzonderingen:

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

Het ongebruikelijke aan auto_ptr is zijn “kopieer” gedrag:

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

Noteer hoe de initialisatie van b met a niet de driehoek kopieert, maar in plaats daarvan het eigendom van de driehoek overdraagt van a naar b. We zeggen ook wel “a wordt verplaatst naar b” of “de driehoek wordt verplaatst van a naar b“. Dit kan verwarrend klinken omdat de driehoek zelf altijd op dezelfde plaats in het geheugen blijft.

Een object verplaatsen betekent het eigendom van een bron die het beheert overdragen aan een ander object.

De kopieerconstructor van auto_ptr ziet er waarschijnlijk ongeveer zo uit (enigszins vereenvoudigd):

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

Gevaarlijke en ongevaarlijke verplaatsingen

Het gevaarlijke van auto_ptr is dat wat syntactisch op een kopie lijkt, eigenlijk een verplaatsing is. Als u een member-functie probeert aan te roepen op een auto_ptr die verplaatst is, zal dat leiden tot ongedefinieerd gedrag, dus u moet heel voorzichtig zijn om een auto_ptr niet te gebruiken nadat het verplaatst is:

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

Maar auto_ptr is niet altijd gevaarlijk. Fabrieksfuncties zijn een prima toepassing voor 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

Zie hoe beide voorbeelden hetzelfde syntactische patroon volgen:

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

En toch roept de ene ongedefinieerd gedrag op, terwijl de andere dat niet doet. Wat is dan het verschil tussen de uitdrukkingen a en make_triangle()? Zijn ze niet allebei van hetzelfde type? Dat zijn ze inderdaad, maar ze hebben verschillende waardecategorieën.

Waardecategorieën

Het is duidelijk dat er een diepgaand verschil moet zijn tussen de uitdrukking a die een auto_ptr variabele aanduidt, en de uitdrukking make_triangle() die de aanroep van een functie aanduidt die een auto_ptr als waarde teruggeeft, en dus een nieuw tijdelijk auto_ptr object creëert telkens als het wordt aangeroepen. a is een voorbeeld van een l-waarde, terwijl make_triangle() een voorbeeld is van een r-waarde.

Verhuizen van l-waarden zoals a is gevaarlijk, omdat we later zouden kunnen proberen om een member-functie via a aan te roepen, waardoor ongedefinieerd gedrag wordt opgeroepen. Aan de andere kant is het verplaatsen van r-waarden zoals make_triangle() volkomen veilig, want nadat de kopie constructor zijn werk heeft gedaan, kunnen we de tijdelijke niet meer gebruiken. Er is geen uitdrukking die deze tijdelijke waarde aanduidt; als we make_triangle() opnieuw schrijven, krijgen we een andere tijdelijke waarde. In feite is het verplaatste tijdelijke al weg op de volgende regel:

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

Noteer dat de letters l en r een historische oorsprong hebben in het linker- en rechterdeel van een toewijzing. Dit is niet langer waar in C++, omdat er l-waarden zijn die niet aan de linkerkant van een toewijzing kunnen staan (zoals arrays of door de gebruiker gedefinieerde typen zonder een toewijzingsoperator), en er zijn r-waarden die dat wel kunnen (alle r-waarden van klasse-typen met een toewijzingsoperator).

Een r-waarde van een klasse-type is een uitdrukking waarvan de evaluatie een tijdelijk object creëert. Onder normale omstandigheden, geen andere uitdrukking binnen hetzelfde bereik geeft hetzelfde tijdelijke object.

Rvalue referenties

We begrijpen nu dat het verplaatsen van lvalues potentieel gevaarlijk is, maar het verplaatsen van rvalues is ongevaarlijk. Als C++ taalondersteuning zou hebben om l-value argumenten van r-value argumenten te onderscheiden, zouden we ofwel verplaatsen van l-values volledig kunnen verbieden, of op zijn minst verplaatsen van l-values expliciet kunnen maken bij de aanroep, zodat we niet langer per ongeluk verplaatsen.

C++11’s antwoord op dit probleem zijn r-value referenties. Een rvalue verwijzing is een nieuw soort verwijzing dat alleen bindt aan rvalues, en de syntaxis is X&&. De goede oude referentie X& staat nu bekend als een lvalue referentie. (Merk op dat X&& geen verwijzing naar een verwijzing is; zoiets bestaat niet in C++.)

Als we const in de mix gooien, hebben we al vier verschillende soorten verwijzingen. Aan welke expressies van het type X kunnen zij zich binden?

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

In de praktijk kunt u const X&& wel vergeten. Beperkt zijn tot het lezen van rvalues is niet erg nuttig.

Een rvalue reference X&& is een nieuw soort referentie die alleen bindt aan rvalues.

Impliciete conversies

Rvalue references hebben verschillende versies doorgemaakt. Sinds versie 2.1 bindt een rvalue verwijzing X&& ook aan alle waardecategorieën van een ander type Y, mits er een impliciete conversie is van Y naar X. In dat geval wordt een tijdelijke van het type X aangemaakt, en wordt de r-waardeverwijzing aan die tijdelijke gebonden:

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

In het bovenstaande voorbeeld is "hello world" een l-waarde van het type const char. Aangezien er een impliciete conversie is van const char via const char* naar std::string, wordt er een tijdelijke van het type std::string gemaakt, en r wordt aan die tijdelijke gebonden. Dit is een van de gevallen waarin het onderscheid tussen rvalues (expressies) en temporaries (objecten) een beetje wazig is.

Move constructors

Een nuttig voorbeeld van een functie met een X&& parameter is de move constructor X::X(X&& source). Het doel is om het eigendom van de beheerde bron over te dragen van de bron naar het huidige object.

In C++11, is std::auto_ptr<T> vervangen door std::unique_ptr<T> die gebruik maakt van rvalue referenties. Ik zal een vereenvoudigde versie van unique_ptr ontwikkelen en bespreken. Eerst kapselen we een ruwe pointer in en overloaden we de operatoren -> en *, zodat onze klasse aanvoelt als een pointer:

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

De constructor neemt het eigendom van het object, en de destructor verwijdert het:

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

Nu komt het interessante deel, de move constructor:

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

Deze move constructor doet precies wat de auto_ptr copy constructor deed, maar hij kan alleen met r-waarden worden geleverd:

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

De tweede regel compileert niet, omdat a een l-waarde is, maar de parameter unique_ptr&& source kan alleen aan r-waarden worden gebonden. Dit is precies wat we wilden; gevaarlijke zetten mogen nooit impliciet zijn. De derde regel compileert prima, omdat make_triangle() een r-waarde is. De move constructor zal het eigendom overdragen van de tijdelijke naar c. Nogmaals, dit is precies wat we wilden.

De move constructor draagt het eigendom van een beheerde bron over aan het huidige object.

Move assignment operators

Het laatste ontbrekende stuk is de move assignment operator. Zijn taak is om de oude resource vrij te geven en de nieuwe resource van zijn argument te verwerven:

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

Noteer hoe deze implementatie van de move assignment operator de logica van zowel de destructor als de move constructor dupliceert. Ben je bekend met het copy-and-swap idioom? Het kan ook worden toegepast op de move semantiek als het move-and-swap idioom:

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

Nu source een variabele is van het type unique_ptr, zal deze worden geïnitialiseerd door de move constructor; dat wil zeggen, het argument zal worden verplaatst naar de parameter. Het argument moet nog steeds een r-waarde zijn, omdat de move constructor zelf een r-waarde referentie parameter heeft. Wanneer de controlestroom de afsluitende accolade van operator= bereikt, gaat source uit de scope, waardoor de oude resource automatisch wordt vrijgegeven.

De move assignment operator draagt eigendom van een beheerde resource over aan het huidige object, waardoor de oude resource wordt vrijgegeven. De move-and-swap idioom vereenvoudigt de implementatie.

Verplaatsen van lvalues

Soms willen we verplaatsen van lvalues. Dat wil zeggen, soms willen we dat de compiler een l-waarde behandelt alsof het een r-waarde is, zodat het de move constructor kan aanroepen, ook al zou dat mogelijk onveilig zijn.Voor dit doel biedt C++11 een standaard bibliotheek functie sjabloon genaamd std::move binnen de header <utility>.Deze naam is een beetje ongelukkig, omdat std::move eenvoudig een l-waarde cast naar een r-waarde; het verplaatst niets op zichzelf. Het maakt alleen verplaatsen mogelijk. Misschien had het std::cast_to_rvalue of std::enable_move moeten heten, maar we zitten nu eenmaal vast aan de naam.

Hier ziet u hoe u expliciet een l-waarde verplaatst:

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

Merk op dat na de derde regel, a niet langer een driehoek bezit. Dat is niet erg, want door std::move(a) expliciet te schrijven, hebben we onze bedoelingen duidelijk gemaakt: “Beste constructor, doe met a wat je wilt om c te initialiseren; a interesseert me niet meer. Voel je vrij om je gang te gaan met a.”

std::move(some_lvalue) cast een l-waarde naar een r-waarde, en maakt zo een volgende move mogelijk.

Xvalues

Merk op dat hoewel std::move(a) een r-waarde is, de evaluatie ervan geen tijdelijk object creëert. Dit raadsel dwong de commissie ertoe een derde waardecategorie te introduceren. Iets dat kan worden gebonden aan een rvalue referentie, ook al is het geen rvalue in de traditionele zin, wordt een xvalue (eXpiring value) genoemd. De traditionele rvalues zijn hernoemd tot prvalues (Pure rvalues).

Zowel prvalues als xvalues zijn rvalues. X-waarden en l-waarden zijn beide gl-waarden (Veralgemeende l-waarden). De relaties zijn gemakkelijker te begrijpen met een diagram:

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

Merk op dat alleen xvalues echt nieuw zijn; de rest is slechts het gevolg van hernoemen en groeperen.

C++98 rvalues staan bekend als prvalues in C++11. Vervang mentaal alle voorkomens van “rvalue” in de voorgaande paragrafen door “prvalue”.

Verplaatsen uit functies

Tot nu toe hebben we verplaatsing gezien naar lokale variabelen, en naar functie parameters. Maar verplaatsing is ook mogelijk in de omgekeerde richting. Als een functie met waarde terugkomt, wordt een object op de aanroep-site (waarschijnlijk een lokale variabele of een tijdelijk, maar het kan elk soort object zijn) geïnitialiseerd met de uitdrukking na het return statement als argument voor de move constructor:

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

Misschien verrassend, automatische objecten (lokale variabelen die niet zijn gedeclareerd als static) kunnen ook impliciet uit functies worden verplaatst:

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

Hoe komt het dat de move constructor de l-waarde result als argument accepteert? De scope van result staat op het punt te eindigen, en het zal worden vernietigd tijdens het afwikkelen van de stack. Niemand kan achteraf klagen dat result op de een of andere manier is veranderd; als de control flow terug is bij de aanroeper, bestaat result niet meer! Om die reden heeft C++11 een speciale regel die het mogelijk maakt om automatische objecten terug te sturen uit functies zonder std::move te hoeven schrijven. In feite zou u std::move nooit moeten gebruiken om automatische objecten uit functies te verplaatsen, omdat dit de “named return value optimization” (NRVO) remt.

Gebruik std::move nooit om automatische objecten uit functies te verplaatsen.

Merk op dat in beide fabrieksfuncties, het retourneertype een waarde is, niet een rvalue verwijzing. R-waarde verwijzingen zijn nog steeds verwijzingen, en zoals altijd, moet je nooit een verwijzing naar een automatisch object retourneren; de aanroeper zou eindigen met een bungelende verwijzing als je de compiler zou misleiden om je code te accepteren, zoals deze:

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

Geef nooit automatische objecten terug door middel van een r-waarde verwijzing. Verplaatsen wordt uitsluitend uitgevoerd door de move constructor, niet door std::move, en niet door het louter binden van een rvalue aan een rvalue reference.

Verplaatsen in leden

Ooit zult u code schrijven als deze:

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

Basically, de compiler zal klagen dat parameter een lvalue is. Als je naar het type kijkt, zie je een rvalue verwijzing, maar een rvalue verwijzing betekent gewoon “een verwijzing die gebonden is aan een rvalue”; het betekent niet dat de verwijzing zelf een rvalue is! Inderdaad, parameter is gewoon een gewone variabele met een naam. Je kunt parameter zo vaak gebruiken als je wilt in de body van de constructor, en het geeft altijd hetzelfde object aan. Het impliciet verplaatsen ervan zou gevaarlijk zijn, vandaar dat de taal het verbiedt.

Een benoemde rvalue referentie is een lvalue, net als elke andere variabele.

De oplossing is om de verplaatsing handmatig in te schakelen:

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

Je zou kunnen aanvoeren dat parameter niet meer wordt gebruikt na de initialisatie van member. Waarom is er geen speciale regel om std::move stil in te voegen, net als bij terugkeerwaarden? Waarschijnlijk omdat het een te grote belasting zou zijn voor de implementeerders van de compiler. Bijvoorbeeld, wat als de constructor body in een andere vertaaleenheid staat? Daarentegen hoeft de return value-regel alleen maar de symbolentabellen te controleren om te bepalen of de identifier na het return-sleutelwoord een automatisch object aanduidt.

Je kunt de parameter ook als waarde doorgeven. Voor move-only types zoals unique_ptr, lijkt er nog geen vast idioom te zijn. Persoonlijk geef ik de voorkeur aan pass by value, omdat het minder rommel in de interface veroorzaakt.

Special member functions

C++98 declareert impliciet drie special member functions on demand, dat wil zeggen, wanneer ze ergens nodig zijn: de copy constructor, de copy assignment operator, en de destructor.

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

Rvalue references zijn door verschillende versies gegaan. Sinds versie 3.0, C++11 verklaart twee extra speciale member functies op aanvraag: de move constructor en de move assignment operator. Merk op dat noch VC10 noch VC11 nog voldoet aan versie 3.0, dus u zult ze zelf moeten implementeren.

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

Deze twee nieuwe speciale member functies worden alleen impliciet gedeclareerd als geen van de speciale member functies handmatig wordt gedeclareerd. Ook als u uw eigen move constructor of move assignment operator declareert, worden noch de copy constructor noch de copy assignment operator impliciet gedeclareerd.

Wat betekenen deze regels in de praktijk?

Als u een klasse schrijft zonder unmanaged resources, hoeft u geen van de vijf special member functions zelf te declareren, en krijgt u gratis correcte copy semantics en move semantics. Anders moet je de speciale member functies zelf implementeren. Natuurlijk, als je klasse geen voordeel heeft van move semantics, is het niet nodig om de speciale move operaties te implementeren.

Merk op dat de kopieer opdracht operator en de verplaats opdracht operator kunnen worden samengevoegd in een enkele, verenigde opdracht operator, die zijn argument als waarde neemt:

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

Op deze manier daalt het aantal speciale lid functies om te implementeren van vijf naar vier. Er is een afweging tussen exception-safety en efficiency, maar ik ben geen expert op dit gebied.

Forwarding references (voorheen bekend als Universal references)

Overweeg het volgende functiesjabloon:

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

Je zou verwachten dat T&& alleen aan rvalues bindt, omdat het er op het eerste gezicht uitziet als een rvalue reference. Het blijkt echter dat T&& ook bindt aan l-waarden:

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

Als het argument een r-waarde is van het type X, dan wordt T afgeleid als X, dus T&& betekent X&&. Maar als het argument een l-waarde is van het type X, dan wordt door een speciale regel afgeleid dat T X& is, en T&& zou dan zoiets betekenen als X& &&. Maar omdat C++ nog steeds geen notie heeft van verwijzingen naar referenties, wordt het type X& && samengevat in X&. Dit klinkt in eerste instantie misschien verwarrend en nutteloos, maar reference collapsing is essentieel voor perfecte forwarding (wat hier niet besproken zal worden).

T&& is geen rvalue referentie, maar een forwarding referentie. Het bindt ook aan l-waarden, in welk geval T en T&& beide l-waarde verwijzingen zijn.

Als u een functiesjabloon wilt beperken tot rvalues, kunt u SFINAE combineren met type traits:

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

Implementatie van move

Nu u reference collapsing begrijpt, is hier hoe std::move wordt geïmplementeerd:

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

Zoals u kunt zien, accepteert move elk soort parameter dankzij de forwarding reference T&&, en het retourneert een rvalue reference. De aanroep van de meta-functie std::remove_reference<T>::type is nodig omdat anders voor l-waarden van het type X het terugkeertype X& && zou zijn, dat zou samenvallen in X&. Omdat t altijd een l-waarde is (onthoud dat een named rvalue reference een l-waarde is), maar we willen t binden aan een rvalue reference, moeten we t expliciet casten naar het juiste return type.De aanroep van een functie die een rvalue reference retourneert is zelf een xvalue. Nu weet je waar xvalues vandaan komen 😉

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.