C++ heeft altijd snelle programma’s voortgebracht. Helaas, tot C++11, is er een hardnekkige wrat die veel C++ programma’s vertraagt: het maken van tijdelijke objecten. Soms kunnen deze tijdelijke objecten worden weg geoptimaliseerd door de compiler (de return value optimalisatie, bijvoorbeeld). Maar dit is niet altijd het geval, en het kan resulteren in dure object kopieën. Wat bedoel ik daarmee?

Laten we zeggen dat je de volgende code hebt:

#include <iostream>using namespace std;vector<int> doubleValues (const vector<int>& v){ vector<int> new_values; new_values.reserve(v.size()); for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr ) { new_values.push_back( 2 * *itr ); } return new_values;}int main(){ vector<int> v; for ( int i = 0; i < 100; i++ ) { v.push_back( i ); } v = doubleValues( v );}

Als je veel high-performance werk in C++ hebt gedaan, sorry voor de pijn die dat met zich meebracht. Als u dat niet hebt gedaan – nou, laten we eens doornemen waarom deze code een verschrikkelijke C++03 code is. (De rest van deze tutorial zal gaan over waarom het prima C++11 code is.) Het probleem zit hem in de kopieën. Wanneer doubleValues wordt aangeroepen, construeert het een vector, new_values, en vult deze op. Dit alleen is misschien geen ideale prestatie, maar als we onze originele vector onaangetast willen houden, hebben we een tweede kopie nodig. Maar wat gebeurt er als we op het return statement drukken?

De gehele inhoud van new_values moet worden gekopieerd! In principe zouden hier twee kopieën kunnen zijn: een in een tijdelijk object dat wordt geretourneerd, en een tweede wanneer de vectortoewijzingsoperator op de regel v = doubleValues( v ); draait. De eerste kopie kan automatisch door de compiler worden geoptimaliseerd, maar er is geen ontkomen aan dat de toewijzing aan v alle waarden opnieuw moet kopiëren, wat een nieuwe geheugentoewijzing vereist en een nieuwe iteratie over de gehele vector.

Dit voorbeeld is misschien een beetje gekunsteld – en natuurlijk kun je manieren vinden om dit soort problemen te vermijden – bijvoorbeeld door de vector op te slaan en terug te sturen met een pointer, of door een vector in te voeren die gevuld moet worden. Het punt is, dat geen van deze programmeerstijlen bijzonder natuurlijk is. Bovendien introduceert een aanpak waarbij een pointer wordt geretourneerd ten minste een extra geheugenallocatie, en een van de ontwerpdoelen van C++ is om geheugenallocaties te vermijden.

Het ergste van dit hele verhaal is dat het object dat geretourneerd wordt uitDoubleValues een tijdelijke waarde is die niet langer nodig is. Wanneer je de regel v = doubleValues( v ) hebt, wordt het resultaat van doubleValues( v ) gewoon weggegooid zodra het is gekopieerd! In theorie zou het mogelijk moeten zijn om de hele kopie over te slaan en alleen de pointer in de tijdelijke vector te stoppen en die in v te houden. In C++03 was het antwoord dat er geen manier was om te zien of een object tijdelijk was of niet, je moest dezelfde code uitvoeren in de toewijzingsoperator of kopieer constructor, ongeacht waar de waarde vandaan kwam, dus stelen was niet mogelijk. In C++11, is het antwoord–dat kan wel!

Daar zijn rvalue referenties en move semantics voor! Move semantics stelt u in staat om onnodige kopieën te vermijden wanneer u werkt met tijdelijke objecten die op het punt staan om te verdampen, en waarvan de middelen veilig van dat tijdelijke object kunnen worden genomen en gebruikt door een ander object.

Move semantics is gebaseerd op een nieuwe functie van C++11, genaamd rvalue references, welke u zult willen begrijpen om echt te waarderen wat er aan de hand is. Dus laten we het eerst hebben over wat een rvalue is, en dan wat een rvalue referentie is.Tenslotte komen we terug op de move semantiek en hoe deze kan worden geimplementeerd metrvalue referenties.

Rvalues en lvalues – bittere rivalen, of beste vrienden?

In C++, zijn er rvalues en lvalues. Een lvalue is een expressie waarvan het adres kan worden genomen, een locator waarde – in essentie, een lvalue geeft een (semi)permanent stukje geheugen. Je kunt toewijzingen maken aan l-waarden. Bijvoorbeeld:

