A C++ mindig is gyors programokat készített. Sajnos a C++11-ig volt egy makacs szemölcs, amely sok C++ programot lelassított: az ideiglenes objektumok létrehozása. Néha ezeket az ideiglenes objektumokat a fordító el tudja optimalizálni (például a visszatérési értékek optimalizálása). De ez nem mindig van így, és ez drága objektummásolásokat eredményezhet. Mire gondolok?

Tegyük fel, hogy a következő kódod van:

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

Ha sok nagy teljesítményű munkát végeztél C++-ban, sajnálom, hogy ez fájdalmat okozott. Ha nem… nos, nézzük meg, hogy ez a kód miért borzalmas C++03 kód. (A bemutató további része arról fog szólni, hogy miért jóC++11 kód.) A probléma a másolatokkal van. A doubleValues hívásakor létrehoz egy vektort,new_values, és feltölti azt. Ez önmagában talán nem ideális teljesítmény, de haaz eredeti vektorunkat meg akarjuk őrizni makulátlanul, szükségünk van egy második másolatra. De mi történik, amikor elérjük a return utasítást?

A new_values teljes tartalmát át kell másolni! Elvileg itt akár két másolat is lehet: egy a visszaadandó ideiglenes objektumba, és egy második, amikor a vektor hozzárendelési operátor lefut a v = doubleValues( v ); sorban. Az első másolatot a fordító automatikusan optimalizálhatja, de nem kerülhető el, hogy a v-hez való hozzárendeléskor az összes értéket újra kell másolni,ami új memóriafoglalást és újabb iterációt igényel a teljesvektoron.

Ez a példa talán egy kicsit mesterkélt – és természetesen találhatunk módot az ilyen jellegű problémák elkerülésére – például a vektor tárolásával és visszaadásával mutatóval, vagy egy feltöltendő vektor átadásával. A helyzet az, hogy egyik programozási stílus sem kifejezetten természetes. Ráadásul egy olyan megközelítés, amely egy mutató visszaadását igényli, legalább egy újabb memóriafoglalást vezet be, és a C++ egyik tervezési célja a memóriafoglalások elkerülése.

A legrosszabb része ennek az egész történetnek az, hogy a doubleValues által visszaadott objektum egy ideiglenes érték, amelyre már nincs szükség. Ha van a v = doubleValues( v ) sor, akkor a doubleValues( v ) eredményét a másolás után egyszerűen eldobjuk! Elméletileg lehetségesnek kellene lennie, hogy kihagyjuk az egész másolást, és csak a mutatót lopjuk el az ideiglenes vektoron belül, és tartsuk v-ben. A C++03-ban a válasz az volt, hogy nem lehetett megmondani, hogy egy objektum ideiglenes-e vagy sem, ugyanazt a kódot kellett lefuttatni a hozzárendelési operátorban vagy a másolási konstruktorban, függetlenül attól, hogy az érték honnan származik, így nem volt lehetséges az ellopás. A C++11-ben a válasz az, hogy lehet!

Ezért vannak az rértékreferenciák és a mozgatás szemantika! A mozgatás szemantika lehetővé teszi, hogy elkerüljük a felesleges másolásokat, amikor olyan ideiglenes objektumokkal dolgozunk, amelyek hamarosan elpárolognak, és amelyek erőforrásai biztonságosan elvehetők az ideiglenes objektumtól, és egy másik használhatja őket.

A mozgatás szemantika a C++11 egy új funkciójára, az rvalue referenciákra támaszkodik, amelyet meg kell értenünk, hogy igazán értékelni tudjuk, mi történik. Ezért először is beszéljünk arról, hogy mi az az rérték, majd arról, hogy mi az az rértékreferencia.Végül visszatérünk a mozgás szemantikára és arra, hogy hogyan valósítható meg az rértékreferenciákkal.

Rértékek és lértékek – keserű riválisok, vagy a legjobb barátok?

A C++-ban vannak rértékek és lértékek. Az lvalue egy olyan kifejezés, amelynek címe felvehető, egy lokátorérték – lényegében az lvalue egy (félig)állandó memóriadarabot biztosít. Az lértékekhez hozzárendeléseket lehet végezni. Forexample:

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

