C++ vždy vytvářel rychlé programy. Bohužel až do verze C++11 existovala neústupná bradavice, která zpomalovala mnoho programů v C++: vytváření dočasných objektů. Někdy mohou být tyto dočasné objekty optimalizovány překladačem (například optimalizace návratové hodnoty). Ne vždy to však platí a může to vést k drahému kopírování objektů. Co mám na mysli?“

Řekněme, že máte následující kód:

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

Pokud jste dělali hodně výkonné práce v C++, omlouvám se za bolest, kterou to přineslo. Pokud ne– no, pojďme si projít, proč je tento kód příšerný kód C++03. (Zbytek tohoto tutoriálu bude o tom, proč je to dobrý kódC++11.) Problém je v kopiích. Když je zavolána funkce doubleValues, zkonstruuje vektor new_values a naplní jej. To samo o sobě nemusí být ideální výkon, ale pokud chceme zachovat náš původní vektor nezkažený, potřebujeme druhou kopii. Ale co se stane, když stiskneme příkaz return?

Celý obsah new_values se musí zkopírovat! V zásadě by zde mohly být až dvě kopie: jedna do dočasného objektu, který bude vrácen, a druhá, když operátor přiřazení vektoru proběhne na řádku v = doubleValues( v );. První kopii může překladač automaticky optimalizovat, ale nevyhneme se tomu, že při přiřazení do v bude nutné znovu zkopírovat všechny hodnoty, což vyžaduje novou alokaci paměti a další iteraci nad celým vektorem.

Tento příklad může být trochu vymyšlený – a samozřejmě můžete najít způsoby, jak se takovým problémům vyhnout – například uložením a vrácením vektoru pomocí ukazatele nebo předáním vektoru, který má být naplněn. Jde o to, že ani jeden z těchto stylů programování není příliš přirozený. Navíc přístup, který vyžaduje vrácení ukazatele, zavedl přinejmenším jednu další alokaci paměti, a jedním z cílů návrhu C++ je vyhnout se alokacím paměti.

Nejhorší na celém tomto příběhu je, že objekt vrácený zdoubleValues je dočasná hodnota, která již není potřeba. Když máte řádek v = doubleValues( v ), výsledek doubleValues( v ) se po zkopírování prostě zahodí! Teoreticky by mělo být možné celé kopírování přeskočit a pouze ukrást ukazatel uvnitř dočasného vektoru a ponechat jej v. Proč vlastně nemůžeme objekt přesunout? V C++03 byla odpověď taková, že neexistoval žádný způsob, jak zjistit, zda je objekt dočasný nebo ne, museli jste v operátoru přiřazení nebo v konstruktoru kopírování provést stejný kód bez ohledu na to, odkud hodnota pochází, takže žádné vykrádání nebylo možné. V C++11 je odpověď – můžete!

Pro to jsou reference na rhodnoty a sémantika přesunu! Sémantika přesunu vám umožňuje vyhnout se zbytečnému kopírování při práci s dočasnými objekty, které se brzy vypaří a jejichž prostředky mohou být bezpečně odebrány z tohoto dočasného objektu a použity jiným objektem.

Sémantika přesunu se opírá o novou vlastnost jazyka C++11, která se nazývá rvalue reference, které budete chtít porozumět, abyste skutečně pochopili, o co jde. Nejprve si tedy povíme, co je to rvalue, pak co je to rvalue reference a nakonec se vrátíme k sémantice přesunu a k tomu, jak ji lze implementovat pomocí rvalue referencí.

Rvalues a lvalues – úhlavní rivalové, nebo nejlepší přátelé?

V jazyce C++ existují rvalues a lvalues. Lhodnota je výraz, jehožadresu lze převzít, hodnota lokátoru – v podstatě lhodnota poskytuje (polo)trvalý kus paměti. K lhodnotám můžete provádět přiřazení. Příklad:

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

Můžete mít také lvalues, které nejsou proměnnými:

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

Tady getRef vrací odkaz na globální proměnnou, takže vrací hodnotu, která je uložena v trvalém umístění. (Kdybyste chtěli, mohli byste doslova napsat & getRef(), a to by vám dalo adresu x.)

Rvalues jsou – no, rvalues nejsou lvalues. Výraz je rhodnota, pokud je jeho výsledkem dočasný objekt. Například:

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

Tady je getVal() rhodnota–vracená hodnota není odkaz na x, je to jen dočasná hodnota. Trochu zajímavější to začne být, pokud místo čísel použijeme skutečné objekty:

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

