C++11 #12: Jak działa semantyka przeniesienia ?

|

Referencje do r-wartości.

Semantyka przeniesienia to jedna z kluczowych koncepcji nowoczesnego języka C++, która zadebiutowała w C++11. Sam język potrzebował jednak kilku nowych funkcjonalności, które umożliwiłyby wprowadzenie tej idei w życie.

Podstawą stały się referencje do r-wartości (ang. r-value references).

Referencja do r-wartości jest w gruncie rzeczy bardzo podobna do tradycyjnych referencji, które tutaj należałoby nazwać referencjami do l-wartości.

Zacznijmy jednak od początku…

Intuicyjnie wiemy, jaka jest różnica pomiędzy l-wartością a r-wartością:

  • l-wartość to obiekt posiadający zdefiniowaną nazwę, który można umieścić po lewej stronie operatora przypisania (ale też po prawej) i pobrać jego adres operatorem &
  • r-wartość to obiekt nie posiadający zdefiniowanej nazwy – obiekt tymczasowy, który może być umieszczony tylko i wyłącznie po prawej stronie operatora przypisania (nie można pobrać jego adresu operatorem &)

Czyli:

int x = 4; // x jest l-wartością, 4 jest r-wartością

MyClass mc = MyClass(); // mc jest l-wartością, MyClass() jest r-wartością

Tworzenie referencji do obu wartości wygląda tak:

int x;
int& ref1 = x; // ref1 jest referencją do l-wartości
int&& ref2 = 7; // ref2 jest referencją do r-wartości

Referencję do r-wartości definiuje się za pomocą kombinacji && (podwójny ampersand).

Jaka jest zatem różnica pomiędzy jedną a drugą referencją ?

Referencji do r-wartości, jak sama nazwa wskazuje, możemy przypisać obiekt tymczasowy (r-wartość):

MyClass&& ref1 = MyClass(); // OK
MyClass& ref2 = MyClass(); // Błąd !

Co nam to daje ? To, co daje nam zwykła referencja w przypadku l-wartości: możemy modyfikować oryginalny obiekt – w tym przypadku r-wartość. To kluczowa uwaga w kontekście semantyki przeniesienia.

Semantyka przeniesienia.

Kopiowanie obiektów może być bardzo kosztowne. Jeśli sytuacja dotyczy pojedynczych obiektów typów podstawowych, to nie ma tragedii. Jeśli jednak mamy zdefiniowany wektor, zawierający kilkaset albo kilka tysięcy elementów, zaczynamy zauważać różnicę.

Często, np. podczas przypisywania takich obiektów, nie chcemy wykonywać żadnych kopii. W C++03 można uniknąć lub ograniczyć ilość tworzonych tymczasowo kopii tylko w niektórych przypadkach. Większość takich sytuacji wynika jasno z definicji języka, a więc jest to problem strukturalny.

C++11 jest pod tym względem rewolucyjny. Semantyka przeniesienia odcisnęła swoje piętno nie tylko na sposobie pisania nowego kodu, ale również w ogromnym stopniu zmieniła bibliotekę standardową języka. Wszystko po to, aby skutecznie wyeliminować sytuacje, w których zachodzi zupełnie niepotrzebne kopiowanie danych.

Semantyka przeniesienia daje kompilatorowi możliwość zastąpienia kosztownych operacji kopiowania czymś, co jest (zazwyczaj) o wiele mniej kosztowne – tzw. operacjami przeniesienia. Jeśli kompilator wie, że kopiowany obiekt źródłowy nie będzie już używany (nie jest potrzebny), może po prostu zrezygnować z kopiowania (czyli tworzenia nowego obiektu, przenoszenia do niego danych i ewentualnego niszczenia niepotrzebnego obiektu źródłowego). Zamiast tego może uznać obecny obiekt źródłowy (de facto kawałek pamięci) za obiekt docelowy.

Co ważniejsze – programista może teraz jawnie poinformować kompilator o tym, że obiekt źródłowy może być w ten sposób użyty. Możemy powiedzieć: oto obiekt, który możesz wykorzystać i zmodyfikować – skorzystaj z tego w celu optymalizacji istniejących instrukcji.

Tak dochodzimy do prostego przykładu – klasycznej funkcji swap, zamieniającej dwa obiekty miejscami:

template<class T> 
swap(T& x, T& y)
{
    T temp(x);	// istnieją dwie kopie obiektu x
    x = y;	// istnieją dwie kopie obiektu y
    y = temp;	// istnieją dwie kopie obiektu temp
}

Jak zoptymalizować ten kod ? Skorzystać z możliwości, jakie daje nam C++11:

template<class T> 
swap(T& x, T& y)
{
    T temp = std::move(x);
    x = std::move(y);
    y = std::move(temp);
}