Lehetnek olyan lértékek is, amelyek nem változók:

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

Itt a getRef egy globális változóra való hivatkozást ad vissza, tehát egy állandó helyen tárolt értéket ad vissza. (Szó szerint írhatnánk & getRef(), ha akarnánk, és az x címét adná meg.)

Az r-értékek… nos, az r-értékek nem l-értékek. Egy kifejezés akkor rérték, ha ideiglenes objektumot eredményez. Például:

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

Itt a getVal() egy r-érték – a visszaadott érték nem egy hivatkozás x-re, hanem csak egy ideiglenes érték. Ez egy kicsit érdekesebbé válik, ha számok helyett valódi objektumokat használunk:

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

Itt a getName egy stringet ad vissza, amely a függvényen belül épül fel. A getName eredményét hozzárendelhetjük egy változóhoz:

string name = getName();

De egy ideiglenes objektumból rendeljük hozzá, nem pedig valamilyen rögzített helyen lévő értékből. getName() egy rérték.

Az ideiglenes objektumok felismerése rértékre való hivatkozással

A fontos dolog az, hogy az rértékek ideiglenes objektumokra utalnak – akárcsak a doubleValues által visszaadott érték. Nem lenne nagyszerű, ha a kétség árnyéka nélkül tudhatnánk, hogy egy kifejezés által visszaadott érték ideiglenes, és valahogyan írhatnánk olyan kódot, amely túlterhelt, hogy másképp viselkedjen az ideiglenes objektumok esetében? Miért, igen, igen, valóban az lenne. És erre szolgálnak az rvalue referenciák. Az rvalue referencia egy olyan hivatkozás, amely csak egy ideiglenes objektumhoz kötődik. Hogy értem?

A C++11 előtt, ha volt egy ideiglenes objektumod, használhattál egy “normál” vagy “lvalue referenciát” a kötéshez, de csak akkor, ha const volt:

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

Az intuíció itt az, hogy nem használhatsz “mutable” referenciát, mert ha igen, akkor képes lennél módosítani egy olyan objektumot, ami hamarosan eltűnik, és ez veszélyes lenne. Vegyük észre egyébként, hogy az ideiglenes objektumra való állandó hivatkozás megtartása biztosítja, hogy az ideiglenes objektum nem semmisül meg azonnal. Ez a C++ szép garanciája, de ez még mindig egy ideiglenes objektum, ezért nem akarjuk módosítani.

A C++11-ben azonban van egy újfajta hivatkozás, az “rvalue referencia”, amely lehetővé teszi, hogy egy változtatható hivatkozást egy rvalue-hoz kössünk, de egy lvalue-hoz nem. Más szavakkal, az rvalue referenciák tökéletesen alkalmasak annak megállapítására, hogy egy érték ideiglenes objektum-e vagy sem. Az rvalue-referenciák a && szintaxist használják ahelyett, hogy csak & lenne, és lehetnek const és non-const, akárcsak az lvalue-referenciák, bár ritkán fogsz látni const rvalue-referenciát (mint látni fogjuk, a változtatható referenciáknak ez a lényege):

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

Ez mind szép és jó, de miben segít? A legfontosabb dolog az lvalue referenciákkal vs rvalue referenciákkal kapcsolatban az, hogy mi történik, amikor olyan függvényeket írunk, amelyek lvalue vagy rvalue referenciákat vesznek argumentumként. Tegyük fel, hogy van két függvényünk:

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

A viselkedés most kezd érdekessé válni – a printReference függvény, amely egyconst l-értékreferenciát vesz fel, bármilyen argumentumot elfogad, amit kap, legyen az egylérték vagy egy rérték, és függetlenül attól, hogy az lérték vagy rérték mutálható vagy sem. A második túlterhelés jelenlétében azonban a printReference egy r-értékreferenciát elfogad minden értéket, kivéve a változtatható értékreferenciákat. Más szóval, ha azt írjuk:

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