Tady getName vrací řetězec, který je zkonstruován uvnitř funkce. Výsledek funkce getName můžete přiřadit do proměnné:

string name = getName();

Ale přiřazujete z dočasného objektu, ne z nějaké hodnoty, která má pevné umístění. getName() je rhodnota.

Detekce dočasných objektů pomocí odkazů na rhodnoty

Důležité je, že rhodnoty odkazují na dočasné objekty – stejně jako hodnota vrácená funkcí doubleValues. Nebylo by skvělé, kdybychom mohli bez stínu pochybnosti vědět, že hodnota vrácená z výrazu je dočasná, a nějak napsat kód, který je přetížený, aby se pro dočasné objekty choval jinak? Proč, ano, ano, skutečně by to tak bylo. A právě k tomu slouží reference rvalue. Reference rvalue je reference, která se váže pouze na dočasný objekt. Co tím myslím?“

Před C++11, pokud jste měli dočasný objekt, mohli jste k jeho vazbě použít „obyčejnou“ nebo „lvalue referenci“, ale pouze pokud byla const:

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

Intuice je taková, že nemůžete použít „mutable“ referenci, protože kdybyste to udělali, mohli byste modifikovat nějaký objekt, který zanedlouho zmizí, a to by bylo nebezpečné. Mimochodem, všimněte si, že držení konstreference na dočasný objekt zajišťuje, že dočasný objekt není okamžitě zničen. To je pěkná záruka C++, ale stále se jedná o dočasný objekt, takže ho nechcete modifikovat.

V C++11 však existuje nový druh reference, „rvalue reference“,která vám umožní svázat mutabilní referenci s rvalue, ale ne s lvalue. Jinými slovy, reference rvalue jsou ideální pro zjišťování, zda je hodnota současný objekt, nebo ne. Reference na rvalue používají syntaxi && namísto pouhého & a mohou být const a non-const, stejně jako reference na lvalue, ačkoli s const referencí na rvalue se setkáte jen zřídka (jak uvidíme, mutablereference jsou tak trochu účel):

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

Tak to je všechno hezké, ale jak to pomůže? Nejdůležitější věc ohledně lvalue referencí vs. rvalue referencí je to, co se stane, když napíšete funkce, které přijímají lvalue nebo rvalue reference jako argumenty. Řekněme, že máme dvě funkce:

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

Teď začne být chování zajímavé–funkce printReference, která bere jako argument lvalue referenci, přijme jakýkoli argument, který je jí zadán, ať už je to lvalue nebo rvalue, a bez ohledu na to, zda je lvalue nebo rvalue mutabilní nebo ne. Avšak v případě druhého přetížení, funkce printReference přebírající referenci na hodnotu rvalue, jí budou předány všechny hodnoty kromě mutablervalue-referencí. Jinými slovy, pokud napíšete:

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

Nyní máme způsob, jak určit, zda referenční proměnná odkazuje na dočasnýobjekt nebo na trvalý objekt. Verze metody rvalue reference jejako tajný zadní vchod do klubu, do kterého se můžete dostat pouze tehdy, pokud jste dočasný objekt (asi nudný klub). Nyní, když máme naši metodu prourčení, zda byl objekt dočasný nebo trvalý, jak ji můžeme použít?

Konstruktor move a operátor přiřazení move

Nejčastějším vzorem, se kterým se při práci s rvalue referencemi setkáte, je vytvoření konstruktoru move a operátoru přiřazení move (který se řídí stejnýmiprincipy). Konstruktor move, stejně jako konstruktor copy, přijímá jako argument instanci objektu a vytváří novou instanci na základě původního objektu. Konstruktor move se však může vyhnout realokaci paměti, protože víme, že mu byl poskytnut dočasný objekt, takže místo kopírování polí objektu je přesuneme.

Co to znamená přesunout pole objektu? Pokud je pole primitivního typu, například int, prostě ho zkopírujeme. Zajímavější to začíná být, pokud je pole ukazatel: zde místo toho, abychom alokovali a inicializovali novou paměť, můžeme ukazatel jednoduše ukrást a null out ukazatel v dočasném objektu! Víme, že dočasný objekt již nebudeme potřebovat, takže můžeme jeho ukazatel z pod něj vyjmout.

Představte si, že máme jednoduchou třídu ArrayWrapper, jako je tato:

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

Všimněte si, že konstruktor kopírování musí jak alokovat paměť, tak kopírovat každou hodnotu z pole, jednu po druhé! To je pro kopírování hodně práce. Přidejme konstruktor move a získejme obrovskou efektivitu.

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

