19.5 Semantyka przenoszenia

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


P164: rmoveassign.cpp     Semantyka przenoszenia

      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.
Zauważmy też funkcję replicate (): pobiera ona i zaraz zwraca obiekty typu Arr.

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


P165: RrefMet.cpp     Przeciążanie metod dla wywołań na l- i r-wartościach

      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