Efektywne zarzadzanie pamięcią jest jednym z głównych problemów podczas tworzenia oprogramowania, które w założeniach ma być szybkie i wydajne. Wyróżnia się tutaj szczególnie problem kopiowania zasobów pomiędzy obiektami. Często zdarza się, że w takiej sytuacji dodatkowo obiekt źródłowy jest obiektem tymczasowym i bezpośrednio po skopiowaniu zawartości jest od razu niszczony.
Już od wersji C++98 pojawiła się w standardzie języka optymalizacja znana pod angielską nazwą „copy elision” pozwalająca kompilatorom na unikanie kopiowania obiektów w pewnych określonych sytuacjach. Przykładowo, gdy zawracamy z funkcji obiekt tymczasowy, kompilator może taki obiekt bezpośrednio stworzyć na stosie w miejscu docelowym. Pomijamy wtedy zupełnie potrzebę wołania konstruktora kopiującego. Podsumowując, mamy:
- return value optimization (RVO) – opisana powyżej,
- named return value optimization (NRVO) – gdy nie mamy do czynienia z obiektem tymczasowym,
- passing by value – gdy przekazujemy do funkcji obiekt tymczasowy przez wartość,
- throw by value – gdy wyjątek jest rzucany przez wartość.
O ile powyższe optymalizacje pozwalają na całkowite pominięcie kopiowania, to jednak obwarowane są całkiem sporymi ograniczeniami. Przykładowo, RVO nie może być użyte w przypadku przypisania do już istniejącego obiektu (assignment operator). Z kolei NRVO nie zadziała, jeśli zwracany obiekt zależy od rezultatu działania samej funkcji. Więcej o samym copy elision znajdziemy tutaj.
Semantyka przenoszenia
O semantyce przenoszenia (move semantics) możemy myśleć, jak o rozszerzeniu i uzupełnieniu optymalizacji wspomnianych we wstępie. Jest to jedna z kluczowych koncepcji wprowadzonych w standardzie języka C++11, która pozwala, w pewnych sytuacjach, na przejęcie zasobów z obiektu źródłowego podczas operacji przenoszenia. Jest to ważne z trzech powodów:
- przenoszenie w większości przypadków będzie szybsze od kopiowania. Należy tutaj wspomnieć, iż przenosić możemy jedynie dane na stercie (dynamicznie zaalokowaną pamięć, uchwyty do plików) dostępne najczęściej poprzez zmienne składowe wskaźnikowe,
- możliwość tworzenia obiektów, których nie da się skopiować, co pozwala na posiadanie zasobów na wyłączność tylko przez jedną instancję w danej chwili (np. std::unique_ptr, std:: unique_lock),
- exception safety – operacja przenoszenia nie wymaga dynamicznej alokacji pamięci, stąd nie ma ryzyka, że może nam jej zabraknąć (co zwykle kończy się wyjątkiem std::bad_alloc).
Referencje do r-wartości
Semantyka przenoszenia nie byłaby możliwa bez rozszerzeń standardu języka C++. Jednym z takich rozszerzeń są referencje do r-wartości. Zapis w kodzie jest podobny do zwykłych referencji (l-wartości), jednak w tym przypadku używamy podwójnego znaku &&:
int && var = 42;
Jest to zupełnie nowy typ referencji (nie jest to referencja do referencji – taka konstrukcja nie istnieje w C++). Wynik każdego wyrażenia r-wartość możemy od teraz przypisać do takiej referencji, co przedłuży czas życia tego obiektu (do tej pory wynik takiego wyrażenia mogliśmy co najwyżej przypisać do const referencji l-wartość). Należy zwrócić uwagę na fakt, iż sama zmienna var to już l-wartość, a tylko wyrażenie 42 (literal) to r-wartość. Podobnie jak w przypadku referencji do l-wartości, referencje do r-wartości muszą zostać zainicjalizowane i nie mogą zmienić odniesienia.
Wartości tymczasowe i wygasające
Kolejną zmianą jest wprowadzenie nowych typów wyrażeń:
- wartości właśnie stworzone, tymczasowe (prvalues – pure r-values) – wszystkie dotychczasowe r-wartości,
- wartości wygasające (xrvalues – expiring value) – skonwertowane l-wartości, które chcemy móc przenosić,
- glvalue (“generalized” lvalue) – l-wartości albo wartości wygasające.
Z tego powodu zmienił się sposób grupowania na poniższy:
Nowy podział wynika z faktu, że jedynie r-wartości mogą być przenoszone. Tylko dla takich wartości może zostać użyta semantyka przenoszenia. Oznacza to, że jeśli chcemy przenieść l-wartość, to najpierw musimy skonwertować ją do wartości wygasającej (xvalue). Ma to szczególnie znaczenie, jeśli posiadamy w kodzie przeładowane funkcje dla l-wartości i r-wartości.
Konwersji dokonujemy przy użyciu szablonowej funkcji std:move ze standardowej biblioteki. Używając std::move, mamy pewność, że kompilator wybierze właściwy wariant przeładowanej funkcji (r-wartość). Pamiętajmy jednak, że sama funkcja std::move nie wykonuje żadnego przenoszenia, a jedynie rzutowanie na referencje do r-wartości (static_cast).
Funkcje specjalne
Operacja przenoszenia ma sens jedynie w odniesieniu do typów złożonych (klasy, struktury, unie). W każdym innym przypadku wykona się zwykła operacja kopiowania. Jeżeli nasze obiekty zarządzają zewnętrznymi zasobami, warto wtedy zaimplementować dwie nowe funkcje specjalne: konstruktor przenoszący oraz przenoszący operator przypisania. Przy czym, jeśli w kodzie mamy zaimplementowaną jedną z tych funkcji, najprawdopodobniej powinniśmy doimplementować i drugą lub (bardziej ogólnie) powinniśmy dodać pozostałe funkcje specjalne zgodnie z regułą pięciu.
Należy również pamiętać, że jeżeli zdefiniujemy przynajmniej jedną z tych funkcji:
- konstruktor kopiujący,
- kopiujący operator przypisania,
- destruktor,
- jedną z przenoszących funkcji specjalnych,
to kompilator nie wygeneruje dla nas nowych funkcji i operacja przenoszenia będzie implementowana poprzez kopiowanie elementu (fallback to copy).
Dodatkowo, jeśli tylko definiujemy funkcje przenoszące, to kompilator nie wygeneruje specjalnych funkcji kopiujących.
Wersje trywialne funkcji przenoszących (czyli wygenerowane przez kompilator) wykonają operacje kopiowania (shallow copy) dla typów prostych, podobnie jak ma to miejsce w przypadku trywialnych funkcji kopiujących. W przypadku typów złożonych zostaną uruchomione odpowiadające im funkcje przenoszące.
Jeżeli wiemy, że wersje trywialne będą dla nas wystarczające, warto zadbać o to, aby kompilator zawsze je dodał, używając słowa kluczowego = default przy deklaracji.
Konstruktor przenoszący
Jednym z przykładów użycia referencji do r-wartości jest konstruktor przenoszący. Jego zadaniem jest przeniesienie zasobów obiektu źródłowego. Standard języka C++ nie mówi wprost, co się powinno stać z obiektem przenoszonym. Ważne jedynie, aby obiekt źródłowy był w stanie umożliwiającym bezpieczne wywołanie destruktora (np. poprzez ustawienie składowych zmiennych wskaźnikowych na nullptr, aby uniknąć podwójnej dealokacji pamięci). Konstruktor kopiujący ma następującą postać (pełny przykład w Załączniku 1.):
buffer(buffer&& rhs) noexcept
: label(std::move(rhs.label)) {
data = rhs.data;
size = rhs.size;
rhs.data = nullptr;
rhs.size = 0;
}
Parametrem jest referencja do r-wartości obiektu źródłowego, który chcemy przenieść.
Jeśli nie ma ryzyka, że podczas operacji przenoszenia może zostać rzucony wyjątek, oznaczmy nasz konstruktor dodatkowo jako noexcept. Pamiętajmy, że podczas przenoszenia modyfikowany jest obiekt źródłowy, a rzucenie wyjątku w trakcie tej operacji może spowodować niespójność wewnętrznych zmiennych i w rezultacie utratę danych.
Ma to znaczenie, jeśli chcemy używać naszej klasy z kontenerami STL biblioteki standardowej, które gwarantują spójność danych (strong exception safety guarantee). Jeśli konstruktor przenoszący nie będzie oznaczony jako noexcept, standardowe kontenery zawsze będą wykonywać operację kopiowania.
Przenoszący operator przypisania
Zadaniem przenoszącego operatora przypisania jest zwolnienie zasobów w obiekcie docelowym i pozyskanie zasobów z obiektu źródłowego. Podobnie jak poprzednio, powinniśmy pozostawić obiekt źródłowy w stanie umożliwiającym bezpieczne wywołanie destruktora. Najczęściej przenoszący operator przypisania przybiera następującą postać (pełny przykład w Załączniku 1.):
buffer& operator=(buffer&& rhs) noexcept {
if (this != &rhs) { // tylko jeśli przenosimy z innego obiektu
label = std::move(rhs.label);
delete [] data;
data = rhs.data;
size = rhs.size;
// usunięcie zasobów z obiektu źródłowego pozwoli później na bezpieczne zniszczenie obiektu źródłowego
rhs.data = nullptr;
rhs.size = 0;
}
return *this;
}
Parametrem jest referencja do r-wartości obiektu źródłowego, który chcemy przenieść.
Pamiętajmy, aby oznaczyć nasz przenoszący operator przypisania jako noexcept z tego samego powodu, jak przy konstruktorze przenoszącym.
Ciekawą opcją jest zastosowanie idiomu copy-and-swap. Zaletami takiego rozwiązania są:
- tylko jeden operator przypisania dla operacji kopiowania i przenoszenia (zunifikowany),
- nieduplikowanie kodu (Załącznik 2.).
Zwracanie przez wartość
Semantyka przenoszenia wprowadza też nową optymalizację: automatyczne przenoszenie zmiennych lokalnych i parametrów. W sytuacji, gdy nie jest możliwe uniknięcie kopiowania (copy elision), kompilator spróbuje taką zmienną przenieść. Ze wskazaną sytuacją będziemy mieli do czynienia w przedstawionym poniżej przykładzie metody wytwórczej (fragment pochodzi z Załącznika 1.):
buffer make_buffer(std::size_t size) {
auto buf = buffer(size);
return buf;
}
Jeśli wynik działania takiej funkcji przypiszemy bezpośrednio do już istniejącej zmiennej (b1), nie będzie możliwe użycie optymalizacji NRVO (fragment pochodzi z Załącznika 1.):
b1 = make_buffer(size);
Zamiast tego, kompilator spróbuje przenieść zasoby zmiennej buf (wykona się przenoszący operator przypisania z klasy buffer). Czyli pomimo faktu, że buf jest l-wartością, to czas życia takiej zmiennej jest znany i bezpieczne jest potraktowanie jej jako wartość wygasająca (xvalue).
Błędem byłoby natomiast stworzenie funkcji jawnie zwracającej referencje do r-wartości:
buffer&& make_buffer(std::size_t size) {
buffer b = buffer(size);
return std::move(b);
}
W takiej sytuacji czas życia zmiennej b nie zostanie wydłużony. Ponadto, nie wykona się też NRVO ani operacja przeniesienia. Otrzymamy jedynie referencje do zniszczonego obiektu (dangling reference).
Przekazywanie referencji do r-wartości
Popatrzmy jeszcze raz na konstruktor przenoszący z załącznika 1. Zawiera zmienną składową label – również implementującą semantykę przenoszenia (std::string). Czyli w tym przypadku buffer nie trzyma bezpośrednio uchwytu do zasobu, bo tzw. „goły wskaźnik” jest tutaj opakowany przez klasę szablonową std::string.
Bardzo ważne jest w takiej sytuacji jasne pokazanie kompilatorowi, że w przypadku zawołania konstruktora przenoszącego, chcemy, aby odpowiednie konstruktory przenoszące zostały zawołane również na zmiennych składowych (pełny przykład w Załączniku 1.):
buffer(buffer&& rhs) noexcept
: label(std::move(rhs.label)) { // wywołanie konstruktora przenoszącego std::string
data = rhs.data;
size = rhs.size;
rhs.data = nullptr;
rhs.size = 0;
}
Parametr rhs jest l-wartością (pamiętajmy, że zmienna typu referencja do r-wartości jest l-wartością), więc także jego zmienne składowe będą l-wartościami. Rzutowanie do xvalue przy pomocy std::move rozwiązuje problem. Gdybyśmy tego nie zrobili, kompilator wybrałby konstruktor kopiujący, co w tym przypadku zniweczyłoby cały zysk wynikający z przenoszenia.
Podobnie wygląda sytuacja w przypadku każdej innej nieszablonowej funkcji. Wszystkie parametry funkcji to l-wartości i musimy wskazać (np. poprzez rzutowanie std::move), które przeładowanie chcemy wywołać.
Na poniższym przykładzie przeładowane funkcje pass_buffer będą siebie wywoływać nawzajem, aż do przepełnienia stosu:
void pass_buffer(const buffer& b) {
pass_buffer (std::move(b));
}
void pass_buffer(buffer&& b) {
pass_buffer (b);
}
Oczywiście, kolejnym problem będzie tu duplikacja kodu, bo zwykłe funkcje przeładowane mają bardzo podobną implementację. W takich sytuacjach z pomocą przychodzą funkcje szablonowe oraz uniwersalne referencje (forwarding reference). Zobaczmy przykład z Załącznika 1.:
template<class... Args>
buffer make_buffer_generic(Args&&... args) {
return buffer{std::forward<Args>(args)...};
}
Szablonowa funkcja make_buffer_generic ma uniwersalną implementację, niezależnie od tego, czy argumenty args są przekazywane jako l-wartość, czy też r-wartość (a także const i volatile). Powyższa implementacja pozwala kompilatorowi na wybranie poprawnego konstruktora (domyślnego, przenoszącego lub kopiującego). Tym razem do przekazania parametrów używamy funkcji std::forward ze standardowej biblioteki.
Przy dedukcji typu ma też zastosowanie nowa reguła – zwijanie referencji (reference collapsing). Definiuje ona postępowanie z typami szablonowymi, które tworzyłyby referencje do referencji. W skrócie, typ buffer&& && (rvalue reference to rvalue reference) zostanie uproszczony do typu buffer&&. Pozostałe kombinacje typów zostaną uproszczone do typu buffer&.
Wsparcie w bibliotece standardowej
Przykładem grupy obiektów implementujących semantykę przenoszenia w bibliotece standardowej są inteligentne wskaźniki (smart pointers). Co ciekawe, pierwszą implementacją inteligentnego wskaźnika była klasa szablonowa std::auto_ptr, która pojawiła się w standardowej bibliotece, jeszcze zanim semantyka przenoszenia weszła do standardu języka C++.
Przenoszenie obiektów imitowane było poprzez funkcje odpowiedzialne za kopiowanie, więc – jak możemy się domyślać – wykonywała swoje zadanie raczej nieudolnie. Wraz z pojawieniem się pełnoprawnych mechanizmów przenoszenia, klasa ta została całkowicie zastąpiona przez std::unique_ptr.
Implementacja klasy szablonowej std::unique_ptr jest na tyle lekka, że można całkowicie zapomnieć o przechowywaniu i przekazywaniu tzw. „gołych wskaźników”, a w połączeniu z funkcją std::make_unique możemy nawet wyeliminować bezpośrednie użycie operatorów new i delete.
Wsparcie biblioteki standardowej jest oczywiście o wiele szersze. Przykładowo, funkcje dodające elementy do kontenera posiadają teraz przeładowane wersje przenoszące (np. std::vector::push_back). Do dyspozycji mamy również funkcje pomocnicze std::move i std::forward biorące na siebie ciężar wykonania odpowiedniego rzutowania.
Oddzielną grupa są obiekty synchronizujące dostęp do zasobów (np. std:: unique_lock), jak i obiekty reprezentujące zasoby (np. std::thread). W obu przypadkach nie chcemy, aby dwa różne obiekty wskazywały na ten sam zasób, więc kopiowanie jest całkowicie wyłączone. Dozwolone jest jedynie przenoszenie zasobów pomiędzy obiektami (np. z użyciem standardowej funkcji std::swap).
Podsumowanie
Pełne zrozumienie mechanizmów przenoszenia oraz świadomość dostępnych optymalizacji zapobiegających kopiowaniu (copy elision), jak również wykorzystanie gotowych wzorców z biblioteki standardowej pozwala na wdrażanie rozwiązań całkowicie eliminujących operowanie na typach wskaźnikowych. Mamy także gwarancję, że takie zasoby przenoszone są pomiędzy obiektami w sposób bezpieczny oraz zwalniane automatyczne w momencie, gdy niszczony jest obiekt zarządzający.
Semantyka przenoszenia nie należy do prostych mechanizmów i jej pełne zrozumienie wymaga sporo czasu. Wydawać by się mogło, że teraz każdy obiekt powinien mieć własne implementacje specjalnych funkcji przenoszących. Jednak tworzenie obiektów z własną implementacją konstruktora przenoszącego oraz przenoszącego operatora przypisania powinno być ostatecznością!
W pierwszej kolejności powinniśmy się starać wykorzystać gotowe rozwiązania z biblioteki standardowej (np. std::unique_ptr) oraz zadbać, aby trywialne wersje funkcji przenoszących były tworzone przez kompilator (wymuszenie poprzez = default). W naszym prostym przykładzie klasy buffer „opakowanie” zmiennej składowej data w obiekt smart pointera w zupełności wystarczyłoby, aby zastąpić implementacje funkcji specjalnych ich wersjami trywialnymi.
Podsumowując, wprowadzenie semantyki przenoszenia do standardu języka C++ jest dużym krokiem w stronę urzeczywistnienia sytuacji, w której pojęcia takie jak: wycieki pamięci (memory leaks), podwójna dealokacja (double deallocation) czy dangling pointers mogłyby w końcu przejść do historii 😊
Załączniki
***
Jeżeli interesuje Cię język C lub C++, zachęcamy do sprawdzenia innych artykułów naszych ekspertów m.in.:
Zostaw komentarz