Páni, konstruktor move je ve skutečnosti jednodušší než konstruktor copy!To je docela výkon. Všimněte si hlavně těchto věcí:

  1. Parametr je nekonst rvalue reference
  2. ostatní._p_vals je nastaven na NULL

Druhé pozorování vysvětluje první – nemohli bychom nastavit ostatní._p_vals naNULL, kdybychom vzali konst rvalue referenci. Ale proč potřebujeme nastavitother._p_vals = NULL? Důvodem je destruktor–když dočasný objektvyjde z oboru, stejně jako všechny ostatní objekty C++ se spustí jeho destruktor.Když se spustí jeho destruktor, uvolní _p_vals. Stejné _p_valy, které jsme právě zkopírovali! Pokud bychom nenastavili hodnotu other._p_vals na NULL, přesun by ve skutečnosti nebyl přesunem – byla by to jen kopie, která by později způsobila pád, jakmile bychom začali používat uvolněnou paměť. To je celý smysl konstruktoru move: zabránit kopírování změnou původního, dočasného objektu!

Znovu, pravidla přetížení fungují tak, že konstruktor move je volán pouze pro dočasný objekt – a pouze dočasný objekt, který může být modifikován. To znamená, že pokud máte funkci, která vrací konst objekt, způsobí, že se místo konstruktoru move spustí konstruktor copy — nepište tedy kód takto:

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

Je tu ještě jedna situace, kterou jsme neprobrali, jak ošetřit v konstruktoru move — když máme pole, které je objektem. Představte si například, že bychom místo pole velikosti měli pole metadat, které by vypadalo takto:

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

Nyní může mít naše pole jméno a velikost, takže bychom možná museli změnit definici ArrayWrapperu takto:

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

Funguje to? Zdá se to velmi přirozené, že stačí zavolat konstruktor MetaDatamove z konstruktoru move pro ArrayWrapper? Problém je, že to prostě nefunguje. Důvod je jednoduchý: hodnota other v konstruktoru themove – je to reference na rvalue. Ale reference na rvalue není ve skutečnosti rvalue. Je to lvalue, a proto se volá konstruktor copy, nikoli konstruktorove. To je divné. Já vím – je to matoucí. Tady je způsob, jak o tom přemýšlet. Hodnota r je výraz, který vytváří objekt, jenž se má vypařit do vzduchu. Je na posledních nohách svého života – nebo se chystá splnit svůj životní účel. Najednou předáme dočasný objekt do konstruktoru move a ten se ujme nového života v novém oboru. V kontextu, kde byl vyhodnocen výraz rvalue, dočasný objekt skutečně skončil. Ale v našem konstruktoru má objekt jméno; bude žít po celou dobu trvání naší funkce. jinými slovy, proměnnou other můžeme ve funkci použít více než jednou a dočasný objekt má definované místo, které skutečně přetrvává po celou dobu trvání funkce. Je to lhodnota v pravém smyslu slova lokátor hodnoty,můžeme objekt lokalizovat na konkrétní adrese, která je stabilní po celou dobu trvání volání funkce. Ve skutečnosti ji můžeme chtít použít později ve funkci. Pokud by byl konstruktor move volán vždy, když bychom drželi objekt v rvaluereferenci, mohli bychom omylem použít přesunutý 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; }

Poslední způsob: jak lvalue, tak rvalue reference jsou lvalue výrazy. Rozdíl je v tom, že reference lvalue musí být const, aby mohla obsahovat odkaz na hodnotu rvalue, zatímco reference rvalue může vždy obsahovat odkaz na hodnotu rvalue. je to jako rozdíl mezi ukazatelem a tím, na co se ukazuje. Věc, na kterou se ukazuje, pochází z rvalue, ale když použijeme samotnou referenci na rvalue, výsledkem je lvalue.

std::move

Jaký je tedy trik pro řešení tohoto případu? Musíme použít std::move, z<utility>–std::move je způsob, jak říct: „Dobře, upřímně řečeno, vím, že mám l-hodnotu, ale chci, aby to byla r-hodnota.“ Std::move samo o sobě nic nepřesouvá; pouze mění l-hodnotu na r-hodnotu, abyste mohli vyvolat konstruktor move. Náš kód by měl vypadat takto:

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

A samozřejmě bychom se měli opravdu vrátit k MetaDatům a opravit jejich vlastní konstruktor move tak, aby používal std::move na řetězec, který drží:

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

Operátor přiřazení move

Stejně jako máme konstruktor move, měli bychom mít i operátor přiřazení move. Můžete ho snadno napsat pomocí stejných technik jako při vytváření move konstruktoru.

