C++11: Referencje do r-wartości i semantyka przenoszenia danych

Programowanie

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:

1 int a = 6;
2 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:

1 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.

1 int &c = 6; // błąd!!!
2 int &c1 = int(); // błąd!!!

Od C++ 11 można mu stworzyć tzw. refererencję do r-wartości. Jest to nazwa dla obiektu, który sam nie ma nazwy i wygląda tak:

1 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 istanieje wyjątek. Od dawna możemy tworzyć stałą referencję do typu, do której przypisujemy wartość tymczasową:

1 const int& e = 5; // OK!

Taka sytuacja została dopuszczona, aby można było w wywołaniu takiej funkcji:

1 void funkcja(const int&);

Podać wartość bezpośrednio - bez tworzenia wcześniej specjalnej zmiennej i stałej referencji do niej:

1 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:

 1 class kontener
 2 {
 3   int *T, n;
 4   public:
 5     kontener(int n_) : T(new int[n_]), n(n_)
 6     {
 7       std::cout << "tworzenie kontenera o poj. "
 8                 << n << " pod adresem: "
 9                 << reinterpret_cast<const void*>(this) << "\n";
10     }
11 
12     ~kontener()
13     {
14       std::cout << "usuwanie kontenera (adres: "
15                 << reinterpret_cast<const void*>(this) << ")\n";
16       delete[] T;
17     }
18 
19     kontener& ustaw(int index, int wartosc)
20     {
21       T[index % n] = wartosc;
22       return *this;
23     }
24 
25     void wyswietl()
26     {
27       for (int i = 0; i < n; cout << T[i++] << " ");
28       cout << "\n";
29     }
30 };

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. W ciele konstruktora widzimy przekazanie na wyjście standardowe komunikatu z informacją o utworzeniu kontenera i jego adresem w pamięci. Destruktor zwalnia zarezerwowaną pamięć i wyświetla stosowny komunikat. Funkcja ustaw służy do wpisania wartości do zarezerwowanej pamięci. Zwraca referencję (zwyczają, znaną od lat) do obiektu kontener na którym operowaliśmy. Dzięki temu możemy dodawać elemety w ten sposób:

1 kontener a(10);
2 a.ustaw(0,10).ustaw(1,15).ustaw(6,40).ustaw(7,50); // itd...

Funkcja wyswietl wyświetla w konsoli zawartość kontenera.

Aby przetestować klasę można skompilować i uruchomić poniższy program (pamiętając o dodaniu przed nim odpowiednich nagłówków i definicji klasy kontener):

1 int main(int argc, char** argv)
2 {
3   kontener a(10);
4   a.ustaw(0,10).ustaw(1,11).ustaw(2,40);
5   a.wyswietl();
6 
7   kontener b(kontener(10));
8   return 0;
9 }

Powyższy program tworzy dwa obiekty klasy kontener. Drugi, o nazwie b, jest tworzony przy pomocy tzw. konstruktora przenoszącego. Przed erą C++11 taki konstruktor nie istniał i to w jaki sposób zachowywał się kompilator przypadku konstruowania obiektu b zależało od jego twórców. Najlepszym rozwiązaniem było najpewniej zamiana tego: kontener b(kontener(10)); w to: kontener b(10);. Ponieważ nie znam się aż tak dobrze na konstrukcji kompilatorów (co przyznaję z ubolewaniem), postanowiłem to sprawdzić poprzez kompui i faktycznie - uruchomienie programu pokazało, że został stworzony tylko obiekt docelowy, czyli kontener b(10);. To rozwiązanie stosowane jest do tej pory jeżeli użytkownik nie zdefiniował swojego konstruktora przenoszącego. Zapewnia to lepszą optymalizację kodu.

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 (wspomniane przed chwilą): konstruktor przenoszący i przenoszący operator przypisania. Ich definicje wyglądają następująco:

1 klasa::klasa(klasa &&);
2 klasa& klasa::operator=(klasa &&);

Widzimy tutaj znane już referencje do r-wartości. Przypomnijmy więc, że referencje do r-wartości służą do nadawania nazw obiektom tymczasowym, które swojej nazwy nie posiadają, więc nie mogą posiadać normalnej referencji. Samo przenoszenie zostało stworzone aby uniknąc bezsensowanego kopiowania dużych ilości danych z jednego obiektu do drugiego. Jakie kroki należy wykonać aby przenieść dane?

  1. skopiować dane z każdej zmiennej składowej jednego obiektu do odpowiedniej zmiennej składowej drugiego obiektu
  2. 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ć - w starym miejscu ma ich teraz nie być!!!

Jeżeli w tej chwili pomyślałeś, że to bez sensu, to w pewnym sensie miałeś rację. Przecież kopiowanie wykonujemy i tak, a dodatkowo musimy jeszcze przywrócić obiekt źródłowy do stanu pierwotnego (tak naprawdę, stanu nieokreślonego). W wielu przypadkach tak rzeczywiście jest. Wyobraźmy sobie jednak, że nasza klasa deklaruje jako składową wskaźnik. Któraś z funkcji składowych (najczęściej konstruktor) dynamicznie alokuje pamięć i jej adres wpisuje do wskaźnika, który jest składową klasy.