W tym przypadku, najprawdopodobniej, nie nastąpi kopiowanie podczas przetwarzania kolejnych linii kodu. Zamiast tego kompilator skorzysta z naszej sugestii – wyrażonej poprzez opakowanie obiektów w szablon funkcji std::move – i „uzna” obiekty źródłowe za obiekty docelowe.

Dlaczego „najprawdopodobniej” i co to jest std::move ? O tym poniżej.

std::move.

std::move jest szablonem funkcji, której w nowoczesnym języku C++ używa się bardzo często. Jest sposobem na to, żeby powiedzieć kompilatorowi:

Traktuj argument funkcji std::move jak r-wartość.

Wewnętrznie jest to po prostu bezwarunkowe rzutowanie podanego argumentu na referencję do r-wartości i zwrócenie jej jako wyniku.

Dla ciekawskich: std::move rezyduje w nagłówku <utility> biblioteki standardowej, a deklaracja tego szablonu wygląda następująco:

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

Konstruktor przenoszący oraz przenoszący operator przypisania.

Jest jeszcze jeden warunek, konieczny do tego, aby pokazany powyżej przykład funkcji swap działał wg mojego opisu – klasa T musi posiadać zdefiniowany konstruktor przenoszący oraz przenoszący operator przypisania (zdefiniowane samodzielnie lub wygenerowane automatycznie przez kompilator).

Ich deklaracje wyglądają następująco:

MyClass(MyClass&& object);             // konstruktor przenoszący klasy MyClass
MyClass& operator=(MyClass&& object);  // przenoszący operator przypisania klasy MyClass

Zauważyliście, co jest typem przyjmowanego parametru ? Referencja do r-wartości. To dlatego widzieliście wcześniej w kodzie std::move.

Dzięki samodzielnie zdefiniowanemu konstruktorowi przenoszącemu oraz przenoszącemu operatorowi przypisania, mamy możliwość wpływania na to, jak działa semantyka przeniesienia w przypadku naszych klas. Tak samo, jak za pomocą operatora przypisania i konstruktora kopiującego kontrolujemy zwykłe kopiowanie obiektów.

Tak więc, jeśli chcemy, aby obiekty naszej klasy mogły być „przenoszone”, musimy zaimplementować obie funkcje specjalne, przyjmujące jako parametr referencje do r-wartości.

Co ważne, biblioteka standardowa czerpie z semantyki przeniesienia pełnymi garściami. Dobrym przykładem są klasy kontenerowe – wszystkie mają zdefiniowane konstruktory przenoszące, przenoszące operatory przypisania oraz (w większości) specjalne wersje funkcji wstawiających elementy. Dzięki temu nie tylko wzrosła wydajność wielu operacji, wykonywanych bezpośrednio na tych klasach, ale również ogólna wydajność algorytmów zaimplementowanych w bibliotece standardowej.

Wielka piątka.

Jest jeszcze jedna rzecz, o której warto w tym miejscu wspomnieć.

Specyfikacja języka C++ mówi o tym, w jakich sytuacjach kompilator może automatycznie generować tzw. specjalne funkcje składowe klas. Do tej grupy należą:

  1. Destruktor.
  2. Konstruktor kopiujący.
  3. Kopiujący operator przypisania.
  4. Konstruktor przenoszący.
  5. Przenoszący operator przypisania.

Celowo pomijam domyślny konstruktor, który nie jest związany z prowadzonymi tutaj rozważaniami.

Dwa ostatnie elementy (konstruktor przenoszący, przenoszący operator przypisania) trafiły do języka w wersji C++11. Wcześniej sytuacja była dość prosta – kompilator generował automatycznie te elementy, które nie zostały jawnie zdefiniowane przez programistę. Jeśli np. został zdefiniowany tylko własny destruktor, pozostałe dwa punkty z listy były generowane w domyślnej wersji przez kompilator.

W tej chwili (>= C++11) sytuacja jest nieco inna. Wygląda to tak:

  • jeśli nie zdefiniujemy żadnej z wymienionych funkcji składowych, wszystkie zostaną wygenerowane przez kompilator
  • jeśli zdefiniujemy dowolny z 3 pierwszych elementów, kompilator nie wygeneruje konstruktora przenoszącego i przenoszącego operatora przypisania
  • jeśli zdefiniujemy konstruktor przenoszący lub przenoszący operator przypisania, żaden z pozostałych elementów nie zostanie automatycznie wygenerowany

Ma to swoje konsekwencje podczas codziennej pracy. Jeśli nie chcemy o tym wszystkim pamiętać – co jest dość dobrym pomysłem – należy zawsze jawnie deklarować chęć wygenerowania domyślnej konstrukcji (słowo kluczowe default w deklaracji funkcji), jej usunięcia (słowo kluczowe delete w deklaracji funkcji), albo zdefiniować funkcję samodzielnie.

5 komentarzy do “C++11 #12: Jak działa semantyka przeniesienia ?”

Dodaj komentarz