C++ har altid produceret hurtige programmer. Desværre har der indtil C++11 været en hårdnakket vorte, der bremser mange C++-programmer: oprettelsen af midlertidige objekter. Nogle gange kan disse midlertidige objekter optimeres væk af compileren (f.eks. ved optimering af returværdier). Men det er ikke altid tilfældet, og det kan resultere i dyre objektkopier. Hvad mener jeg?

Lad os sige, at du har følgende kode:

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

Hvis du har arbejdet meget med højtydende arbejde i C++, så beklager du den smerte, som det medførte. Hvis du ikke har – lad os gennemgå, hvorfor denne kode er forfærdelig C++03-kode. (Resten af denne vejledning vil handle om, hvorfor det er fin C++11-kode.) Problemet er med kopierne. Når doubleValues kaldes, konstruerer den en vektor, new_values, og fylder den op. Dette alene er måske ikke ideel ydelse, men hvisvi ønsker at bevare vores oprindelige vektor ubesudlet, har vi brug for en anden kopi. Men hvad sker der, når vi rammer return-erklæringen?

Hele indholdet af new_values skal kopieres! I princippet kan der være op til to kopier her: en til et midlertidigt objekt, der skal returneres, og en anden, når vektortildelingsoperatoren kører på linjen v = doubleValues( v );. Den første kopi kan optimeres væk af compileren automatisk, men det kan ikke undgås, at tildelingen til v skal kopiere alle værdierne igen,hvilket kræver en ny hukommelsesallokering og endnu en iteration over helevektoren.

Dette eksempel er måske lidt konstrueret – og man kan naturligvis finde måder at undgå denne slags problemer på – f.eks. ved at lagre og returnerevektoren som pointer eller ved at overgive en vektor, der skal fyldes op. Sagen er den, at ingen af disse programmeringsstile er særlig naturlige. Desuden har en fremgangsmåde, der kræver returnering af en pointer, indført mindst én yderligere hukommelsesallokering, og et af C++’s designmål er at undgå hukommelsesallokeringer.

Den værste del af hele denne historie er, at det objekt, der returneres fradoubleValues, er en midlertidig værdi, der ikke længere er nødvendig. Når du har linjen v = doubleValues( v ), vil resultatet af doubleValues( v ) bare blive smidt væk, når det er blevet kopieret! I teorien burde det være muligt at springe hele kopieringen over og blot stjæle pointeren inde i den midlertidige vektor og beholde den i v. Hvorfor kan vi i virkeligheden ikke flytte objektet? I C++03 er svaret, at der ikke var nogen måde at fortælle, om et objekt var midlertidigt eller ej. Man var nødt til at køre den samme kode i tildelingsoperatoren eller kopikonstruktøren, uanset hvor værdien kom fra, så der var ingen mulighed for at stjæle den. I C++11 er svaret – det kan man!

Det er det, som rvalue-referencer og move-semantik er til for! Move-semantik giver dig mulighed for at undgå unødvendige kopier, når du arbejder med midlertidige objekter, der er ved at fordampe, og hvis ressourcer sikkert kan tages fra det midlertidige objekt og bruges af et andet.

Move-semantik er afhængig af en ny funktion i C++11, kaldet rvalue-referencer, som du gerne vil forstå for virkelig at forstå, hvad der foregår. Så lad os først tale om, hvad en rvalue er, og derefter hvad en rvalue-reference er.Til sidst vender vi tilbage til move-semantikken, og hvordan den kan implementeres med rvalue-referencer.

Rvalues og lvalues – bitre rivaler eller bedste venner?

I C++ findes der rvalues og lvalues. En lvalue er et udtryk, hvisadresse kan tages, en lokaliseringsværdi – i det væsentlige giver en lvalue et (semi)permanent stykke hukommelse. Du kan foretage tildelinger til lvalues. Forexample:

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

Du kan også have lvalues, der ikke er variabler:

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

Her returnerer getRef en reference til en global variabel, så den returnerer en værdi, der er gemt på et permanent sted. (Du kunne bogstaveligt talt skrive & getRef(), hvis du ville, og det ville give dig adressen på x.)

Rvalues er – ja, rvalues er ikke lvalues. Et udtryk er en r-værdi, hvis det resulterer i et midlertidigt objekt. For eksempel:

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

Her er getVal() en r-værdi – den værdi, der returneres, er ikke en reference til x, det er bare en midlertidig værdi. Det bliver lidt mere interessant, hvis vi bruger rigtige objekter i stedet for tal:

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

Her returnerer getName en streng, der er konstrueret inde i funktionen. Du kan tildele resultatet af getName til en variabel:

string name = getName();

Men du tildeler fra et midlertidigt objekt og ikke fra en værdi, der har en fast placering. getName() er en rvalue.

Detektering af midlertidige objekter med rvalue-referencer

Det vigtige er, at rvalues refererer til midlertidige objekter – ligesom den værdi, der returneres fra doubleValues. Ville det ikke være fantastisk, hvis vi uden skyggen af tvivl kunne vide, at en værdi, der returneres fra et udtryk, var midlertidig, og på en eller anden måde skrive kode, der er overbelastet til at opføre sig anderledes for midlertidige objekter? Jo, jo, ja, det ville det sandelig være. Og det er det, som rvalue-referencer er til for. En rvalue-reference er en reference, der kun binder sig til et midlertidigt objekt. Hvad mener jeg?

Forud for C++11 kunne man, hvis man havde et midlertidigt objekt, bruge en “almindelig” eller “lvalue-reference” til at binde det, men kun hvis det var const:

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

Intuitionen her er, at man ikke kan bruge en “mutabel” reference, for så ville man kunne ændre et objekt, der er ved at forsvinde, og det ville være farligt. Bemærk i øvrigt, at det at holde fast i en konreference til et midlertidigt objekt sikrer, at det midlertidige objekt ikke destrueres med det samme. Dette er en god garanti i C++, men det er stadig et midlertidigt objekt, så du ønsker ikke at ændre det.

I C++11 er der imidlertid en ny type reference, en “rvalue-reference”, som giver dig mulighed for at binde en mutabel reference til en rvalue, men ikke en lvalue. Med andre ord er rvalue-referencer perfekte til at registrere, om en værdi er et midlertidigt objekt eller ej. Rvalue-referencer bruger &&-syntaksen i stedet for bare & og kan være const og non-const, ligesom lvalue-referencer, selv om du sjældent vil se en const rvalue-reference (som vi vil se, er mutable referencer lidt af pointen):

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

Så langt er alt dette godt og vel, men hvordan hjælper det? Den vigtigste ting om lvalue-referencer vs rvalue-referencer er, hvad der sker, når du skriver funktioner, der tager lvalue- eller rvalue-referencer som argumenter. Lad os sige, at vi har to funktioner:

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

Nu bliver opførslen interessant – printReference-funktionen, der tager en første lvalue-reference, accepterer ethvert argument, den får, uanset om det er en lvalue eller en rvalue, og uanset om lvalue eller rvalue er mutabel eller ej. Men hvis den anden overload, printReference, tager en r-værdi-reference, vil den modtage alle værdier undtagen mutableervalue-referencer. Med andre ord, hvis du skriver:

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 har vi en måde at afgøre, om en referencevariabel refererer til et midlertidigtobjekt eller til et permanent objekt. rvalue-reference-versionen af metoden er som den hemmelige bagdør indgang til klubben, som man kun kan komme ind i, hvis man er et midlertidigt objekt (kedelig klub, tror jeg). Nu hvor vi har vores metode til at bestemme, om et objekt var en midlertidig eller en permanent ting, hvordan kan vi så bruge den?

Move-konstruktør og move-tildelingsoperator

Det mest almindelige mønster, du vil se, når du arbejder med rvalue-referencer, er at oprette en move-konstruktør og en move-tildelingsoperator (som følger de sammeprincipper). En move-konstruktør tager ligesom en copy-konstruktør en instans af et objekt som argument og opretter en ny instans baseret på det oprindelige objekt. Move-konstruktøren kan dog undgå reallokering af hukommelse, fordi vi ved, at den har fået et midlertidigt objekt, så i stedet for at kopiere objektets felter vil vi flytte dem.

Hvad betyder det at flytte et felt i et objekt? Hvis feltet er en primitiv type, som f.eks. int, kopierer vi det bare. Det bliver mere interessant, hvis feltet er en pointer: Her kan vi i stedet for at allokere og initialisere ny hukommelse simpelthen stjæle pointeren og annullere pointeren i det midlertidige objekt! Vi ved, at der ikke længere vil være brug for det midlertidige objekt, så vi kan tage dets pointer ud under det.

Forestil dig, at vi har en simpel ArrayWrapper-klasse, som denne:

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

Bemærk, at kopikonstruktøren både skal allokere hukommelse og kopiere hver værdi fra arrayet, én ad gangen! Det er en masse arbejde for en kopi. Lad os tilføje en move-konstruktør og opnå en massiv effektivitet.

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