int a;a = 1; // here, a is an lvalue

Je kunt ook lvalues hebben die geen variabelen zijn:

int x;int& getRef () { return x;}getRef() = 4;

Hier retourneert getRef een verwijzing naar een globale variabele, dus het retourneert een waarde die is opgeslagen op een permanente plaats. (Je zou letterlijk & getRef() kunnen schrijven als je dat zou willen, en het zou je het adres van x geven.)

R-waarden zijn – nou ja, r-waarden zijn geen l-waarden. Een uitdrukking is een rvalue als het resulteert in een tijdelijk object. Bijvoorbeeld:

int x;int getVal (){ return x;}getVal();

Hier, getVal() is een r-waarde-de waarde die wordt geretourneerd is geen verwijzing naar x, het is gewoon een tijdelijke waarde. Dit wordt een beetje interessanter als we echte objecten gebruiken in plaats van getallen:

string getName (){ return "Alex";}getName();

Hier retourneert getName een string die is geconstrueerd in de functie. Je kunt het resultaat van getName aan een variabele toewijzen:

string name = getName();

Maar je wijst toe vanuit een tijdelijk object, niet vanuit een waarde die een vaste plaats heeft. getName() is een rvalue.

Tijdelijke objecten opsporen met rvalue-verwijzingen

Het belangrijkste is dat rvalues naar tijdelijke objecten verwijzen–zoals de waarde die van doubleValues terugkomt. Zou het niet geweldig zijn als we zonder enige twijfel konden weten dat een waarde die terugkomt van een expressie tijdelijk is, en op de een of andere manier code kunnen schrijven die overloaded is om zich anders te gedragen voor tijdelijke objecten? Waarom, ja, dat zou het inderdaad zijn. En dit is waar rvalue referenties voor zijn. Een rvalue referentie is een referentie die zich alleen bindt aan een tijdelijk object. Wat bedoel ik daarmee?

Vóór C++11, als u een tijdelijk object had, kon u een “gewone” of “l-waarde verwijzing” gebruiken om het te binden, maar alleen als het const was:

const string& name = getName(); // okstring& name = getName(); // NOT ok

De intuïtie hier is dat u geen “mutable” verwijzing kunt gebruiken omdat, als u dat deed, u in staat zou zijn om een object te wijzigen dat op het punt staat te verdwijnen, en dat zou gevaarlijk zijn. Merk trouwens op dat het vasthouden aan een constreference naar een tijdelijk object ervoor zorgt dat het tijdelijke object niet onmiddellijk wordt vernietigd. Dit is een mooie garantie van C++, maar het is nog steeds een tijdelijk object, dus u wilt het niet wijzigen.

In C++11, echter, is er een nieuw soort verwijzing, een “rvalue verwijzing”, waarmee u een mutable verwijzing kunt binden aan een rvalue, maar niet aan een lvalue. Met andere woorden, rvalue referenties zijn perfect om te detecteren of een waarde een tijdelijk object is of niet. R-waarde verwijzingen gebruiken de && syntax in plaats van alleen &, en kunnen const en non-const zijn, net als l-waarde verwijzingen, hoewel je zelden een const r-waarde verwijzing zult zien (zoals we zullen zien, zijn mutabele verwijzingen zo’n beetje het punt):

const string&& name = getName(); // okstring&& name = getName(); // also ok - praise be!

Dit is allemaal goed en wel, maar wat heeft het voor nut? Het belangrijkste van l-waarde verwijzingen versus r-waarde verwijzingen is wat er gebeurt als je functies schrijft die l-waarde of r-waarde verwijzingen als argumenten hebben. Laten we zeggen dat we twee functies hebben:

printReference (const String& str){ cout << str;}printReference (String&& str){ cout << str;}

Nu wordt het gedrag interessant- de printReference functie die een l-value referentie neemt zal elk argument accepteren dat het krijgt, of het nu een l-value of een r-value is, en ongeacht of de l-value of r-value muteerbaar is of niet. In het geval van de tweede overload, printReference die een r-waarde verwijzing neemt, zal het echter alle waarden krijgen behalve muteerbare-waarde-verwijzingen. Met andere woorden, als je schrijft:

string me( "alex" );printReference( me ); // calls the first printReference function, taking an lvalue referenceprintReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference

Nu hebben we een manier om te bepalen of een referentie-variabele verwijst naar een tijdelijkobject of naar een permanent object. De r-waarde referentie versie van de methode is als de geheime achterdeur ingang van de club waar je alleen in kan als je een tijdelijk object bent (saaie club, denk ik). Nu we onze methode hebben om te bepalen of een object een tijdelijk of een permanent ding was, hoe kunnen we het gebruiken?

Move constructor en move assignment operator

Het meest voorkomende patroon dat je zult zien bij het werken met rvalue referenties is het maken van een move constructor en een move assignment operator (die dezelfde principes volgen). Een move constructor neemt, net als een copy constructor, een instantie van een object als argument en maakt een nieuwe instantie gebaseerd op het oorspronkelijke object. De move constructor kan echter geheugenherallocatie vermijden omdat we weten dat het een tijdelijk object heeft gekregen, dus in plaats van de velden van het object te kopiëren, zullen we ze verplaatsen.

Wat betekent het om een veld van het object te verplaatsen? Als het veld een primitivetype is, zoals int, kopiëren we het gewoon. Het wordt interessanter als het veld een pointer is: in plaats van nieuw geheugen toe te wijzen en te initialiseren, kunnen we gewoon de pointer stelen en de pointer in het tijdelijke object null out zetten! We weten dat het tijdelijke object niet langer nodig zal zijn, dus kunnen we zijn pointer eronderuit halen.

Stel je voor dat we een eenvoudige ArrayWrapper klasse hebben, zoals deze:

class ArrayWrapper{ public: ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; } private: int *_p_vals; int _size;};

Merk op dat de kopieer constructor zowel geheugen moet toewijzen als elke waarde uit de array moet kopiëren, één voor één! Dat is veel werk voor een kopie. Laten we een move constructor toevoegen en wat enorme efficiëntie winnen.

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _size( 64 ) {} ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _size( other._size ) { other._p_vals = NULL; other._size = 0; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; int _size;};

Wauw, de move constructor is eigenlijk eenvoudiger dan de copy constructor! Dat is nogal een prestatie. De belangrijkste dingen om op te merken zijn:

  1. De parameter is een niet-const rvalue referentie
  2. other._p_vals is ingesteld op NULL

De tweede opmerking verklaart de eerste–we konden other._p_vals niet opNULL zetten als we een const rvalue referentie hadden genomen. Maar waarom moeten we other._p_vals = NULL zetten? De reden is de destructor–wanneer het tijdelijke object uit scope gaat, net als alle andere C++ objecten, zal zijn destructor lopen.Wanneer zijn destructor loopt, zal het _p_vals vrijmaken. Dezelfde _p_vals die we zojuist gekopieerd hebben! Als we andere._p_vals niet op NULL zetten, zou de move niet echt een move zijn–het zou gewoon een kopie zijn die later een crash introduceert als we vrijgekomen geheugen gaan gebruiken. Dit is het hele punt van een move constructor: om een kopie te voorkomen door het originele, tijdelijke object te veranderen!

Opnieuw, de overload regels werken zo dat de move constructor alleen wordt aangeroepen voor een tijdelijk object – en alleen een tijdelijk object dat kan worden gewijzigd. Dit betekent dat als je een functie hebt die een const object retourneert, de kopieer constructor wordt uitgevoerd in plaats van de move constructor – schrijf dus geen code als deze:

const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!

Er is nog een situatie die we nog niet hebben besproken hoe te handelen in eenove constructor – wanneer we een veld hebben dat een object is. Bijvoorbeeld, stel je voor dat we in plaats van een grootte-veld, een metadata-veld hadden dat er als volgt uitzag:

class MetaData{public: MetaData (int size, const std::string& name) : _name( name ) , _size( size ) {} // copy constructor MetaData (const MetaData& other) : _name( other._name ) , _size( other._size ) {} // move constructor MetaData (MetaData&& other) : _name( other._name ) , _size( other._size ) {} std::string getName () const { return _name; } int getSize () const { return _size; } private: std::string _name; int _size;};

Nu kan onze array een naam en een grootte hebben, dus we zouden de definitie van ArrayWrapper als volgt moeten veranderen:

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _metadata( 64, "ArrayWrapper" ) {} ArrayWrapper (int n) : _p_vals( new int ) , _metadata( n, "ArrayWrapper" ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { other._p_vals = NULL; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _metadata( other._metadata ) { for ( int i = 0; i < _metadata.getSize(); ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; MetaData _metadata;};

Werkt dit? Het lijkt heel natuurlijk, nietwaar, om gewoon de MetaDatamove constructor aan te roepen vanuit de move constructor voor ArrayWrapper? Het probleem is dat dit gewoon niet werkt. De reden is eenvoudig: de waarde van other in themove constructor–het is een rvalue verwijzing. Maar een r-waarde verwijzing is niet, in feite, een r-waarde. Het is een l-waarde, en dus wordt de kopieer constructor aangeroepen, niet deove constructor. Dit is vreemd. Ik weet het–het is verwarrend. Hier is de manier om er over na te denken. Een r-waarde is een uitdrukking die een object creëert dat op het punt staat te verdampen in de lucht. Het loopt op zijn laatste benen in het leven–of staat op het punt zijn levensdoel te vervullen. Plotseling geven we het tijdelijke door aan een move constructor, en het begint een nieuw leven in het nieuwe bereik. In de context waar de rvalue expressie werd geëvalueerd, is het tijdelijke object echt over en uit. Maar in onze constructor heeft het object een naam; het zal leven voor de gehele duur van onze functie. Met andere woorden, we kunnen de variabele other meer dan eens gebruiken in de functie, en het tijdelijke object heeft een gedefinieerde plaats die echt blijft bestaan voor de gehele functie. Het is een l-waarde in de ware zin van de term locator-waarde, we kunnen het object lokaliseren op een bepaald adres dat stabiel is voor de gehele duur van de functie-aanroep. Het kan zelfs zo zijn dat we het later in de functie willen gebruiken. Als een move constructor zou worden aangeroepen telkens wanneer we een object in een rvaluereferentie hielden, zouden we per ongeluk een verplaatst object kunnen gebruiken!

 // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { // if _metadata( other._metadata ) calls the move constructor, using // other._metadata here would be extremely dangerous! other._p_vals = NULL; }

Eindelijk: zowel lvalue- als rvalue-referenties zijn lvalue-uitdrukkingen. Het verschil is dat een lvalue verwijzing const moet zijn om een verwijzing naar een rvalue te bevatten, terwijl een rvalue verwijzing altijd een verwijzing naar een rvalue kan bevatten. Het is als het verschil tussen een pointer, en datgene waarnaar verwezen wordt. Het ding waarnaar verwezen wordt komt van een r-waarde, maar als we de r-waarde verwijzing zelf gebruiken, resulteert dat in een l-waarde.

std::move

Dus wat is de truc om dit geval te behandelen? We moeten std::move gebruiken, from<utility>–std::move is een manier om te zeggen: “ok, eerlijk gezegd weet ik dat ik een l-waarde heb, maar ik wil dat het een r-waarde is.” std::move verplaatst op zich niets; het verandert alleen een l-waarde in een r-waarde, zodat je de move constructor kunt aanroepen. Onze code zou er zo moeten uitzien:

#include <utility> // for std::move // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( std::move( other._metadata ) ) { other._p_vals = NULL; }

En natuurlijk moeten we teruggaan naar MetaData en zijn eigen move constructor aanpassen zodat die std::move gebruikt op de string die hij bevat:

 MetaData (MetaData&& other) : _name( std::move( other._name ) ) // oh, blissful efficiency : _size( other._size ) {}

Move assignment operator

Net zoals we een move constructor hebben, zouden we ook een move assignment operator moeten hebben. U kunt er gemakkelijk een schrijven met dezelfde technieken als voor het maken van een move constructor.

Move constructors en impliciet gegenereerde constructors

Zoals u weet, zal de compiler in C++, wanneer u een constructor declareert, niet langer de standaard constructor voor u genereren. Hetzelfde geldt hier: als u een verplaatsbare constructor aan een klasse toevoegt, moet u uw eigen standaard constructor declareren en definiëren. Anderzijds verhindert het declareren van een move constructor niet dat de compiler een impliciet gegenereerde copyconstructor levert, en het declareren van een move opdrachtoperator verhindert niet dat een standaard opdrachtoperator wordt aangemaakt.

Hoe werkt std::move

Je vraagt je misschien af, hoe schrijf je een functie als std::move? Hoe krijg je die magische eigenschap om een l-waarde te transformeren in een verwijzing naar een waarde? Het antwoord, zoals je misschien al raadt, is typecasting. De eigenlijke declaratie voor std::move is iets ingewikkelder, maar in de kern is het gewoon een static_cast naar een r-waarde verwijzing. Dit betekent, eigenlijk, dat je move niet echt hoeft te gebruiken- maar je zou het wel moeten doen, omdat het veel duidelijker is wat je bedoelt. Het feit dat een cast nodig is, is trouwens een zeer goede zaak! Het betekent dat je niet per ongeluk een l-waarde kan omzetten in een r-waarde, wat gevaarlijk zou zijn omdat het een onbedoelde move zou kunnen toestaan. Je moet expliciet std::move (of een cast) gebruiken om een l-waarde om te zetten in een r-waarde verwijzing, en een r-waarde verwijzing zal nooit binden aan een l-waarde op zich.

Een expliciete r-waarde verwijzing teruggeven vanuit een functie

Zijn er ooit momenten waarop je een functie zou moeten schrijven die een r-waarde verwijzing teruggeeft? Wat betekent het eigenlijk om een rvalue-referentie terug te geven? Zijn functies die objecten als waarde teruggeven niet al rvalues?

Laten we eerst de tweede vraag beantwoorden: een expliciete rvalue verwijzing teruggeven is anders dan een object als waarde teruggeven. Neem het volgende eenvoudige voorbeeld:

int x;int getInt (){ return x;}int && getRvalueInt (){ // notice that it's fine to move a primitive type--remember, std::move is just a cast return std::move( x );}

Het is duidelijk dat in het eerste geval, ondanks het feit dat getInt() een rvalue is, er een kopie van de variabele x wordt gemaakt. We kunnen dit zelfs zien door een kleine helper functie te schrijven:

void printAddress (const int& v) // const ref to allow binding to rvalues{ cout << reinterpret_cast<const void*>( & v ) << endl;}printAddress( getInt() ); printAddress( x );

Wanneer u dit programma uitvoert, zult u zien dat er twee verschillende waarden worden afgedrukt.

Aan de andere kant,

printAddress( getRvalueInt() ); printAddress( x );

afdrukt dezelfde waarde omdat we hier expliciet een rvalue teruggeven.

Dus het retourneren van een rvalue verwijzing is een ander ding dan het niet retourneren van een rvalue verwijzing, maar dit verschil manifesteert zich het meest merkbaar als je een reeds bestaand object hebt dat je retourneert in plaats van een tijdelijk object dat in de functie wordt gemaakt (waar de compiler waarschijnlijk de kopie voor je zal elimineren).

Nu over naar de vraag of je dit wilt doen. Het antwoord is: waarschijnlijk niet. In de meeste gevallen maakt het alleen de kans groter dat je eindigt met een bungelende verwijzing (een geval waarin de verwijzing bestaat, maar het tijdelijke object waarnaar het verwijst, is vernietigd). Het probleem is vergelijkbaar met het gevaar van het retourneren van een l-waarde verwijzing – het object waarnaar verwezen wordt bestaat misschien niet meer. R-waarde verwijzingen kunnen niet op magische wijze een object voor je in leven houden. Het teruggeven van een r-waarde verwijzing zou voornamelijk zinvol zijn in zeer zeldzame gevallen waarin je een member functie hebt en het resultaat moet teruggeven van het aanroepen vanstd::move op een veld van de klasse vanuit die functie–en hoe vaak ga je dat doen?

Move semantics and the standard library

Terugkomend op ons oorspronkelijke voorbeeld: we gebruikten een vector, en we hebben geen controle over de vector klasse en of die wel of niet een move constructor move assignment operator heeft. Gelukkig is de standaard commissie verstandig, en is de move semantiek toegevoegd aan de standaard bibliotheek. Dit betekent dat u nu efficiënt vectoren, maps, strings en welke andere standaardbibliotheek-objecten dan ook kunt retourneren, waarbij u optimaal profiteert van de move-semantiek.

Verplaatsbare objecten in STL-containers

In feite gaat de standaardbibliotheek nog een stap verder. Als u de move-semantiek in uw eigen objecten inschakelt door move-toewijzingsoperatoren en move-constructors te maken, zal de STL, wanneer u deze objecten in een container opslaat, automatisch std::move gebruiken, waarbij automatisch wordt geprofiteerd van move-enabledclasses om inefficiënte kopieën te elimineren.

Articles

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.