Semantyka przenoszenia jest rewolucyjną koncepcją, która pojawiła się w wersji C++11. Umożliwia ona zmianę stylu tworzenia programów, a jednocześnie przyczynia się do ich przyspieszenia.
Z l-wartościami programiści C++ mieli do czynienia "od zawsze". To koncepcja odziedziczona po języku C, oznaczająca określone, stałe miejsce w pamięci, w którym można przechowywać wartość przez długi czas i odwoływać się do niej po nazwie.
Nazwa "l-wartość" wzięła się stąd, że zasadniczo tego typu wyrażenia pojawiają się po lewej stronie operatora przypisania. Oto przykłady l-wartości:
std::string str{ "łańcuch" };
int x;
int y = x + 1;
Zmienna str
jest l-wartością. Również l-wartością są zmienne x
i y
. Jednak nie jest nią już wyrażenie x + 1
znajdujące się po prawej stronie operatora przypisania. Czym więc ono jest?
Jest r-wartością, a więc mówiąc w uproszczeniu, wyrażeniem znajdującym się po prawej stronie operatora przypisania, takim, które nie ma przypisanej nazwy ani nie przebywa w pamięci przez dłuższy czas. Jest ono wyznaczane, a następnie od razu usuwane (czyli jest wyrażeniem tymczasowym). W powyższym przypadku wyrażenie x + 1
zostało obliczone tylko po to, by jego kopię następnie przypisać do zmiennej y
. Po wykonaniu przypisania wyrażenie zostało usunięte z pamięci.
Oprócz l-wartości i r-wartości nowoczesny język C++ rozróżnia gl-wartości (uogólnione l-wartości), x-wartości (wartości "wygasające") oraz pr-wartości ("czyste" r-wartości). Zagadnienie to jest dość zaawansowane, a wiedza o nim nie jest konieczna, by zrozumieć działanie semantyki przenoszenia.
Jak widać, r-wartość została skopiowana do l-wartości. Nie jest to problem w przypadku tak prostym, jak ten, który dotyczy typów podstawowych. Co jednak w przypadku, gdy zmienna jest czymś bardziej skomplikowanym niż zwykły typ int
i zawiera w sobie duże ilości danych? Jeśli takich przypisań pojawia się wiele, mamy do czynienia ze spadkiem wydajności programu. Czy dałoby się coś na to poradzić?
Przypuśćmy, że mamy funkcję, która zwraca jakiś obiekt z dużą ilością danych. Zgodnie z tym, co przed chwilą napisano, w przypadku przypisania tego obiektu do zmiennej nastąpi jego kopiowanie. Oto odpowiedni kod:
BigData createBigData(void)
{
BigData bd;
return bd;
}
// ... gdzieś w programie
BigData bigData { createBigData() };
Cały obiekt bd
zwrócony z funkcji createBigData
zostanie skopiowany do obiektu bigData
.
Sprawdźmy to - utwórzmy klasę BigData
, w której zdefiniujemy konstruktor kopiujący i kopiujący operator przypisania, aby zobaczyć, czy tak jest w rzeczywistości.
class BigData
{
public:
BigData(const int _id) : id{_id}
{
vec = new std::vector<int>;
for (size_t i = 0; i < 1000; i++)
vec->push_back(i);
std::cout << "Konstruktor, id = " << id << std::endl;
}
BigData(const BigData& src)
{
vec = new std::vector<int>;
for (size_t i = 0; i < 1000; i++)
vec->push_back((*src.vec)[i]);
id = src.id;
std::cout << "Konstruktor kopiujący, id = " << id << std::endl;
}
BigData& operator=(const BigData& rhs)
{
if (this != &rhs)
{
delete vec;
vec = new std::vector<int>;
for (size_t i = 0; i < 1000; i++)
vec->push_back((*rhs.vec)[i]);
id = rhs.id;
}
std::cout << "Kopiujący operator przypisania, id = " << id << std::endl;
return *this;
}
~BigData()
{
std::cout << "Destruktor, id = " << id << std::endl;
delete vec;
}
int id;
std::vector<int> *vec;
};
BigData createBigData(const int id)
{
BigData bd(id);
return bd;
}
Żeby było ciekawiej, klasa zawiera zmienną, której przydziela się pamięć dynamiczną na stercie. Z tego powodu w konstruktorze kopiującym ani w przeciążonym kopiującym operatorze przypisania nie można przypisywać zmiennej vec
, ponieważ po zakończeniu operacji dwie zmienne wskazywałyby ten sam adres pamięci. Jeśli któryś z obiektów zostałby usunięty, zmienna vec
z drugiego wskazywałaby pamięć, która została już zwolniona (mielibyśmy do czynienia z tzw. wskaźnikiem zawieszonym - przyczyną poważnych błędów w programach). Pamięć trzeba więc rezerwować, a następnie po kolei kopiować elementy wektora.
Przetestujmy działanie klasy i funkcji:
int main()
{
std::cout << "Początek procesu 1.\n";
std::cout << "Utworzenie obiektu na podstawie obiektu zwróconego z funkcji:\n";
BigData bd1{ createBigData(1) };
std::cout << "Koniec procesu 1.\n";
std::cout << "Początek procesu 2.\n";
BigData bd2{ 2 };
std::cout << "Przypisanie obiektu na podstawie obiektu zwróconego z funkcji:\n";
bd2 = createBigData(3);
std::cout << "Koniec procesu 2.\n";
}
Otrzymaliśmy następujące wyniki:
Początek procesu 1.
Utworzenie obiektu na podstawie obiektu zwróconego z funkcji:
Konstruktor, id = 1
Konstruktor kopiujący, id = 1
Destruktor, id = 1
Koniec procesu 1.
Początek procesu 2.
Konstruktor, id = 2
Przypisanie obiektu na podstawie obiektu zwróconego z funkcji:
Konstruktor, id = 3
Konstruktor kopiujący, id = 3
Destruktor, id = 3
Kopiujący operator przypisania, id = 3
Destruktor, id = 3
Koniec procesu 2.
Przeanalizujmy je po kolei. Najpierw kopiujemy obiekt podczas definiowania innego obiektu - zgodnie z tym powinien zostać wykonany konstruktor kopiujący. Najpierw w wyniku pojawia się wiersz "Konstruktor, id = 1"
- to tworzenie obiektu bd
w funkcji createBigData(1)
. Następnie mamy informację "Konstruktor kopiujący, id = 1"
- to kopiowanie obiektu z funkcji do obiektu bd1
w głównym programie. Wreszcie pojawia się informacja "Destruktor, id = 1"
- to usuwanie niepotrzebnego już obiektu wygenerowanego w funkcji.
Drugi przypadek (przypisanie) jest bardziej skomplikowany. Najpierw tworzymy samodzielną zmienną bd2
- (stąd informacja "Konstruktor, id = 2"
). Następnie ponownie jest wywoływany konstruktor ("Konstruktor, id = 3"
), ponieważ uruchamiamy funkcję createBigData(3)
, która tworzy lokalny obiekt bd
. Konstruktor kopiujący kopiuje ("Konstruktor kopiujący, id = 3"
) ten obiekt do innego, tymczasowego obiektu, który zostanie za chwilę zwrócony i przypisany do zmiennej bd2
. W tym momencie mamy dwa obiekty tymczasowe odpowiadające obiektowi bd
. Jest wywoływany destruktor ("Destruktor, id = 3"
), który usuwa pierwszy obiekt tymczasowy (bd
) wygenerowany w funkcji. Pozostaje drugi obiekt tymczasowy, który zostaje przypisany do zmiennej bd2
("Kopiujący operator przypisania, id = 3"
). Po skopiowaniu jest wywoływany destruktor ("Destruktor, id = 3"
) niszczący ten drugi obiekt tymczasowy.
Jak widać, pojawia się dużo kosztownych operacji kopiowania, chociaż w funkcji createBigData()
obiekt bd
po utworzeniu oraz skopiowaniu do innego jest od razu usuwany. To zwykłe marnotrawstwo czasu procesora. Czy nie można byłoby sprawić, aby pamięć zajmowana przez taki tymczasowy obiekt, który ma i tak zostać usunięty, została nie skopiowana, ale od razu przypisana do jakiejś zmiennej (czyli l-wartości) o dłuższym czasie trwania? W C++ wymyślono odpowiednie rozwiązanie: zdefiniowano nowy konstruktor przenoszący i przenoszący operator przypisania, których parametrami są r-wartości. Jeśli kompilator wykryje próbę kopiowania r-wartości, wywoła zamiast konstruktora kopiującego (czy kopiującego operatora przypisania) konstruktor przenoszący (albo przenoszący operator przypisania).
Kompilator wykona jednak zwykłe kopiowanie, jeśli stwierdzi, że obiekt źródłowy jest l-wartością. Potwierdzimy to za chwilę w działającym kodzie, ale najpierw zdefiniujmy konstruktora przenoszącego i przenoszący operator przypisania:
BigData(BigData&& src) : vec{ src.vec }, id{ src.id }
{
src.vec = nullptr;
std::cout << "Konstruktor przenoszący\n";
}
BigData& operator=(BigData&& rhs)
{
if (this != &rhs)
{
delete vec; // usuń istniejący element
vec = rhs.vec;
id = rhs.id;
}
std::cout << "Przenoszący operator przypisania\n";
return *this;
}
Konstruktor przenoszący wykorzystuje parametr src
, którego typem jest BigData&&
. Co oznaczają te dwa ampersandy &&
? W konstruktorze kopiującym mieliśmy jeden ampersand, który oznaczał referencję do l-wartości. Dwa znaki &&
oznaczają referencję do r-wartości.
Oto przykłady referencji do r-wartości:
int&& iRef = 5; int&& sumRef = iRef + 10;
Pierwsza zmienna jest referencją do r-wartości
5
, a druga referencją do r-wartościiRef + 10
. Pamiętajmy o tym, że utworzenie refencji do r-wartości przedłuża jej czas trwania, który będzie równy czasowi trwania tejże referencji.
Używamy referencji do r-wartości, więc kompilator użyje konstruktora przenoszącego, jeśli napotka wyrażenie będące r-wartością. Przeanalizujmy kod. Zamiast kopiować wszystkie elementy wektora po kolei, kopiujemy jedynie jego adres i zmienną id
. Następnie do wektora oryginalnego przypisujemy nullptr
. Robimy to dlatego, aby destruktor, który zostanie wykonany, nie spowodował pojawienia się błędu. Pamiętajmy, że pierwotny obiekt po przeniesieniu nie może już być używany - dane z niego zostały przeniesione do innego obiektu.
Referencji do r-wartości używamy także jako parametru przenoszącego operatora przypisania. Tu również nie kopiujemy elementów wektora, ale jedynie jego adres (i zmienną id
).
Spróbujmy uruchomić poprzedni kod i zobaczmy, co się zmieniło. Oto otrzymane wyniki:
Początek procesu 1.
Utworzenie obiektu na podstawie obiektu zwróconego z funkcji:
Konstruktor, id = 1
Konstruktor przenoszący, id = 1
Destruktor, id = 1
Koniec procesu 1.
Początek procesu 2.
Konstruktor, id = 2
Przypisanie obiektu na podstawie obiektu zwróconego z funkcji:
Konstruktor, id = 3
Konstruktor przenoszący, id = 3
Destruktor, id = 3
Przenoszący operator przypisania, id = 3
Destruktor, id = 3
Koniec procesu 2.
W przypadku pierwszym wcześniej uruchamiał się konstruktor kopiujący. Jak widzimy, w wyniku pojawia się wiersz "Konstruktor, id = 1"
- to tworzenie obiektu bd
w funkcji createBigData(1)
, więc tutaj nic nie uległo zmianie. Ale następnie mamy informację "Konstruktor przenoszący, id = 1"
- przenieśliśmy obiekt bd
z funkcji do obiektu bd1
w głównym programie. Pojawia się oczywiście informacja "Destruktor, id = 1"
- to usuwanie niepotrzebnego już obiektu wygenerowanego w funkcji (ponieważ destruktor natrafia na nullptr
w przypadku zmiennej vec
, nic się nie dzieje).
A oto drugi przypadek. Tworzymy zmienną bd2
- stąd informacja "Konstruktor, id = 2"
. Następnie ponownie jest wywoływany konstruktor ("Konstruktor, id = 3"
), ponieważ uruchamiamy funkcję createBigData(3)
, która tworzy lokalny obiekt bd
. Tym razem jednak konstruktor przenoszący przenosi ("Konstruktor przenoszący, id = 3"
) ten obiekt do innego, tymczasowego obiektu, który zostanie za chwilę przeniesiony do zmiennej bd2
. Jest wywoływany destruktor ("Destruktor, id = 3"
), który usuwa pierwszy obiekt tymczasowy (bd
) wygenerowany w funkcji (który jest już w stanie po przeniesieniu). Pozostaje drugi obiekt tymczasowy, który zostaje przeniesiony do zmiennej bd2
("Przenoszący operator przypisania, id = 3"
). Po przeniesieniu jest wywoływany destruktor ("Destruktor, id = 3"
) niszczący ten drugi obiekt tymczasowy, który również jest w stanie po przeniesieniu.
Widzimy, że zamiast konstruktorów kopiujących i kopiujących operatorów przypisania zostały użyte konstruktory przenoszące i przenoszące operatory przypisania.
Wróćmy do wcześniejszej uwagi, w której stwierdziliśmy, że kompilator używa przenoszących składników klasy, gdy wykrywa r-wartość. Jeśli wykryje l-wartość, wykona tradycyjne kopiowanie. Oto odpowiedni kod:
std::cout << "Początek procesu 3.\n";
std::cout << "Utworzenie obiektu:\n";
BigData bd4(4);
std::cout << "Koniec procesu 3.\n";
std::cout << "Początek procesu 4.\n";
std::cout << "Utworzenie obiektu na podstawie innego:\n";
BigData bd5{ bd4 };
std::cout << "Koniec procesu 4.\n";
A oto uzyskane wyniki:
Początek procesu 3.
Utworzenie obiektu:
Konstruktor, id = 4
Koniec procesu 3.
Początek procesu 4.
Utworzenie obiektu na podstawie innego:
Konstruktor kopiujący, id = 4
Koniec procesu 4.
Najpierw za pomocą zwykłego konstruktora został utworzony obiekt bd4
("Konstruktor, id = 4"
). Następnie przy użyciu tego obiektu został utworzony nowy obiekt bd5
. Jak widać, przez kompilator został zastosowany konstruktor kopiujący ("Konstruktor kopiujący, id = 4"
), ponieważ bd4
jest l-wartością.
Ktoś mógłby zadać pytanie: czy nie da się nawet w takim przypadku "wymusić" przeniesienia obiektu zamiast skopiowania? Musiałoby to oczywiście mieć sens - użycie obiektu bd4
po przeniesieniu jest po prostu błędem. Załóżmy, że programista nie będzie go już wykorzystywać - w jaki sposób można więc go przenieść? Trzeba zastosować funkcję std::move()
. Tak naprawdę ta funkcja niczego nie przenosi, ale jedynie (upraszczając) zmienia l-wartość na referencję do r-wartości (czyli jest to rzutowanie typu), dzięki czemu kompilator automatycznie wykorzystuje konstruktor przenoszący i/lub przenoszący operator przypisania.
Zmodyfikujmy poprzedni kod i użyjmy funkcji std::move()
:
std::cout << "Początek procesu 3.\n";
std::cout << "Utworzenie obiektu:\n";
BigData bd4(4);
std::cout << "Koniec procesu 3.\n";
std::cout << "Początek procesu 4.\n";
std::cout << "Utworzenie obiektu na podstawie innego:\n";
BigData bd5{ std::move(bd4) };
std::cout << "Koniec procesu 4.\n";
Uzyskaliśmy następujące wyniki:
Początek procesu 3.
Utworzenie obiektu:
Konstruktor, id = 4
Koniec procesu 3.
Początek procesu 4.
Utworzenie obiektu na podstawie innego:
Konstruktor przenoszący, id = 4
Koniec procesu 4.
Początek jest taki sam, jak poprzednio, ale dalej widzimy, że podczas tworzenia obiektu bd5
został użyty konstruktor przenoszący ("Konstruktor przenoszący, id = 4"
), ponieważ dla l-wartości bd4
zastosowaliśmy funkcję std::move()
, a przez to uczyniliśmy ją referencją do r-wartości.
Typy zdefiniowane w bibliotece standardowej szeroko wspierają semantykę przenoszenia, dlatego mogą być używane na przykład jako wartości zwracane z funkcji. Kompilator w takich przypadkach powinien wykorzystać odpowiednie konstruktory i operatory przenoszące.
Sposób zwracania wartości z funkcji wynika również z cechy kompilatora C++17. Jeśli wyrażenie zwracane przez instrukcję return
jest pojedynczą zmienną, kompilator musi je potraktować jako r-wartość. W takim przypadku może też zastosować tzw. optymalizację nazwanej wartości zwracanej (NRVO), co oznacza, że wartość wyrażenia jest umieszczana w pamięci przeznaczonej do przechowywania wartości zwracanej, a przez to unika się kopiowania (wcześniej w artykule poinformowano, że w pewnym momencie podczas działania funkcji createBigData()
pojawiają się dwa obiekty tymczasowe - jeden to obiekt zmiennej, a drugi to obiekt przeznaczony do zwracania wartości z funkcji. Jak widać, optymalizacja NRVO nie zadziałała w tym przypadku).
Programista w przypadku zwracania pojedynczej zmiennej nie powinnien więc z instrukcją return
używać funkcji std::move()
, ponieważ poprzez wymuszenie konstuktora przenoszącego mogłaby ona zakłócić działanie optymalizacji NRVO. Jeśli klasa nie miałaby konstruktora przenoszącego, pojawiłoby się nawet pogorszenie wydajności, ponieważ kompilator użyłby zwykłego konstruktora kopiującego i nie zastosował optymalizacji NRVO! W nowoczesnym języku C++ zwracajmy z funkcji pojedyncze zmienne (lokalne lub jej parametry) przez wartość. A jeśli zwracamy jakieś wyrażenie, a nie pojedyczną zmienną, też nie jest to problemem, ponieważ jest ono z defincji r-wartością.
Funkcję std::move()
warto jednak użyć, jeśli zwracana zmienna jest statyczna. Również należy to robić, gdy zwracamy zmienną będącą polem klasy.