Poczynając od standardu C++11, istnieje druga forma referencji, oznaczana dwoma, a nie jednym znakiem '&'. Takie referencje nazywane są r-referencjami. Takie referencje mogą być związane („być inną nazwą”) tylko z r-wartościami, czyli obiektami tymczasowymi, bez dostępnego użytkownikowi adresu. L-wartości też mają oczywiście wartość, ale prócz tego mają dobrze określoną lokalizację w pamięci; r-wartości natomiast to „tylko wartości”.
Rozważmy następujące instrukcje
1. int i = 2;
2. int &r1 = i; // 'normalna' referencja do l-wartości
3. int &&r2 = i; // Źle - prawa strona to l-wartość
4. int &r3 = i + 2; // Źle - prawa strona to r-wartość
5. const int &r4 = i + 2; // OK - const-referencja do r-wartości
6. int &&r5 = i + 2; // OK
Linia 2 jest prawidłowa, bo zmienna
i
jest
l-wartością a
r1
jest „normalną” l-referencją.
Jednakże linia 3 jest nielegalna, bo zmienna
i
jest l-wartością – w żadnym przypadku nie jest zmienną
tymczasową! – i nie można jej związać z r-referencją.
Linia 4 jest też nieprawidłowa, bo wyrażenie
i+2 nie jest l-wartością, więc nie można
go związać ze zwykłą referencją. Można jednak, jak wiemy,
związać obiekt tymczasowy z referencją do stałej, jak
w linii 5. W końcu ostatnia linia jest prawidłowa, bo
r-referencję związujemy z obiektem tymczasowym.
Pracując z l- i r-wartościami warto pamiętać jakie funkcje (operatory) zwracają l-wartości, a jakie r-wartości. L-wartości są zwracane przez operator przypisania, indeksowania, dereferencji, prefiksowej inkrementacji i dekrementacji (ale nie postfiksowej). Wyniki takich operacji mogą być związane z normalną, l-referencją. Z drugiej strony, r-wartości są zwracane przez operatory arytmetyczne, porównania, bitowe, postfiksową inkrementację/dekrementację – zwracane wartości mogą być związane z r-referencją.
R-referencje są przede wszystkim używane jako parametry funkcji. Gdy taki parametr jest zadeklarowany jako r-referencja, w wywołaniu, jako odpowiadający temu parametrowi argument, musi pojawić się r-wartość, czyli obiekt tymczasowy. Wiemy zatem, że za chwilę on „zniknie” i nikt nie będzie miał do niego dostępu. Jest więc bezpiecznie przejąć („ukraść”) jego zasoby bez konieczności ich kopiowania. Typowym przykładem może być pole wskaźnikowe wskazujące, na przykład, na tablicę, która logicznie należy do obiektu, ale fizycznie jest poza nim, gdzieś na stercie. Normalnie w takich sytuacjach musieliśmy definiować konstruktor kopiujący, operator przypisania oraz destruktor, żeby kopiować nie wskaźniki, ale tę tablicę, alokując oczywiście za każdym razem pamięć na nią (i zwalniając ją w destruktorze). Jeśli jednak wiemy, że „oryginał” (w konstruktorze kopiującym czy operatorze przypisania) nigdy już nie będzie dla nikogo dostępny, możemy przekopiować tylko wskaźnik — musimy tylko zadbać, aby obiekt tymczasowy pozostawić w stanie pozwalającym na jego usunięcie bez żadnych złych skutków. Mówimy wtedy, że nasza klasa wspiera semantykę przenoszenia.
Może się zdarzyć, że mamy l-wartość, którą, na przykład, chcemy przekazać jako argument do funkcji; wiemy jednak, że tej zmiennej już nie będziemy więcej potrzebować. W takich sytuacjach możemy „poprosić” kompilator, żeby potraktował naszą l-wartość jak r-wartość i pozwolił na jej przeniesienie i pozostawienie w stanie co prawda nieokreślonym, ale legalnym i usuwalnym. Może to uczynić kopiowanie zasobów zbędnym i prowadzić do bardziej efektywnego kodu. Taką konwersję l-wartości do r-wartości wykonuje funkcja std::move (z nagłówka utility).
Jak więc zapewnić, aby nasza klasa wspierała semantykę
przenoszenia?
Musimy zdefiniować konstruktor przenoszący
z parametrem zadeklarowanym jako
r-referencja. Oczywiście nie do stałej, bo przecież
właśnie zamierzamy zmodyfikować otrzymany obiekt tymczasowy
— chcemy „zawłaszczyć” jego zasoby (poprzez kopiowanie
wskaźników) i pozbawić go kontroli nad zawłaszczonymi zasobami
(poprzez „wyzerowanie” wskaźników).
W analogiczny sposób przeciążamy przenoszący operator
przypisania.
W poniższym przykładzie definiujemy klasę
Arr
która
jest „opakowaniem” zwykłej tablicy liczb całkowitych.
Jedynymi polami tej klasy są wymiar tablicy i wskaźnik na
nią — sama tablica jest tu „zasobem”, który logicznie
należy do obiektu, ale fizycznie jest alokowany gdzieś na stercie.
W linii ➊ definiujemy „normalny” konstruktor, a w linii
➋ konstruktor kopiujący. Pobiera on obiekt tego samego typu,
którego nie może oczywiście zmienić — aby zapewnić, by nowo
tworzony obiekt i obiekt przesłany do konstruktora zachowały
niezależność, musimy zaalokować tablicę i przekopiować elementy
z tablicy należącej do przesłanego obiektu do tablicy należącej
do tego obiektu
1. #include <cstring>
2. #include <iostream>
3. #include <utility> // move
4.
5. using std::cout; using std::endl;
6.
7. class Arr {
8. size_t size;
9. int* arr;
10. public:
11. Arr(size_t s, const int* a) ➊
12. : size(s),
13. arr(static_cast<int*>(
14. std::memcpy(new int[size],a,
15. size*sizeof(int))))
16. {
17. cout << "ctor from array\n";
18. }
19. Arr(const Arr& other) ➋
20. : size(other.size),
21. arr(static_cast<int*>(
22. std::memcpy(new int[size],other.arr,
23. size*sizeof(int))))
24. {
25. cout << "copy-ctor\n";
26. }
27. Arr(Arr&& other) noexcept ➌
28. : size(other.size), arr(other.arr)
29. {
30. other.size = 0;
31. other.arr = nullptr;
32. cout << "move-ctor\n";
33. }
34. Arr& operator=(const Arr& other) { ➍
35. if (this == &other) return *this;
36. int* a = new int[other.size];
37. memcpy(a,other.arr,other.size*sizeof(int));
38. delete [] arr;
39. size = other.size;
40. arr = a;
41. cout << "copy-assign\n";
42. return *this;
43. }
44. Arr& operator=(Arr&& other) noexcept { ➎
45. if (this == &other) return *this;
46. delete [] arr;
47. size = other.size;
48. arr = other.arr;
49. other.size = 0;
50. other.arr = nullptr;
51. cout << "move-assign\n";
52. return *this;
53. }
54. ~Arr() {
55. delete [] arr;
56. }
57. friend std::ostream& operator<<(std::ostream& str,
58. const Arr& a) {
59. if (a.size == 0) return cout << "Empty";
60. str << "[ ";
61. for (size_t i = 0; i < a.size; ++i)
62. str << a.arr[i] << " ";
63. return str << "]";
64. }
65. };
66.
67. Arr replicate(Arr a) { ➏
68. cout << "In replicate\n";
69. return a;
70. }
71.
72. int main() {
73. cout << "**** 0 ****\n";
74. int a[]{1,2,3,4};
75. Arr arr(std::size(a),a);
76. cout << "arr : " << arr << endl;
77.
78. cout << "**** 1 ****\n";
79. Arr arr1 = replicate(arr);
80. cout << "arr1: " << arr1 << endl;
81. cout << "arr : " << arr << endl;
82.
83. cout << "\n**** 2 ****\n";
84. arr = arr1;
85. cout << "arr : " << arr << endl;
86. cout << "arr1: " << arr1 << endl;
87.
88. cout << "\n**** 3 ****\n";
89. Arr arr2 = replicate(std::move(arr));
90. cout << "arr2: " << arr2 << endl;
91. cout << "arr : " << arr << endl;
92.
93. cout << "\n**** 4 ****\n";
94. arr = replicate(std::move(arr2));
95. cout << "arr : " << arr << endl;
96. cout << "arr2: " << arr2 << endl;
97.
98. cout << "\n**** 5 ****\n";
99. arr2 = std::move(arr);
100. cout << "arr2: " << arr2 << endl;
101. cout << "arr : " << arr << endl;
102. }
W linii ➌ definiujemy konstruktor przenoszący, który będzie
użyty jeśli obiekt
other
jest tymczasowy.
Konstruktor po prostu kopiuje oba pola, w tym wskaźnik.
Teraz wskaźnik w tym obiekcie wskazuje na dokładnie tę samą
tablicę, co wskaźnik w obiekcie
other. Nie ma żadnej
alokacji pamięci, żadnego kopiowania elementów tablicy.
Musimy tylko zadbać, aby destruktor obiektu
other
nie zwolnił tablicy, którą właśnie „zawłaszczyliśmy”!
W tym celu „zerujemy” wskaźnik w obiekcie
other,
tak, żeby
delete
w destruktorze niczego nie usuwało.
W podobny sposób implementujemy (➎) operator przenoszącego
przypisania.
Zauważmy, że obie funkcje przenoszące (Konstruktor i operator przypisania) są zadeklarowane jako noexcept: w ten sposób „obiecujemy”, że nie zgłoszą żadnych wyjątków — rzeczywiście, nie powinny, bo tylko kopiują zmienne typów prostych. To jest istotne, bo wiele funkcji z Biblioteki Standardowej nie skorzysta z możliwości przenoszenia (tracąc wynikające stąd korzyści) jeśli odpowiednie funkcje nie zapewniają, że nie zgłoszą wyjątków.
Przeanalizujmy więc działanie programu.
*** 0 ***: tworzymy obiekt typu
Arr
używając
pierwszego konstruktora i wypisujemy go — dostajemy wydruk
**** 0 ****
ctor from array
arr : [ 1 2 3 4 ]
*** 1 ***: teraz wołamy fukcję
replicate
przekazując
arr
przez wartość. Ponieważ
arr
jest l-wartością, do wykonania kopii, która ma zostać położona
stosie wykorzystany będzie zwykły konstruktor kopiujący.
Funkcja zwraca jednak przez wartość, a więc obiekt tymczasowy,
który będzie użyty do zainicjowania obiektu
arr1
—
wywołany zatem zostanie konstruktor przenoszący
**** 1 ****
copy-ctor
In replicate
move-ctor
arr1: [ 1 2 3 4 ]
arr : [ 1 2 3 4 ]
*** 2 ***: w przypisaniu
arr=arr1 obiekt
po prawej jest l-wartością, zatem użyte będzie normalne
przypisanie kopiujące
**** 2 ****
copy-assign
arr : [ 1 2 3 4 ]
arr1: [ 1 2 3 4 ]
*** 3 ***: teraz przekazujemy do funkcji
replicate
obiekt
arr, ale jako r-wartość (skonwertowaną
przez funkcję
std::move). Zatem konstruktor
przenoszący zostanie zastosowany do wykonania kopii, która będzie
położona na stosie. Zwrócony przez funkcję obiekt tymczasowy
użyty jest do zainicjowania obiektu
arr2
—
znów więc zastosowany będzie konstruktor przenoszący. Zauważmy,
że obiekt
arr
został „wyzerowany”!
**** 3 ****
move-ctor
In replicate
move-ctor
arr2: [ 1 2 3 4 ]
arr : Empty
*** 4 ***: teraz przekazujemy
arr2
jako r-wartość,
a tymczasowy obiekt zwracany przypisujemy do istniejącego
obiektu
arr. Tak więc konstruktor przenoszący będzie
użyty do utworzenia obiektu tymczasowego i przenoszący operator
przypisania do przypisania na
arr; zmienna
arr2
będzie wyzerowana
**** 4 ****
move-ctor
In replicate
move-ctor
move-assign
arr : [ 1 2 3 4 ]
arr2: Empty
*** 5 ***: tu przypisujemy
arr
zrzutowane na
r-wartość (przez
move) na
arr2;
przenoszące przypisanie będzie użyte i
arr
będzie
wyzerowane
**** 5 ****
move-assign
arr2: [ 1 2 3 4 ]
arr : Empty
Jak pamiętamy, konstruktor kopiujący i operator kopiującego przypisania są tworzone automatycznie przez kompilator (jeśli nie są delete d). Sytuacja z odpowiednimi funkcjami przenoszącymi jest inna: jeśli klasa definiuje konstruktor kopiujący i/lub kopiujący operator przypisania i/lub destruktor, to konstruktor i przypisanie przenoszące nie będą utworzone, a wtedy kiedy są potrzebne, będzie użyta odpowiednia funkcja kopiująca.
Jeśli klasa nie definiuje swoich składowych kopiujących – konstruktor kopiujący, operator kopiującego przypisania, destruktor — kompilator utworzy składowe przenoszące jeśli tylko wszystkie pola mogą być przeniesione: pola typów prostych mogą (bo przenoszenie jest równoważne kopiowaniu) a pola typów obiektowych nie zawsze (na szczęście string i mogą).
Jeśli jednak klasa definiuje konstruktor przenoszący i/lub przenoszący operator przypisania, to „zwykłe", kopiujące wersje tych operacji będą oznaczone jako delete d — sami je musimy zdefiniować, jeśli są potrzebne.
Ogólnie, gdy choć jedna z funkcji kontrolujących kopiowanie/przenoszenie powinna mieć niedomyślną implementację, powinniśmy zdefiniować wszystkie pięć (konstruktory kopiujący i przenoszący, kopiujące i przenoszące operatory przypisania, oraz destruktor).
Jest nawet możliwe przeciążanie metod w taki sposób, że
odpowiednia wersja zostanie wybrana na podstawie tego, czy
obiekt na rzecz którego ją wywołujemy jest tymczasowy, czy nie.
Przeciążenie dla wywołań na l-wartościach jest zaznaczony
pojedynczym znakiem '&' za listą parametrów, a takie dla
wywołań na r-wartościach — podwójnym znakiem '&',
jak w przykładzie poniżej
1. #include <iostream>
2.
3. struct X {
4. void fun() & { std::cout << "L-value\n"; }
5. void fun() && { std::cout << "R-value\n"; }
6. };
7.
8. int main() {
9. X x{};
10. x.fun(); // wywołanie fun() na l-wartości
11. X{}.fun(); // wywołanie fun() na r-wartości
12. }
który drukuje
L-value
R-value
T.R. Werner, 23 lutego 2022; 19:40