Kiedy chcemy skopiować klasę wykorzystując do tego konstruktor kopiujący, musimy pamiętać o tym, że skopiowanie wskaźnika spowoduje jednoczesną pracę dwóch obiektów na jednym obszarze pamięci. Takie działanie, jeśli nie jest zaplanowane, musi zakończyć się katastrofą (najczęściej błąd: double free or corruption). W celu uniknięcia takich sytuacji programiści implementują swoje konstruktory kopiujące, które zachowują się odpowiednio w przypadku pamięi alokowanej dynamicznie - po prostu alokują drugi, taki sam obszar i przepisują dane ze źródła do właśnie zaalokowanej pamięci.

Sytuacja konstruktorów wygląda dokładnie tak samo w przypadku konstruktorów przenoszących. Jednak dzięki temu, że tutaj dane przenosimy, nie musimy kopiować zawartości pamięci zaalokowanej dynamicznie przez obiekt źródłowy. Wystarczy sprytna manipulacja wskaźnikami. Aby zobrazować przykład, dopiszę dwa konstruktory (kopiujący i przenoszący) do naszej klasy kontener:

 1 class kontener
 2 {
 3   public:
 4     int *T, n;
 5 
 6     kontener(int n_) : T(new int[n_]), n(n_)
 7     {
 8       std::cout << "tworzenie kontenera o poj. " << n
 9                 << " pod adresem: " << reinterpret_cast<const void*>(this)
10                 << "\n";;
11     }
12 
13     kontener(const kontener& zrodlo)
14       : T(new int[zrodlo.n]), n(zrodlo.n) // konstruktor kopiujący
15     {
16       std::cout << "konstruktor kopiujący z "
17                 << reinterpret_cast<const void*>(&zrodlo)
18                 << " do " << reinterpret_cast<const void*>(this) << "\n";
19       copy(zrodlo.T, zrodlo.T+n, T);
20     }
21 
22     kontener(kontener &&zrodlo)
23       : T(zrodlo.T), n(zrodlo.n) // konstruktor przenoszący
24     {
25       std::cout << "konstruktor przenoszacy z "
26                 << reinterpret_cast<const void*>(&zrodlo)
27                 << " do " << reinterpret_cast<const void*>(this) << "\n";
28       zrodlo.T = nullptr;
29     }
30 
31     ~kontener()
32     {
33       std::cout << "usuwanie kontenera (adres: "
34                 << reinterpret_cast<const void*>(this) << ")\n";
35       delete[] T;
36     }
37 
38     kontener& ustaw(int index, int wartosc)
39     {
40       T[index % n] = wartosc;
41       return *this;
42     }
43 
44     void wyswietl()
45     {
46       for (int i = 0; i < n; cout << T[i++] << " ");
47       cout << "\n";
48     }
49 };

Pozwoliłem sobie przenieść wskaźnik T i zmienną n do przestrzeni publicznej klasy, aby uprościć kod. Jeżeli komuś zależy na tym, aby składniki te pozostały prywatne, to wersję z kilkoma dodatkowymi (ale niezbędnymi) funkcjami znajdzie na moim github'ie

Kompilując i uruchamiając poniższy kod z nową wersją klasy zobaczymy, że wszystko działa jak należy. Jedną rzeczą, której w tym kodzie jeszcze nie omówiłem, to funkcja move z biblioteki standardowej. Wrócę do niej po omówieniu powyższego przykładu.

 1 int main(int argc, char** argv)
 2 {
 3   kontener a(10);
 4   a.ustaw(0,10).ustaw(1,11).ustaw(2,40);
 5   a.wyswietl();
 6 
 7   kontener b(a); // konstruktor kopiujący
 8   b.wyswietl();
 9 
10   kontener c(move(b)); // konstruktor przenoszący
11   c.wyswietl();
12   return 0;
13 }

Konstruktor przenoszący, do wskaźnika w nowym obiekcie (docelowym) wpisuje adres z obiektu źródłowego, a w źródłowym wpisuje nullptr. nullptr jest wynalazkiem wprowadzonym do C++11, aby zunifikować wartość przypisywaną do wskaźnika, który nie ma pokazywać na jakieś konkretne miejsce - wskaźnika niezainicjalizowanego. (O aspektach technicznych nullptr napiszę w osobym, krótkim tekście). Co daje nam takie podjeście?

  1. Gdyby w obiekcie źródłowym pozostał adres, destruktory obydwu obiektów (źródłowego i docelowego) próbowałyby zwolnić ten sam obszar pamięci, a to spowodowałoby błąd podczas działania programu (wspomniany już błąd: double free or corruption)
  2. Nie musieliśmy kopiować całego obszaru pamięci tak jak w przypadku konstruktora kopiującego (gdzie de facto jest to uzasadnione i konieczne) 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:

1 template <class T>
2 typename remove_reference<T>::type&& move (T&& arg) noexcept;

W skrócie działa ona tak samo jak rzutowanie static_cast<kontener&&>:

1 kontener a(5);
2 kontener b(static_cast<kontener&&>(a));

Korzystanie z rzutowania jest jednak niezalecane, ponieważ funkcja std::move jest znacznie szybsza.

Dokładny opis funkcji move zasługuje na osobny (dość długi) artykuł. Na tę chwilę wystarczy wiedzieć, że 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).