Most már van módunk meghatározni, hogy egy referencia változó egy ideiglenesobjektumra vagy egy állandó objektumra utal. A módszer rvalue-referenciás változata olyan, mint a klub titkos hátsó bejárata, ahová csak akkor juthatsz be, ha ideiglenes objektum vagy (unalmas klub, gondolom). Most, hogy megvan a módszerünk annak meghatározására, hogy egy objektum ideiglenes vagy állandó dolog volt-e, hogyan használhatjuk?

Mozgatás-konstruktor és mozgatás hozzárendelési operátor

A leggyakoribb minta, amit rvalue referenciákkal való munka során látni fogsz, hogy létrehozol egy mozgatás-konstruktort és egy mozgatás hozzárendelési operátort (ami ugyanazokat az elveket követi). A move konstruktor, hasonlóan a copy konstruktorhoz, egy objektum példányát veszi argumentumként, és egy új példányt hoz létre az eredeti objektum alapján. A move konstruktor azonban elkerülheti a memória újraallokálását, mert tudjuk, hogy egy ideiglenes objektumot kap, így az objektum mezőinek másolása helyett inkább áthelyezzük őket.

Mit jelent az objektum egy mezőjének áthelyezése? Ha a mező egy primitívtípus, például int, akkor egyszerűen csak másoljuk. Érdekesebbé válik a dolog, ha a mező egy mutató: itt ahelyett, hogy új memóriát rendelnénk és inicializálnánk, egyszerűen ellophatjuk a mutatótés nullázhatjuk a mutatót az ideiglenes objektumban! Tudjuk, hogy az ideiglenes objektumra már nem lesz szükségünk, ezért kivehetjük a mutatóját.

Tegyük fel, hogy van egy egyszerű ArrayWrapper osztályunk, például így:

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

Figyeljük meg, hogy a másoló konstruktornak egyszerre kell memóriát allokálnia és minden értéket egyenként kimásolnia a tömbből! Ez rengeteg munka egy másoláshoz. Adjunk hozzá egy move konstruktort, és nyerjünk egy hatalmas hatékonyságot.

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, a move konstruktor valójában egyszerűbb, mint a copy konstruktor!Ez nem semmi. A legfontosabb dolgok, amiket észre kell venni:

  1. A paraméter egy nem const rvalue referencia
  2. other._p_vals NULL-ra van állítva

A második megfigyelés megmagyarázza az elsőt–nem tudnánk other._p_vals-tNULL-ra állítani, ha const rvalue referenciát vennénk. De miért kell azother._p_vals = NULL-t beállítanunk? Az ok a destruktor–amikor az ideiglenes objektum kikerül a hatóköréből, mint minden más C++ objektum, a destruktora lefut.Amikor a destruktora lefut, felszabadítja _p_vals. Ugyanazt az _p_vals-t, amit az imént másoltunk! Ha nem állítjuk a other._p_vals-t NULL-ra, akkor a mozgatás nem is lenne igazi mozgatás – csak egy másolás lenne, ami később összeomlást okoz, amint elkezdjük használni a felszabadított memóriát. Ez a move konstruktor lényege: elkerülni a másolást az eredeti, ideiglenes objektum megváltoztatásával!

A túlterhelési szabályok ismét úgy működnek, hogy a move konstruktort csak egy ideiglenes objektumra hívjuk meg–és csak egy olyan ideiglenes objektumra, amely módosítható. Ez azt jelenti, hogy ha van egy olyan függvényünk, amely egy const objektumot ad vissza, akkor az a másoló konstruktort fogja futtatni a mozgatás-konstruktor helyett – tehát ne írjunk ilyen kódot:

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

Még egy helyzet van, amit még nem beszéltünk meg, hogyan kell kezelni a mozgatás-konstruktorban – amikor van egy mezőnk, ami egy objektum. Például képzeljük el, hogy méret mező helyett egy metaadat mezőnk van, ami így néz ki:

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

Most a tömbünknek lehet neve és mérete, így lehet, hogy az ArrayWrapper definícióját így kell megváltoztatnunk:

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