Wow, move-konstruktøren er faktisk enklere end copy-konstruktøren!Det er noget af en bedrift. De vigtigste ting at bemærke er:

  1. Parameteren er en non-const rvalue-reference
  2. other._p_vals er sat til NULL

Den anden observation forklarer den første – vi kunne ikke sætte other._p_vals tilNULL, hvis vi havde taget en const rvalue-reference. Men hvorfor skal vi indstilleother._p_vals = NULL? Årsagen er destruktoren – når det midlertidige objekt går ud af rækkevidde, vil dets destruktor, ligesom alle andre C++-objekter, blive kørt.Når dets destruktor kører, vil den frigøre _p_vals. De samme _p_vals, som vi lige har kopieret! Hvis vi ikke sætter other._p_vals til NULL, vil flytningen ikke rigtig være en flytning – det vil bare være en kopi, der medfører et nedbrud senere, når vi begynder at bruge frigjort hukommelse. Dette er hele pointen med en move-konstruktør: at undgå en kopi ved at ændre det oprindelige, midlertidige objekt!

Også her fungerer overload-reglerne således, at move-konstruktøren kun kaldes for et midlertidigt objekt – og kun et midlertidigt objekt, der kan ændres. Noget af det betyder, at hvis du har en funktion, der returnerer et const-objekt, vil det få copy-konstruktøren til at køre i stedet for move-konstruktøren – skriv ikke kode som denne:

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

Der er stadig en situation mere, som vi ikke har diskuteret, hvordan man håndterer i en move-konstruktør – når vi har et felt, der er et objekt. Forestil dig for eksempel, at vi i stedet for at have et størrelsesfelt havde et metadata-felt, der ligner dette:

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 vores array have et navn og en størrelse, så vi skal måske ændre definitionen af ArrayWrapper på følgende måde:

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

Funktionerer dette? Det virker meget naturligt, ikke sandt, at man bare kalder MetaDatamove-konstruktøren inde fra move-konstruktøren for ArrayWrapper? Problemet er, at dette bare ikke virker. Årsagen er enkel: værdien af other imove-konstruktøren – det er en rvalue-reference. Men en r-værdi-reference er faktisk ikke en r-værdi. Det er en lvalue, og derfor kaldes copy-konstruktøren og ikkemove-konstruktøren. Det er mærkeligt. Jeg ved det godt – det er forvirrende. Her er en måde at tænke på det på. En rvalue er et udtryk, der skaber et objekt, som er ved at fordampe i den blå luft. Det er på sine sidste ben i livet – eller er ved at opfylde sit livsformål. Pludselig sender vi det midlertidige til en move-konstruktør, og det får nyt liv i det nye scope. I den kontekst, hvor rvalue-udtrykket blev evalueret, er det midlertidige objekt virkelig slut med at eksistere. Men i vores konstruktør har objektet et navn; det vil være i live i hele funktionens varighed. med andre ord kan vi bruge variablen other mere end én gang i funktionen, og det midlertidige objekt har en defineret placering, som virkelig består i hele funktionen. Det er en lvalue i den egentlige betydning af begrebet locator value,vi kan lokalisere objektet på en bestemt adresse, der er stabil i hele funktionskaldets varighed. Det kan faktisk være, at vi ønsker at bruge den senere i funktionen. Hvis en move-konstruktør blev kaldt, hver gang vi holdt et objekt i en rvaluereference, kunne vi ved et uheld bruge et flyttet objekt!

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

Sæt endeligt: Både lvalue- og rvalue-referencer er lvalue-udtryk. Forskellen er, at en lvalue-reference skal være const for at indeholde en reference til en rvalue, mens en rvalue-reference altid kan indeholde en reference til en rvalue.Det er ligesom forskellen mellem en pointer, og det, der peges på. Den ting, der peges på, kom fra en rvalue, men når vi bruger rvalue-reference selv, resulterer det i en lvalue.

std::move

Så hvad er tricket til at håndtere dette tilfælde? Vi skal bruge std::move, from<utility>–std::move er en måde at sige, “ok, ærligt talt, jeg ved godt, at jeg har en lvalue, men jeg vil have den til at være en rvalue.” std::move flytter ikke i sig selv noget; den forvandler bare en lvalue til en rvalue, så du kan påkalde move-konstruktøren. Vores kode bør se således ud:

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

Og selvfølgelig bør vi virkelig gå tilbage til MetaData og rette dens egen move-konstruktør, så den bruger std::move på den streng, den indeholder:

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

Move assignment operator

Sådan som vi har en move-konstruktør, bør vi også have en move assignment operator. Du kan nemt skrive en sådan ved hjælp af de samme teknikker som ved oprettelse af en move-konstruktør.

