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; // OKLinia 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