std::move
Jedną z ciekawszych i najbardziej przydatnych możliwości nowego standardu C++ jest przenoszenie obiektów. Co mam na myśli mówiąc, że dany obiekt może zostać przeniesiony? W dużym uproszczeniu będzie to oznaczać “nie skopiowany”. Funkcja szczególnie przydatna do uniknięcia zbędnego kopiowania obiektów tymczasowych.
Wyobraźmy sobie pewien abstrakcyjny przykład: klasa Bar
wykorzystuje obiekty
klasy Foo
. W związku z tym przechowuje wewnętrznie obiekty tej klasy. W C++03
mogłoby to wyglądać następująco:
class Foo;
struct Bar
{
explicit Bar(const Foo& f) : myFoo(f) {}
Foo myFoo;
};
int main()
{
Bar b((Foo()));
}
Problem powyższego rozwiązania tkwi w niepotrzebnym kopiowaniu. Obiekt klasy
Foo
zostanie najpierw utworzony ze swoim domyślnym konstruktorem, a następnie
skopiowany już wewnątrz klasy Bar
. Jednak to kopiowanie jest całkowicie
niepotrzebne, bo oryginał już po chwili przestaje istnieć. Znacznie lepiej
byłoby przesunąć informacje z takiego tymczasowego obiektu w nowe miejsce.
Wyobraźmy sobie takie przeniesienie informacji o obiekcie (a więc jego atrybutów) jako przeprowadzkę do nowego mieszkania. Dopiero co je kupiliśmy, wszystkie graty są już przeniesione, nic, tylko zburzyć ściany w poprzednim lokum, żeby zostawić za sobą wszystkie nieprzyjemne wspomnienia, prawda? Nie do końca. Przede wszystkim, poprzednie mieszkanie nie było nasze (bo wynajmowane od czasu studiów, jeszcze za pieniądze rodziców) i jego właściciel może chcieć je ponownie wynająć. Również musimy mieć na względzie to, żeby nasi znajomi nie próbowali się z nami kontaktować pod starym adresem, bo może ich spotkać niemiła niespodzianka.
Właśnie na coś takiego pozwala operacja przenoszenia obiektu, jedynie z pewnym zastrzeżeniem: oryginał musi być nadal ważnym obiektem w sensie możliwości jego użycia. Przecież, jeśli był utworzony na stosie, to w momencie jego zwijania obiekt musi zostać w normalny sposób usunięty. Być może też ktoś przechowuje referencję do takiego obiektu i chciałby go wywołać…
Niebezpieczne zabawy
Zatrzymajmy się na chwilę w tym miejscu. Przed chwilą poprzez analogię do
przeprowadzki pokazałem, że próby odwoływania się do przeniesionego obiektu mogą
dać niespodziewane (niezdefiniowane) wyniki, a teraz wspominam o tym, że ktoś
mógłby chcieć się do takiego obiektu odwołać? Od razu powiem - nie powinno się
tego robić. Sytuacja przypomina problemy z jedynym sprytnym wskaźnikiem
udostępnianym przez bibliotekę standardową przed C++11, czyli std::auto_ptr
.
Problem z nim jest taki, że operacje, które wyglądają jak kopiowanie są tak
naprawdę niejawnym przesunięciem. Jeśli zatem utworzony auto_ptr
przekazać do
funkcji, to poza nią traci on swoją ważność. Przykład:
void doSthWithPtr(std::auto_ptr<Foo> p);
int main()
{
std::auto_ptr<Foo> fooPtr(new Foo);
doSthWithPtr(foo);
fooPtr->fooMethod(); // niepoprawne, przeniesione do zmiennej "p" funkcji doSthWithPtr
}
Nie jest to dobrze przemyślane rozwiązanie i w bardziej skomplikowanym kodzie
często prowadzi do błędów. Z tego powodu w nowej wersji C++ auto_ptr
został
zastąpiony przez unique_ptr
. Z drugiej strony zwracanie std::auto_ptr
jako
wyniku działania funkcji jest całkowicie poprawne i nie niesie z sobą żadnego
ryzyka, a umożliwia automatyczne usuwanie opakowanego wskaźnika. Z tego
czerpiemy naukę, że aby przenoszenie obiektów w ogóle miało jakiś sens, musimy
być w stanie określić, kiedy jest ono bezpieczne, a kiedy nie. Jeśli zaś chcemy
umożliwić przenoszenie obiektów nawet gdy nie jest to bezpieczne, należy robić
to jawnie.
Referencja do r-wartości (rvalue reference)
Jedynymi bezpiecznymi - z punktu widzenia przenoszenia - instancjami danej klasy
są tzw. r-wartości (rvalues), czyli obiekty tymczasowe istniejące jedynie
przez jedno wyrażenie. Przykładem r-wartości jest wywołanie konstruktora bez
tworzenia obiektu (jak w pierwszym przykładzie) albo wynik jakiegoś działania,
np. dodawania (foo1 + foo2
zwróci trzeci obiekt wynikowy, który jest właśnie
r-wartością). C++11 rozwiązuje problem rozpoznawania takich obiektów poprzez
wprowadzenie nowego typu referencyjnego, rvalue reference. Aby powiedzieć, że
w danym miejscu spodziewamy się referencji do r-wartości, używamy podwójnego
ampersanda (“&&
”) zapisywanego po nazwie typu. Dzięki takiemu zapewnieniu
wiemy, że możemy bez wahania i do woli przenosić obiekt rezydujący pod daną
zmienną.
Przykład sygnatury funkcji, która przyjmuje referencję do r-wartości jako
argument: void doSth(Foo&& givenFoo)
Zwracam przy tym uwagę na brak słowa
kluczowego const
przed nazwą typu, które często występuje wraz ze zwykłymi
referencjami. Wprawdzie jest ono dozwolone i oczywiście istnieją stałe
referencje do r-wartości, lecz z powodu ich specyfiki, miałyby one znikome
znaczenie. Jedynym zastosowaniem dla const rvalue reference, które potrafię
wymyślić, jest przeładowanie funkcji w celu zapewnienia, że jej parametr nie
zostanie przekazany przez r-wartość:
void doSth(const Foo& sth) {
// kod
}
void doSth(const Foo&&) = delete; // błąd kompilacji np. przy wywołaniu doSth((Foo());
Należy przy tym zaznaczyć, że obiektu przekazanego przez referencję do r-wartości wcale nie musimy przenosić! Możemy go równie dobrze skopiować, czy też wykonać na nim jakiekolwiek inne operacje. Nie jest to jednak zalecane z dwóch powodów. Po pierwsze sztucznie się ograniczamy, bo używamy wówczas takiej zmiennej jak zwykłej referencji, lecz przy próbie przekazania l-wartości (lvalue (czyli odwrotności r-wartości) kompilator zwróci błąd. Po drugie piszemy kod całkowicie wbrew konwencji sugerując przy tym, że dany obiekt jest przenoszony, pomimo że nie jest.
Konstruktor przenoszący (move constructor)
Miejscem, w którym decydujemy o sposobie przenoszenia obiektu jest konstruktor
przenoszący. Jest to nowy typ konstruktora wprowadzony w C++11, którego
sygnatura jest następująca: Foo::Foo(Foo&& that)
. To w nim przenosimy
informacje o obiekcie that
do nowego obiektu. Można więc powiedzieć, że w ten
sposób zmieniane jest posiadanie zarządzanych zasobów. Pamiętamy bowiem o tym,
że przeniesienie zasobów wiąże się z ich zresetowaniem w oryginalnym obiekcie (w
przeciwnym wypadku byłoby to kopiowanie). Najlepiej to zostanie zobrazowane na
przykładzie:
template <typename MyType>
class Foo
{
MyType* myObj;
public:
Foo(Foo&& that) : myObj(that.myObj)
{
that.myObje = nullptr;
}
};
W powyższym przykładzie po prostu podmieniliśmy wskaźnik na obiekt typu MyType
i zdezaktualizowaliśmy go w oryginale. Jest to niezwykle ważne, bo w momencie
wywołania destruktora, oryginał mógłby próbować go usunąć. Próba usunięcia
wskaźnika NULL jest natomiast niegroźna.
Fizycznie zmieniliśmy zatem jedynie wartości dwóch wskaźników, co jest operacją
niezwykle tanią, gdyż uniknęliśmy kosztownego kopiowania obiektu typu MyType
,
który nie wiadomo co tam w sobie ma, być może kontenery przechowujące tysiące
innych obiektów. Jednocześnie zmieniliśmy właściciela zasobów, gdyż oryginalny
obiekt Foo
przestał mieć o nich jakiekolwiek pojęcie.
Przykład ten moglibyśmy również zaimplementować przy użyciu idiomu move-and-swap (wariacja copy-and-swap), czyli przenieś i podmień. Wykorzystuje on fakt, że przy konstruowaniu obiektu, do którego będziemy przenosić zarządzane zasoby, wpierw ustawiamy je na jakieś wartości początkowe. Wystarczy zatem podmienić te wartości początkowe z wartościami przenoszonymi (tj. zamienić je miejscami) i efekt będzie ten sam.
// move-and-swap
template <typename MyType>
class Foo {
MyType* myObj;
public:
friend swap(Foo& first, Foo& second)
{
std::swap(first.myObj, second.myObj);
}
Foo(Foo&& that) : myObj(nullptr)
{
swap(*this, that);
}
Foo& operator=(Foo&& rhs)
{
swap(*this, rhs);
return *this;
}
};
Tym razem zaimplementowany został także operator przypisania wspierający
semantykę przenoszenia. Dzięki wykorzystaniu zaprzyjaźnionej operacji swap
zaoszczędziliśmy również przy pisaniu kodu, gdyż jest ona jedynym miejscem gdzie
faktycznie następuje podmiana. Taka implementacja ma również inne zalety, jednak
powiem o nich być może innym razem. ## Przenoszenie L-wartości
Może się zdarzyć tak, że w celu zaoszczędzenia cykli procesora poświęconych na
kopiowanie, będziemy chcieli przenieść obiekt będący l-wartością, czyli nie
będący obiektem tymczasowym. Wiemy bowiem, że już za chwilę dany obiekt
przestanie mieć dla nas w danym miejscu znaczenie i chcielibyśmy zmienić jego
właściciela, biorąc za to pełną odpowiedzialność. Wobec tego musimy jawnie
zadeklarować chęć przeniesienia l-wartości. Standard C++11 przewidział taką
możliwość i udostępnia w tym celu funkcję pomocniczą dostępną w pliku
nagłówkowym <utility>
, std::move
. Funkcja ta dzięki rzutowaniu zwraca
dowolny dostarczony jej argument jako referencję do r-wartości. Wywołując zatem
std::move
w jawny sposób mówimy, że wyrażamy zgodę na przeniesienie danego
obiektu wraz ze wszystkimi konsekwencjami tego działania. Użycie wygląda w
sposób następujący:
Foo myObj;
Foo newObj(std::move(myObj));
Wróćmy jeszcze na chwilę do klasy Bar
, która przechowuje swój egzemplarz
obiektu Foo przekazywany w konstruktorze. Moglibyśmy przeładować konstruktor
jednoargumentowy klasy Bar
, aby przyjmował również obiekty przez referencję do
r-wartości. W ten sposób można udostępnić użytkownikowi klasy zarówno możliwość
kopiowania obiektów Foo
jak i ich przenoszenia. Należy jedynie pamiętać o tym,
że wewnątrz konstruktora przenoszącego obiekt przekazany przez referencję do
r-wartości zostanie przypisany do pewnej zmiennej referencyjnej i stanie się
l-wartością. Należy zatem ponownie jawnie zadeklarować chęć jego przeniesienia
poprzez użycie funkcji std::move
, gdyż w przeciwnym razie zostałby skopiowany.
Najlepiej, jeśli pokażę to na przykładzie:
explicit Bar(Foo&& f) : myFoo(f) {} // 1
explicit Bar(Foo&& f) : myFoo(std::move(f)) {} // 2
W wersji 1
konstruktora przenoszącego spowodujemy skopiowanie obiektu f
do
myFoo
. f
jest l-wartością - nie jest to zmienna tymczasowa, która zaraz
ginie, może natomiast być użyta w całym ciele konstruktora. Jeśli zależy nam na
faktycznym przeniesieniu obiektu, powinniśmy jawnie to zadeklarować, tak jak ma
to miejsce w wersji 2
konstruktora przenoszącego. Zwróćmy uwagę, że spowoduje
to wywołanie konstruktora przenoszącego klasy Foo
, który w analogiczny sposób
może chcieć przenosić zasoby, którymi zarządza. W ten sposób może zostać
wywołana cała kaskada konstruktorów przenoszących.
Domyślny konstruktor przenoszący
Co, jeśli w którymś momencie owej kaskady natrafimy na obiekt, który nie ma
zaimplementowanego konstruktora przenoszącego? Na szczęście tutaj do gry włącza
się kompilator, który domyślnie wygeneruje konstruktor przenoszący dla
większości obiektów (a także przenoszącą wersję operatora przypisania).
Generacja taka nie zostanie on jedynie wykonana, gdy autor danej klasy jawnie
określi, że jej sobie nie życzy (X(X&&) = delete;
) lub jawnie destruktor,
konstruktor kopiujący lub kopiujący operator przypisania. Z drugiej strony,
jeśli jawnie określimy destruktor, konstruktor przenoszący lub przenoszący
operator przypisania, kompilator nie wygeneruje konstruktora kopiującego i
kopiującego operatora przypisania. Może się również zdarzyć tak, że określimy
pewien konstruktor przenoszący, ale jeśli będzie on inny niż domyślny,
kompilator nadal będzie takowy generował.
Aby zmusić kompilator do wygenerowania np. domyślnego konstruktora przenoszący dla klasy, w której określiliśmy konstruktor kopiujący, musimy zapisać to następująco:
struct X {
X(const X& that);
X& operator=(X rhs);
X(X&&) = default;
};
Mam nadzieję, że semantyka przenoszenia w C++11, jeśli do tej pory nie była do
końca zrozumiała, stała się dzięki temu artykułowi nieco jaśniejsza. Jest to
bowiem doskonała technika optymalizacyjna, warta stosowania. Warto tu jeszcze
tylko nadmienić, że biblioteka standardowa została przystosowana do przenoszenia
obiektów. Zatem, wywołując std::vector::push_back
możemy przekazać referencję
do r-wartości i wektor przeniesie przekazany w ten sposób obiekt zamiast go
kopiować. Warto o tym poczytać i zorientować się, w których miejscach możemy
dodatkowo zoptymalizować nasze programy.