Move-konstruktører og implicit genererede konstruktører

Som du ved, vil compileren i C++, når du deklarerer en konstruktør, ikke længere generere standardkonstruktøren for dig. Det samme er tilfældet her: Hvis du tilføjer en move-konstruktør til en klasse, skal du deklarere og definere din egen standardkonstruktør. På den anden side forhindrer deklaration af en move-konstruktør ikke compileren i at levere en implicit genereret kopi-konstruktør, og deklaration af en move-tildelingsoperator forhindrer ikke oprettelsen af en standard-tildelingsoperator.

Hvordan virker std::move

Du undrer dig måske over, hvordan man skriver en funktion som std::move? Hvordan får man denne magiske egenskab til at omdanne en lvalue til en reference til enrvalue? Svaret er, som du måske gætter, typecasting. Den egentlige deklaration for std::move er noget mere indviklet, men i bund og grund er det blot en static_cast til en r-værdi-reference. Det betyder faktisk, at du egentlig ikke behøver at bruge move – men du bør gøre det, da det er meget mere klart, hvad du mener. At der er behov for en cast er i øvrigt en meget god ting! Det betyder, at du ikke ved et uheld kan konvertere en l-værdi til en r-værdi, hvilket ville være farligt, da det kan give mulighed for at foretage et uheldigt move. Du skal eksplicit bruge std::move (eller en cast) for at konvertere en lvalue til en rvalue-reference, og en rvalue-reference vil aldrig binde til en lvalue på egen hånd.

Returnering af en eksplicit rvalue-reference fra en funktion

Er der nogensinde tidspunkter, hvor du bør skrive en funktion, der returnerer en rvalue-reference? Hvad betyder det overhovedet at returnere en rvalue-reference? Er funktioner, der returnerer objekter efter værdi, ikke allerede r-værdier?

Lad os først besvare det andet spørgsmål: At returnere en eksplicit r-værdi-reference er noget andet end at returnere et objekt efter værdi. Tag følgende enkle eksempel:

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

Det er klart, at der i det første tilfælde, på trods af at getInt() er en rvalue, bliver der lavet en kopi af variablen x. Vi kan endda se dette ved at skrive en lille hjælpefunktion:

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

Når du kører dette program, vil du se, at der udskrives to separate værdier.

På den anden side udskriver

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

den samme værdi, fordi vi udtrykkeligt returnerer en r-værdi her.

Så at returnere en r-værdi-reference er en anden ting end ikke at returnere en r-værdi-reference, men denne forskel manifesterer sig mest markant, hvis du har et allerede eksisterende objekt, du returnerer i stedet for et midlertidigt objekt, der oprettes i funktionen (hvor compileren sandsynligvis vil fjerne kopien for dig).

Nu til spørgsmålet om, hvorvidt du ønsker at gøre dette. Svaret er: sandsynligvis ikke. I de fleste tilfælde gør det bare det mere sandsynligt, at du ender med en dangling reference (et tilfælde, hvor referencen eksisterer, men det midlertidigeobjekt, som den henviser til, er blevet ødelagt). Problemet ligner meget den fare, der er forbundet med at returnere en lvalue-reference – det objekt, der henvises til, eksisterer måske ikke længere. Rvalue-referencer kan ikke på magisk vis holde et objekt i live for dig.At returnere en rvalue-reference ville primært give mening i meget sjældne tilfælde, hvor du har en medlemsfunktion og skal returnere resultatet af et kald afstd::move på et felt i klassen fra den pågældende funktion – og hvor ofte vil du gøre det?

Move-semantik og standardbiblioteket

For at vende tilbage til vores oprindelige eksempel – vi brugte en vektor, og vi har ikke kontrol over vektorklassen, og om den har en move-konstruktør move-tildelingsoperator eller ej. Heldigvis er standardiseringsudvalget klogt nok, og der er blevet tilføjet semantik formove til standardbiblioteket. Det betyder, at du nu effektivt kan returnere vektorer, maps, strings og alle andre standardbiblioteksobjekter, som du ønsker, idet du drager fuld fordel af move-semantik.

Flytbare objekter i STL-containere

Standardbiblioteket går faktisk et skridt videre. Hvis du aktiverer movesemantik i dine egne objekter ved at oprette move-tildelingsoperatører ogmove-konstruktører, vil STL, når du gemmer disse objekter i en container, automatisk bruge std::move og automatisk drage fordel af move-aktiverede klasser for at eliminere ineffektive kopier.

Articles

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.