Körülbelül ezzel lehet leírni a move konstruktor és a move assignment operátor funkcióját. Vagyis, ha egy objektumot értékszerint átmozgatunk egy másik helyre, miközben tudjuk, hogy a régi hely megszűnik, akkor a heapen tárolt dolgait felesleges újralefoglalni és átmásolni, elég ha a pointereket áttesszük az új objektumba, és lenullázuk a régiben. Ahhoz hogy ezt kényelmesen(???) megtehessük bevezették az rvalue referenciát. (Szerint egyszerűbb azt kiejteni, hogy balérték vagy jobbérték, minthogy lvalue vagy rvalue. De az egyértelműség miatt írásban továbbra is az angol kifejezéseket fogom használni.) Figyelem, ami most következik, az high magic! Mindazonáltal a jövőben felgyorsíthatja a programunkat, így érdemes megérteni valamennyire.
Először is tisztázzuk, mi is az rvalue és az lvalue. Általában azt szoktuk rvalue-nak hívni, ami ideiglenesen létrehozott objektum. De igazából vannak rvalue-k, amik tovább élnek, mint egyes lvalue-k. Valahol azt olvastam, hogy úgy tudjuk könnyen megkülönböztetni őket, hogy az lvalue-knak van nevük, míg az rvalue-k nincs. Legalábbis körülbelül igaz ez. Ez egy viszonylag jó magyarázat, de nem tökéletes. Talán a legpontosabb fogalmazás az, hogy rvalue az, aminek le tudjuk kérdezni a címét az & operátorral. Rvalue-t általában azok a függvények és operátorok adnak vissza, amiknek a visszatérési értékének a típusa nem lvalue referencia; illetve bizonyos kasztolásoknál is rvalue keletkezik, ahol ideiglenesen új objektumot kell létrehozni; illetve a legtöbb literál is rvalue (ez utóbbit a szabványból nem tudtam kiolvasni). Például rvalue az 5 és true. De mondjuk lvalue az x változó. Igazából minden változó lvalue. Függvényekre visszatérési értékeire pedig itt van három példa:
int fn1();
int& fn2();
int&& fn3();
A két zöld függvény (monokróm monitor előtt ülők kedvéért: fn1 és fn3) rvalue-t ad vissza, młg a kék fn2 lvalue-t. A kisérletezéseim alapján a string literál konstans lvalue-nak számít, de nem vagyok biztos benne. (A szabványból nem tudtam kitalálni.)
lvalue-ra hivatkozhatunk lvalue referenciával, ami azonos a jó öreg referenciával. Egy & jel jelöli. rvalue-ra viszont lvalue referenciák közül csak a konstans verzióval hivatkozhatunk. Ezért van az, hogy ha egy függvény const string& paraméterrel rendelkezik, annak át tudunk adni egy string literált, míg egy string& paraméterű függvénynek nem. Rvalue referenciával ezzel szemben tetszőleges rvalue-ra hivatkozhatunk, és meg is változtathatjuk őket. A jele: &&. Természetesen ebből is van konstans verzió.
Mivel most referenciákat fogunk nagy mennyiségben egymásra halmozni, még egy fontos szabály jön be: a referenciák összeolvadhatnak. Természetesen nem írhatunk két referencia jelet egymás mellé, de egy typedeffel létrehozott típus, vagy egy template paraméter hordozhat magában már egy referenciát, és a felhasználás helyén ehhez hozzáadhatunk egy továbbit is. A C++-ban nincs referenciára mutató referencia, így ilyen esetben a többszörös referenciák egyszeressé alakulnak át. Ráadásul az lvalue referencia a domináns:
typedef int& T;
typedef int&& U;
void alfa(T&& x) { }
void beta(U&& x) { }
void gamma(U& x) { }
alfa(5);
beta(5);
gamma(5);
Ezekből a kifejezésekből alfa és gamma meghívása hibás, mert a referencia összeolvadási szabály alapján lvalue referenciával probálnak rvalue-ra mutatni.
Ha az rvalue referenciát függvényparaméter típusaként alkalmazzuk, akkor tudnunk kell, hogy csak rvalue-kat kap el, abban viszont erősebb konstans lvalue referenciánál.
void multi(string& a) { cout << "one\n"; }
void multi(const string& a) { cout << "two\n"; }
void multi(string&& a) { cout << "three\n"; }
void multi(const string&& a) { cout << "four\n"; }
string s = "a";
const string s2 = "b";
multi(s);
multi(s2);
multi(string("d"));
A végeredménye:
one
two
three
Az első két hívás lvalue-t adott át a függvényeknek, ezért az lvalue referenciák kapták el. A harmadik esetben rvalue-t adunk át, amit az rvalue referencia kap el.
A két függvény, amit meg kell írnunk egy osztály mozgatásának felgyorsításához a move construktor, ami erősen hasonlít a copy construktorra, és a move értékadás (assignment) operátor, ami erősen hajaz a copy értékadás operátorra, vagyis a régi értékadás operátorra. A különbség a move és a copy függvények között, hogy copy esetén az értékek megduplázódnak, míg a move-nál a régi ideiglenes objektumból eltűnnek, és az új objektumba költöznek.
Lássuk ezt egy példán. Először írok egy hagyományos objektumot, ami egy karaktertömböt tárol a heapen. Nevezzük MovableStore-nak az osztályt:
#include <memory>
#include <iostream>
using namespace std;
class MovableStore
{
private:
static const size_t defaultSize = 32;
char *m_data;
size_t m_size;
void clear()
{
delete[] m_data;
release();
}
void release()
{
m_data = NULL;
m_size = 0;
}
public:
// Konstruktorok
// Default konstruktor
MovableStore():
m_data(new char[defaultSize]), m_size(defaultSize)
{
cout << "Default konstruktor: size = 32, 0x" << hex << this << dec << endl;
memset(m_data, 0, defaultSize);
}
// Egyparameteres konstruktor
explicit MovableStore(size_t size, bool b = true):
m_data(new char[size]), m_size(size)
{
cout << "Konstruktor: size = " << size << ", 0x" << hex << this << dec << endl;
memset(m_data, 0, m_size);
}
// Copy konstruktor
MovableStore(const MovableStore& other):
m_data(new char[other.m_size]), m_size(other.m_size)
{
cout << "Copy konstruktor: size = " << m_size << ", 0x" << hex << &other << " -> 0x" << this << dec << endl;
memcpy(m_data, other.m_data, m_size);
}
// Copy assignment operator
MovableStore& operator =(MovableStore& other)
{
cout << "Copy ertekadas operator: size = " << other.m_size << ", 0x" << hex << &other << " -> 0x" << this << dec << endl;
if (&other == this)
return *this;
delete[] m_data;
m_data = new char[other.m_size];
memcpy(m_data, other.m_data, other.m_size);
m_size = other.m_size;
return *this;
}
// Destruktor
~MovableStore()
{
cout << "Destruktor: size = " << m_size << ", 0x" << hex << this << dec << endl;
clear();
}
// Lekerdezofuggvenyek
char *getStorage() const { return m_data; }
size_t getSize() const { return m_size; }
bool isEmpty() const { return m_size == 0; }
};
Megjegyzés: az egyparaméteres konstruktor elé odaírtam az explicit kulcsszót. Ez fontos. Ugyanis azok a konstruktorok, amiknek egyetlen paramétere van, vagy amiknek a default értékeit behelyettesítve egyparaméteressé tehetők, azok implicit típuskonverziós konstruktorként is működnek. Vagyis úgy tudunk velük kasztolni, hogy ki sem írjuk a típust. Tehát, ha nem lenne ott az explicit, akkor ez is értelmes lenne: MovableStora a = 5; Ami azért meglepő. De még fontosabb, hogy egy bonyolult kifejezésben a tudtunk és beleegyezésünk nélkül kasztolgathatna kedvére. Mondjuk az explicit szó után is jó ez a sor: MovableStore a = (MovableStore)5; Dehát ehhez már explicit hülyének kell lenni.
Akkor most adjuk hozzá a move konstruktort és a move értékadás operátort:
// Move konstruktor
MovableStore(MovableStore&& other):
m_data(0), m_size(0)
{
cout << "Move konstruktor: size = " << other.m_size << ", 0x" << hex << &other << " -> 0x" << this << dec << endl << " ";
*this = move(other);
}
// Move assignment operator
MovableStore& operator =(MovableStore&& other)
{
cout << "Move ertekadas operator: size = " << other.m_size << ", 0x" << hex << &other << " -> 0x" << this << dec << endl;
if (&other == this)
return *this;
delete[] m_data;
m_data = other.m_data;
m_size = other.m_size;
other.release();
return *this;
}
Amint láthatjuk a move függvényekben rvalue referencia van a paraméterlistában. Az értékadás operátor törli az esetleges tartalmat, ha van. Ha nincs, vagyis nullpointer van az m_data-ban, akkor a delete nem fog csinálni semmit. Ezután a pointert és a hosszt átmove-olja a paraméterből. Természetesen ügyelni kell az önmagunkra másolásra, ahogy mindig. A move konstruktorban pedig célszerű átirányítani a vezérlést a move értékadás operátorra, mert így nem lesz megduplázva a kód. Azonban ehhez egy STL-es függvényt is meg kell hívni: a move-ot. Ez nem csinál mást, minthogy rvalue referenciaként adja vissza a bemenetére adott értéket. Ahogy az elején már írtam, egy függvény, ami rvalue referenciát ad vissza, annak a visszatérési értéke rvalue lesz, ebből kitalálható, hogy a move rvalue-t csinál a beletett értékből. De miért kell ez? Nos, ezt is említettem már az elején: minden változó lvalue, márpedig az rvalue referencia paraméterünk lokális változó! Vagyis annak ellenére lvalue, hogy a típusa rvalue referencia. Hát igen, elég fura dolgokat produkál ez az új referenciatípus.
Megjegyzés: Az összes értékadás (=, +=, *=, stb.) operátor visszatérési értéke lvalue referencia, hiszen a baloldalát adjuk vissza. Az lvalue eredetileg innen kaphatta a nevét.
Egy kis teszt:
cout << "Letrehozzuk m1-et:\n";
MovableStore m1;
cout << "m1 cime: 0x" << hex << &m1 << dec << "\n\nLetrehozzuk m2-t:\n";
MovableStore m2(56);
cout << "m2 cime: 0x" << hex << &m2 << dec << "\n\nAtmasoljuk m2-t m1-be:\n";
m1 = m2;
cout << "\nBemasolunk egy ideiglenes objektumot m1-be:\n";
m1 = MovableStore(26);
cout << "\nAtmozgatjuk m2-t m1-be:\n";
m1 = move(m2);
if (m2.isEmpty())
cout << "\nm2 most mar ures.\n";
És az eredménye:
Letrehozzuk m1-et:
Default konstruktor: size = 32, 0x0018F87C
m1 cime: 0x0018F87C
Letrehozzuk m2-t:
Konstruktor: size = 56, 0x0018F86C
m2 cime: 0x0018F86C
Atmasoljuk m2-t m1-be:
Copy ertekadas operator: size = 56, 0x0018F86C -> 0x0018F87C
Bemasolunk egy ideiglenes objektumot m1-be:
Konstruktor: size = 26, 0x0018F70C
Move ertekadas operator: size = 26, 0x0018F70C -> 0x0018F87C
Destruktor: size = 0, 0x0018F70C
Atmozgatjuk m2-t m1-be:
Move ertekadas operator: size = 56, 0x0018F86C -> 0x0018F87C
m2 most mar ures.
Amint láthatjuk move-olásnál a move-olt objektum kiürül, és a másik megkapja a tartalmát. Eddig rendben is vagyunk.
Végeztem egy sebességtesztet: egy vectort feltöltöttem 1000000 rvalue MovableStore objektummal, majd töröltem az egészet, és ezt megismételtem 10-szer. A copy verzió 2.9282 s-ig futott, míg a move verzió 1.38276 s-ig. Tehát a gyorsulás 2.12-szörös, vagyis 112%-os. Ez azért elég jó, és még ha nem is minden esetben lesz ennyi, de azért biztosan számottevő lesz a javulás. Összesen 22099753 konstruktor futott le mindkét esetben. Ebből 10000000 az rvalue objektumé volt, és 12099753 pedig a vector újraméretezése miatt történt. A copy esetben 22099753 delete, new és memcpy történt, a move esetben viszont csak 10000000 delete és new. Mivel a vector átméretezésekor nem kellett megszüntetni és újra létrehozni a heapben tárolt adatterületünket. Emiatt lett gyorsabb. Egy vector<vector<MovableStore>> pedig még gyorsabb lehet a külső vector átméretezésekor, hiszen itt csak a belső vector objektumok fejlécobjektumait kell újrafoglalni. A move miatt a MovableStore-hoz hozzá sem fog érni. Természetesen itt nem a MovableStore move-szemantikája lesz a mérvadó, hanem a vectoré.
Azonban az rvalue referencia még egy dologra jó: ez pedig a stringösszeadás, és minden ahhoz hasonlatos feladat. Ha stringeket adunk össze, minden összeadás után létrejön egy új ideiglenes objektum, ami új stringet foglal le a memóriában, majd belemásolja a másik kettő összegét. Elég kölcséges, és nem túl hatékony folyamat. Mostantól kezdve viszont az első ideiglenes objektumban fogja összegezni a string tartalmát, és ezt a végén átmove-olja a célobjektumba. Itt vannak az operátoraink hozzá (én nem összefűzök két területet, hanem karakterenként összeadom őket a változatosság kedvéért):
class MovableStore
{
...
private:
void add(const MovableStore& other)
{
if (other.m_size > m_size)
{
char *newData = new char[other.m_size];
memcpy(newData, m_data, m_size);
memcpy(newData + m_size, other.m_data + m_size, other.m_size - m_size);
delete[] m_data;
m_data = newData;
m_size = other.m_size;
}
for (size_t i = 0; i < m_size; ++i)
m_data[i] += other.m_data[i];
}
friend MovableStore operator +(const MovableStore& left, const MovableStore& right);
friend MovableStore&& operator +(const MovableStore& left, MovableStore&& right);
friend MovableStore&& operator +(MovableStore&& left, const MovableStore& right);
friend MovableStore&& operator +(MovableStore&& left, MovableStore&& right);
};
MovableStore operator +(const MovableStore& left, const MovableStore& right)
{
cout << "lvalue ref - lvalue ref osszeadas operator: 0x" << hex << &left << " + 0x" << &right << dec << endl;
MovableStore temp = left;
temp.add(right);
return temp;
}
MovableStore&& operator +(const MovableStore& left, MovableStore&& right)
{
cout << "lvalue ref - rvalue ref osszeadas operator: 0x" << hex << &left << " + 0x" << &right << dec << endl;
right.add(left);
return move(right);
}
MovableStore&& operator +(MovableStore&& left, const MovableStore& right)
{
cout << "rvalue ref - lvalue ref osszeadas operator: 0x" << hex << &left << " + 0x" << &right << dec << endl;
left.add(right);
return move(left);
}
MovableStore&& operator +(MovableStore&& left, MovableStore&& right)
{
cout << "rvalue ref - rvalue ref osszeadas operator: 0x" << hex << &left << " + 0x" << &right << dec << endl;
left.add(right);
right.clear();
return move(left);
}
Hogy az összeadás operátorok bal oldalán is meg tudjuk különböztetni az lvalue és rvalue értékeket, ezért inkább az operátor overloadingot használtam az osztályon belüli operátor deklaráció helyett. Hát igen, 4 esetünk van, hiszen mindkét oldal lehet rvalue és konstans lvalue is. Ha az összeadás operátor meg tudná változtatni az lvalue értékeket is, akkor be kéne venni a nem konstans lvalue referenciát is, és így már 9 overloadot kéne írnunk. Szerencsére most nem ilyen rossz a helyzet.
Megjegyzés: Amikor két rvalue-nk van, akkor az egyiket módosítjuk, de a másikat nem szükséges törölni, mert az hamarosan önmagától megsemmisül.
Amint láthatjátok itt az összes olyan helyen, ahol valamelyik paraméter rvalue referencia, a visszatérés is rvalue referencia. Ezekben az egyik rvalue-t módosítjuk, így nem kell újat létrehozni, és időt spórolunk meg. A végén pedig ezt az objektumot adjuk vissza. Az előző tesztünk folytassuk az összeadás operátor meghívásával:
cout << "\nMost lefoglalunk negy lvalue-t.\n";
MovableStore x1, x2, x3, x4;
cout << "Es az osszeguket az m1-be tesszuk.\n";
m1 = x1 + x2 + x3 + x4;
cout << "\nMost negy rvalue osszeget rakjuk az m1-be.\n";
m1 = MovableStore(40) + MovableStore(40) + MovableStore(40) + MovableStore(40);
cout << "\nVege.\n\n";
Itt két esetet vizsgálok, az egyik, ahol lvalue-kat ad össze, a másik, ahol rvalue-kat.
Most lefoglalunk negy lvalue-t.
Default konstruktor: size = 32, 0x0024FC20
Default konstruktor: size = 32, 0x0024FC18
Default konstruktor: size = 32, 0x0024FC10
Default konstruktor: size = 32, 0x0024FC08
Es az osszeguket az m1-be tesszuk.
lvalue ref - lvalue ref osszeadas operator: 0x0024FC20 + 0x0024FC18
Copy konstruktor: size = 32, 0x0024FC20 -> 0x0024FBF0
rvalue ref - lvalue ref osszeadas operator: 0x0024FBF0 + 0x0024FC10
rvalue ref - lvalue ref osszeadas operator: 0x0024FBF0 + 0x0024FC08
Move ertekadas operator: size = 32, 0x0024FBF0 -> 0x0024FBF8
Destruktor: size = 0, 0x0024FBF0
Most negy rvalue osszeget rakjuk az m1-be.
Konstruktor: size = 40, 0x0024FC40
Konstruktor: size = 40, 0x0024FC38
Konstruktor: size = 40, 0x0024FC30
Konstruktor: size = 40, 0x0024FC28
rvalue ref - rvalue ref osszeadas operator: 0x0024FC28 + 0x0024FC30
rvalue ref - rvalue ref osszeadas operator: 0x0024FC28 + 0x0024FC38
rvalue ref - rvalue ref osszeadas operator: 0x0024FC28 + 0x0024FC40
Move ertekadas operator: size = 40, 0x0024FC28 -> 0x0024FBF8
Destruktor: size = 0, 0x0024FC28
Destruktor: size = 0, 0x0024FC30
Destruktor: size = 0, 0x0024FC38
Destruktor: size = 0, 0x0024FC40
Vege.
Destruktor: size = 32, 0x0024FC08
Destruktor: size = 32, 0x0024FC10
Destruktor: size = 32, 0x0024FC18
Destruktor: size = 32, 0x0024FC20
Destruktor: size = 0, 0x0024FC00
Destruktor: size = 40, 0x0024FBF8
Az lvalue-k összeadásánál először le kell másolnunk az egyik lvalue-t, már őket nem változtathatjuk meg. Majd ebben az új ideiglenes objektumban szépen összeadogatjuk az értékeket, és a végén átmove-oljuk a célhelyre.
Az rvalue-k összeadásánál nincs szükség másolásra, az egyikben szépen összeadogatja az értékeket, majd a végén a célhelyre move-olja.
A vége után láthatjuk még a négy lvalue, valamint m1 és a kiürült m2 destruktorát.