Ez működik? Ugye nagyon természetesnek tűnik, hogy csak meghívjuk a MetaDatamove konstruktort az ArrayWrapper move konstruktorából? A problémaaz, hogy ez egyszerűen nem működik. Az ok egyszerű: az other értéke a move konstruktorban – ez egy r-érték hivatkozás. De egy rértékreferencia valójában nem rérték. Ez egy l-érték, és így a másoló konstruktort hívja meg, nem pedig amozgató konstruktort. Ez furcsa. Tudom, ez zavaró. Így kell gondolkodni róla. Az rvalue egy olyan kifejezés, amely egy olyan objektumot hoz létre, amely hamarosan elpárolog a levegőbe. Ez az utolsó lábainál tart az életben–vagy épp azon van, hogy betöltse az életcélját. Hirtelen átadjuk az ideigleneset egy move konstruktornak, és az új életet kezd az új hatókörben. Abban a kontextusban, ahol az rvalue kifejezés kiértékelésre került, az ideiglenes objektumnak tényleg vége és kész. De a mi konstruktorunkban azobjektumnak van neve; a függvényünk teljes időtartama alatt élni fog.Más szóval, az other változót többször is használhatjuk a függvényben,és az ideiglenes objektumnak van egy meghatározott helye, amely valóban megmarad az egész függvényben. Ez egy lérték a locator value kifejezés valódi értelmében,az objektumot egy adott címen tudjuk lokalizálni, amely a függvényhívás teljes időtartamára stabil. Valójában lehet, hogy később is használni akarjuk afunkcióban. Ha egy move konstruktort hívnánk meg, valahányszor rvaluereferenciában tartanánk egy objektumot, akkor véletlenül egy mozgatott objektumot használhatnánk!

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

Végső soron: mind az lvalue, mind az rvalue referenciák lvalue kifejezések. Akülönbség az, hogy egy lvalue referenciának const-nak kell lennie ahhoz, hogy egyrvalue-referenciát tartson, míg egy rvalue-referencia mindig egy rvalue-ra való hivatkozást tarthat.Ez olyan, mint a különbség a pointer és aközött, amire mutat. A dolog, amire mutattunk, egy rvalue-ból származik, de amikor magát az rvalue-referenciát használjuk, az egy lvalue-t eredményez.

std::move

Szóval mi a trükk ennek az esetnek a kezelésében? Használnunk kell az std::move, from<utility>–std::move egy módja annak, hogy azt mondjuk: “ok, őszintén tudom, hogy van egy lértékem, de azt akarom, hogy rérték legyen.” Az std::move önmagában nem mozgat semmit; csak egy lértéket rértékké változtat, hogy meghívhassuk a move konstruktort. A kódunknak így kellene kinéznie:

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

És persze tényleg vissza kellene mennünk a MetaData-hoz, és kijavítani a saját move konstruktorát, hogy az std::move-t használja a benne lévő stringre:

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

Move hozzárendelési operátor

Amint ahogy van egy move konstruktorunk, úgy kell lennie egy move hozzárendelési operátornak is. Könnyen írhatunk egyet ugyanazzal a technikával, mint a move konstruktor létrehozásához.

Move konstruktorok és implicit módon generált konstruktorok

Amint tudjuk, a C++-ban, amikor bármilyen konstruktort deklarálunk, a fordító nemgenerálja számunkra az alapértelmezett konstruktort. Ugyanez igaz itt is: ha egy osztályhoz hozzáadsz egy mozgatható konstruktort, akkor a saját alapértelmezett konstruktorodat kell deklarálnod és definiálnod. Másrészt, egy move konstruktor deklarálása nem akadályozza meg a fordítót abban, hogy egy implicit módon generált másolatkonstruktort adjon, és egy move hozzárendelési operátor deklarálása nem akadályozza meg egy standard hozzárendelési operátor létrehozását.

Hogyan működik az std::move