Přesunové konstruktory a implicitně generované konstruktory

Jak víte, v C++ když deklarujete jakýkoli konstruktor, překladač už vám nevygeneruje implicitní konstruktor. Totéž platí i zde: přidání přesouvacího konstruktoru do třídy bude vyžadovat, abyste deklarovali a definovali svůj vlastní výchozí konstruktor. Na druhou stranu deklarace konstruktoru move nezabrání překladači v poskytnutí implicitně generovaného kopírovacího konstruktoru a deklarace přiřazovacího operátoru move nebrání vytvoření standardního přiřazovacího operátoru.

Jak funguje std::move

Možná se ptáte, jak se píše funkce jako std::move? Jak získáte tuto kouzelnou vlastnost přeměny lhodnoty na referenci? Odpověď, jak asi tušíte, je typecasting. Skutečná deklarace funkce std::move je poněkud složitější, ale v jádru se jedná o pouhé statické_převedení na referenci na rhodnotu. To vlastně znamená, že ve skutečnosti nemusíte používat move – ale měli byste, protože je mnohem jasnější, co máte na mysli. Skutečnost, že je vyžadováno předání, je mimochodem velmi dobrá věc! Znamená to, že nemůžete omylem převést hodnotu l na hodnotu r, což by bylo nebezpečné, protože by mohlo dojít k náhodnému přesunu. Musíte explicitně použít std::move (nebo cast), abyste převedli lvalue na rvalue reference, a rvalue reference se nikdy nebude vázat na lvalue on itsown.

Vrácení explicitní rvalue reference z funkce

Jsou někdy situace, kdy byste měli napsat funkci, která vrací rvalue referenci? Co vůbec znamená vracet referenci na rvalue? Nejsou snad funkce, které vracejí objekty podle hodnoty, již rhodnotami?

Odpovězme nejprve na druhou otázku: Vrácení explicitní reference na rhodnotu je něco jiného než vrácení objektu podle hodnoty. Vezměme si následující jednoduchý příklad:

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

V prvním případě je zřejmé, že navzdory tomu, že funkce getInt() je rhodnota, dochází k vytvoření kopie proměnné x. V druhém případě se jedná o kopii proměnné x. Můžeme se o tom přesvědčit i tak, že si napíšeme malou pomocnou funkci:

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

Při spuštění tohoto programu uvidíte, že se vypisují dvě samostatné hodnoty.

Na druhou stranu

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

vypisuje stejnou hodnotu, protože zde explicitně vracíme hodnotu r. To znamená, že se vypisuje stejná hodnota.

Vracení reference na rhodnotu je tedy něco jiného než nevracení reference na rhodnotu, ale tento rozdíl se nejvíce projeví, pokud máte již existující objekt, který vracíte, namísto dočasného objektu vytvořeného ve funkci (kde překladač pravděpodobně zlikviduje kopii za vás).

Teď k otázce, zda to chcete dělat. Odpověď zní:pravděpodobně ne. Ve většině případů to jen zvyšuje pravděpodobnost, že skončíte s visící referencí (případ, kdy reference existuje, ale dočasnýobjekt, na který odkazuje, byl zničen). Problém je docela podobný nebezpečí, které hrozí při vracení reference lvalue – objekt, na který se odkazuje, již nemusí existovat. Vracení rvalue reference by mělo smysl především ve velmi vzácných případech, kdy máte členskou funkci a potřebujete vrátit výsledek volánístd::move na pole třídy z této funkce – a jak často to budete dělat?

Sémantika přesunu a standardní knihovna

Vrátíme-li se k našemu původnímu příkladu — používali jsme vektor a nemáme kontrolu nad třídou vektor a nad tím, zda má nebo nemá operátor přiřazení move konstruktoru. Naštěstí je standardizační komise moudrá a sémantikamove byla do standardní knihovny přidána. To znamená, že nyní můžete efektivně vracet vektory, mapy, řetězce a jakékoliv jiné objekty standardní knihovny, které chcete, s plným využitím sémantiky move.

Přesunutelné objekty v kontejnerech STL

Ve skutečnosti jde standardní knihovna ještě o krok dál. Pokud povolíte sémantiku move ve svých vlastních objektech vytvořením operátorů přiřazení move a konstruktorů move, při ukládání těchto objektů do kontejneru STL automaticky použije std::move a automaticky využije výhod tříd s povoleným move, aby eliminovala neefektivní kopírování.

Articles

Napsat komentář

Vaše e-mailová adresa nebude zveřejněna.