Ten artykuł pochodzi z mojego starego bloga na kacperkolodziej.pl.
Standard C++11 wprowadził dla nas dwa nowe udogodnienia, które mogą uczynić nasz kod jeszcze szybszym. Są to referencje do r-wartości (r-value references) i semantyka przenoszenia danych (move semantics). Te dwa rozwiązania, dobrze wykorzystane, umożliwiają tworzenie szybszego i bardziej efektywnego kodu. W tym artykule zamierzam omówić zarówno referencje do r-wartości jaki semantykę przenoszenia. Są one ściśle powiązane.
Na pierwszy ogień pójdą referencje do r-wartości, ponieważ są one podstawą dla przenoszenia danych.
Referencje do r-wartości
Na samym początku powiedzmy sobie czym jest r-wartość i l-wartość. Spójrzmy na następujący kod:
int a = 6;
int &b = a;
L-wartość (ang. l-value) to określenie na wszystko co może stać po lewej
stronie znaku równości. Tutaj łatwiej powiedzieć, co nie jest l-wartością:
wszystkie obiekty tymczasowe. Czyli np. 6, którą widzimy w pierwszym wierszu
powyższego kodu. Liczba 6 nie jest l-wartością, ponieważ jest obiektem
tymczasowym. Z tego powodu nie jest l-wartością i nie można znaleźć sie po
lewej stronie znaku =. Nie ma również możliwości zrobienia takiego przypisania:
int() = a; // błąd!!!
Stworzony obiekt int() jest tymczasowy, nie ma nazwy i nie można nic do niego
przypisać. Do obiektu tymczasowego nie możemy stworzyć referencji jaką znamy z
języka C czy wcześniejszych standardów C++. Od tej reguły jest jeden mały
wyjątek, ale o nim wspomnę później. Nie możemy stworzyć zwykłej referencji do
r-wartości ponieważ referencja jest kolejną nazwą (aliasem) dla
istniejącego obiektu posiadającego nazwę. Ponieważ obiekt int() jest
obiektem tymczasowym, nie ma swojej nazwy. Nie można więc stworzyć do niego
standardowej referencji.
int &c = 6; // błąd!!!
int &c1 = int(); // błąd!!!
Od C++ 11 można mu stworzyć tzw. referencję do r-wartości. Jest to nazwa dla obiektu, który sam nie ma nazwy i wygląda tak:
int &&d = 6; // OK!
Oczywiście tworzenie referencji do r-wartości „dla sztuki" jest mało sensowne. Przydatność tego narzędzia możemy docenić dopiero kiedy zajmiemy się funkcjami i klasami. Głównym zastosowaniem referencji do r-wartości są funkcje przenoszące dane pomiędzy obiektami.
Wyjątek od reguły
Tak jak wspomniałem nieco wcześniej, od reguły istnieje wyjątek. Od dawna możemy tworzyć stałą referencję do typu, do której przypisujemy wartość tymczasową:
const int& e = 5; // OK!
Taka sytuacja została dopuszczona, aby można było w wywołaniu takiej funkcji:
void funkcja(const int&);
Podać wartość bezpośrednio - bez tworzenia wcześniej specjalnej zmiennej i stałej referencji do niej:
funkcja(5); // OK!
Semantyka przenoszenia danych
Wyobraźmy sobie, że potrzebujemy kontenera na n obiektów jakiegoś typu. Dla uproszczenia przyjmiemy, że będą to liczby całkowite ze znakiem (int). Stworzymy więc prostą klasę, która dynamicznie zaalokuje przestrzeń w pamięci na nasze liczby:
class kontener
{
int *T, n;
public:
kontener(int n_) : T(new int[n_]), n(n_)
{
std::cout << "tworzenie kontenera o poj. "
<< n << " pod adresem: "
<< reinterpret_cast<const void*>(this) << "\n";
}
~kontener()
{
std::cout << "usuwanie kontenera (adres: "
<< reinterpret_cast<const void*>(this) << ")\n";
delete[] T;
}
kontener& ustaw(int index, int wartosc)
{
T[index % n] = wartosc;
return *this;
}
void wyswietl()
{
for (int i = 0; i < n; cout << T[i++] << " ");
cout << "\n";
}
};
Kontener posiada 4 funkcje składowe: konstruktor, destruktor, ustaw i
wyswietl. Konstruktor w liście inicjalizacyjnej rezerwuje pamięć na n
obiektów typu int i zapisuje wskaźnik do tej pamięci oraz liczbę
przechowywanych w niej obiektów do zmiennych składowych. Destruktor zwalnia
zarezerwowaną pamięć. Funkcja ustaw służy do wpisania wartości do
zarezerwowanej pamięci i zwraca referencję do obiektu kontener, umożliwiając
łańcuchowanie wywołań:
kontener a(10);
a.ustaw(0,10).ustaw(1,15).ustaw(6,40).ustaw(7,50); // itd...
Powyższy program tworzy dwa obiekty klasy kontener. Drugi, o nazwie b, jest
tworzony przy pomocy tzw. konstruktora przenoszącego. Od standardu C++11
kompilator definiuje automatycznie więcej funkcji. W standardzie C++03 były to:
domyślny konstruktor, destruktor, konstruktor kopiujący i kopiujący operator
przypisania. Teraz otrzymujemy dodatkowo: konstruktor przenoszący i
przenoszący operator przypisania. Ich definicje wyglądają następująco:
klasa::klasa(klasa &&);
klasa& klasa::operator=(klasa &&);
Widzimy tutaj znane już referencje do r-wartości. Samo przenoszenie zostało stworzone aby uniknąć bezsensownego kopiowania dużych ilości danych z jednego obiektu do drugiego. Aby przenieść dane należy:
- skopiować dane z każdej zmiennej składowej jednego obiektu do odpowiedniej zmiennej składowej drugiego obiektu
- zmienne składowe obiektu źródłowego doprowadzić do jakiejkolwiek postaci, która nie będzie identyczna z ich starym stanem - w końcu dane mieliśmy przenieść, a nie skopiować
Kiedy klasa posiada wskaźnik do dynamicznie alokowanej pamięci, konstruktor
przenoszący może po prostu przekazać wskaźnik bez kopiowania zawartości pamięci.
Oto rozszerzona wersja klasy kontener z konstruktorem kopiującym i
przenoszącym:
class kontener
{
public:
int *T, n;
kontener(int n_) : T(new int[n_]), n(n_)
{
std::cout << "tworzenie kontenera o poj. " << n
<< " pod adresem: " << reinterpret_cast<const void*>(this)
<< "\n";;
}
kontener(const kontener& zrodlo)
: T(new int[zrodlo.n]), n(zrodlo.n) // konstruktor kopiujący
{
std::cout << "konstruktor kopiujący z "
<< reinterpret_cast<const void*>(&zrodlo)
<< " do " << reinterpret_cast<const void*>(this) << "\n";
copy(zrodlo.T, zrodlo.T+n, T);
}
kontener(kontener &&zrodlo)
: T(zrodlo.T), n(zrodlo.n) // konstruktor przenoszący
{
std::cout << "konstruktor przenoszacy z "
<< reinterpret_cast<const void*>(&zrodlo)
<< " do " << reinterpret_cast<const void*>(this) << "\n";
zrodlo.T = nullptr;
}
~kontener()
{
std::cout << "usuwanie kontenera (adres: "
<< reinterpret_cast<const void*>(this) << ")\n";
delete[] T;
}
kontener& ustaw(int index, int wartosc)
{
T[index % n] = wartosc;
return *this;
}
void wyswietl()
{
for (int i = 0; i < n; cout << T[i++] << " ");
cout << "\n";
}
};
Kompilując i uruchamiając poniższy kod z nową wersją klasy zobaczymy, że wszystko działa jak należy:
int main(int argc, char** argv)
{
kontener a(10);
a.ustaw(0,10).ustaw(1,11).ustaw(2,40);
a.wyswietl();
kontener b(a); // konstruktor kopiujący
b.wyswietl();
kontener c(move(b)); // konstruktor przenoszący
c.wyswietl();
return 0;
}
Konstruktor przenoszący, do wskaźnika w nowym obiekcie (docelowym) wpisuje
adres z obiektu źródłowego, a w źródłowym wpisuje nullptr. Co daje nam takie
podejście?
- Gdyby w obiekcie źródłowym pozostał adres, destruktory obydwu obiektów próbowałyby zwolnić ten sam obszar pamięci, a to spowodowałoby błąd (double free or corruption)
- Nie musieliśmy kopiować całego obszaru pamięci tak jak w przypadku konstruktora kopiującego, co daje nam dużą oszczędność zasobów procesora
Funkcja std::move
Na koniec napiszę kilka słów o funkcji std::move. Jej definicja wygląda
następująco:
template <class T>
typename remove_reference<T>::type&& move (T&& arg) noexcept;
W skrócie działa ona tak samo jak rzutowanie static_cast<kontener&&>:
kontener a(5);
kontener b(static_cast<kontener&&>(a));
Korzystanie z rzutowania jest jednak niezalecane, ponieważ funkcja std::move jest znacznie szybsza.
Funkcja move przystosowuje obiekt (lub referencję do jakiegoś obiektu) do
bycia przenoszonym, a jej użycie jako argument konstruktora gwarantuje
uruchomienie konstruktora przenoszącego (o ile nie jest on usunięty - wtedy
kompilator zaprotestuje).