Elgondolkodhatunk azon, hogy hogyan írunk egy olyan függvényt, mint az std::move? Hogyan kapjuk meg ezt a varázslatos tulajdonságot, hogy egy lértéket értékreferenciává alakítunk? A válasz, ahogy azt már sejtheted, a tipizálás. Az std::move tényleges deklarációja valamivel bonyolultabb, de alapjában véve ez csak egy static_cast egy rértékhivatkozásra. Ez tulajdonképpen azt jelenti, hogy nem igazán kell használnod a move-t – de kellene, mivel sokkal világosabb, hogy mit értesz alatta. Az a tény, hogy szükség van egy cast-ra, egyébként nagyon jó dolog! Ez azt jelenti, hogy nem tudsz véletlenül egy lértéket rértékké alakítani, ami veszélyes lenne, mivel ez lehetővé tenné egy véletlen lépés végrehajtását. Kifejezetten std::move-ot (vagy cast-ot) kell használnod ahhoz, hogy egy lértéket egy rértékreferenciává alakíts, és egy rértékreferencia soha nem fog egy lértékhez kötődni.

Egy explicit rértékreferencia visszaadása egy függvényből

Létezik olyan eset, amikor olyan függvényt kell írnod, amely egy rértékreferenciát ad vissza? Mit jelent egyébként egy rvalue-referenciát visszaadni? Az objektumokat érték szerint visszaadó függvények nem már rértékek?

Válaszoljunk először a második kérdésre: explicit rérték-hivatkozást visszaadni más, mint objektumot érték szerint visszaadni. Vegyük a következő egyszerű példát:

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

Az első esetben nyilvánvaló, hogy annak ellenére, hogy a getInt() egy rérték, az x változóról egy másolat készül. Ezt egy kis segédfüggvény megírásával is láthatjuk:

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

Amikor lefuttatjuk ezt a programot, látni fogjuk, hogy két külön értéket nyomtatunk ki.

A másik oldalon a

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

ugyanazt az értéket nyomtatja ki, mert itt kifejezetten egy r-értéket adunk vissza.

Az r-értékreferencia visszaadása tehát más dolog, mint az, hogy nem adunk vissza r-értékreferenciát, de ez a különbség leginkább akkor jelentkezik, ha egy már létező objektumot adunk vissza a függvényben létrehozott ideiglenes objektum helyett (ahol a fordító valószínűleg megszünteti helyettünk a másolást).

Most térjünk rá arra a kérdésre, hogy akarjuk-e ezt csinálni. A válasz:valószínűleg nem. A legtöbb esetben ez csak valószínűbbé teszi, hogy a végén egy lógó hivatkozást kapsz (olyan eset, amikor a hivatkozás létezik, de az ideiglenesobjektum, amelyre hivatkozik, megsemmisült). A probléma nagyon hasonló az lvalue hivatkozás visszaadásának veszélyéhez – a hivatkozott objektum már nem létezik. Az rértékreferenciák nem tudnak egy objektumot varázslatos módon életben tartani helyetted.Az rértékreferencia visszaadásának elsősorban nagyon ritka esetekben lenne értelme, amikor van egy tagfüggvényed, és vissza kell adnod azstd::move meghívásának eredményét az osztály egy mezőjén a függvényből – és milyen gyakran fogod ezt megtenni?

Move szemantika és a szabványos könyvtár

Visszatérve az eredeti példánkhoz — egy vektort használtunk, és nem rendelkezünk a vektor osztály felett, és hogy van-e benne move konstruktor move hozzárendelési operátor vagy sem. Szerencsére a szabványügyi bizottság bölcs, és aove szemantikát hozzáadták a szabványos könyvtárhoz. Ez azt jelenti, hogy most már hatékonyan adhatunk vissza vektorokat, leképezéseket, karakterláncokat és bármilyen más szabványos könyvtári objektumot, kihasználva a move szemantikát.

Moveable objects in STL containers

Sőt, a szabványos könyvtár még egy lépéssel tovább megy. Ha a saját objektumainkban engedélyezzük a mozgathatóságot a mozgathatósági hozzárendelési operátorok és mozgathatósági konstruktorok létrehozásával, akkor amikor ezeket az objektumokat tároljuk egy konténerben, az STL automatikusan az std::move-ot fogja használni, automatikusan kihasználva a mozgathatósági osztályok előnyeit a nem hatékony másolatok kiküszöbölése érdekében.

Articles

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.