cpp

Wprowadzenie

Semantyka przeniesienia dotyczy wyłącznie danych typów klasowych (do tego zaliczają się także struktury i unie), więc będziemy mówić o przenoszeniu obiektów, a nie danych. Obiekt jest daną typu klasowego, czyli danymi interpretowanymi zgodnie z definicją klasy. Najczęściej stan obiektu jest jego wartością.

Definicja wartości obiektu zależy od implementacji klasy. Zwykle wartością obiektu jest stan obiektów bazowych i składowych. Jednak na wartość obiektu nie muszą składać się niektóre dane, np. dane podręczne (ang. cache data), które składają się na jego stan.

Wartość obiektu może być kopiowana podczas:

W inicjalizacji i przypisaniu rozróżniamy obiekt źródłowy (wyrażeń <expr> wyżej) i docelowy (zmiennych t wyżej). Obiekt docelowy jest inicjalizowany obiektem źródłowym. Obiekt docelowy jest po lewej stronie operatora przypisania, a źródłowy po prawej.

Fakty o kopiowaniu obiektów:

Obiekty źródłowe i docelowe mogą być gdziekolwiek, czyli w dowolnym obszarze pamięci, nie tylko na stosie czy stercie. Na przykład, obiekt źródłowy może być na stosie, a obiekt docelowy w obszarze pamięci dla danych statycznych i globalnych. Obiekt nie powinien wiedzieć, w jakim obszarze pamięci się znajduje.

Kopiowanie może być problemem w zależności od tego czy jest potrzebne czy nie. Nie jest problemem, jeżeli jest potrzebne, np. kiedy musimy wykonać kopię obiektu do zmian, bo oryginału nie możemy zmieniać.

Kopiowanie jest problemem, kiedy jest zbędne, czyli wtedy, kiedy obiekt źródłowy po kopiowaniu nie jest potrzebny. Zbędne kopiowanie pogarsza wydajność: kod będzie działał poprawnie, ale mógłby być szybszy.

Semantyka przeniesienia

Semantyka przeniesienia pozwala na przeniesienie wartości z obiektu źródłowego do docelowego kiedy kopiowanie nie jest potrzebne. Została ona wprowadzona w C++11, ale jej potrzeba była zauważona w latach dziewięćdziesiątych. Przenoszenie jest jak ratowanie ładunku (wartości) z tonącego statku (obiektu, który wkrótce nie będzie potrzebny).

Semantyka przeniesienia jest stosowana:

Semantyka przeniesienia jest implementowana przez:

Jak to działa

Konstruktor: kopiujący i przenoszący

Klasa może mieć kopiujący lub przenoszący konstruktor. Może mieć oba albo żadnego. Konstruktor kopiujący i przenoszący są przeciążeniami konstruktora.

Konstruktor przenoszący klasy T ma jeden parametr typu T &&.

Prosty przykład

W przykładzie niżej klasa ma zdefiniowane trzy konstruktory:

#include <iostream>

struct A
{
  A()
  {
    std::cout << "default ctor\n";
  }

  // The copy constructor has a single parameter of type const A &.
  A(const A &)
  {
    // Copy the data from object a to *this.
    std::cout << "copy-ctor\n";
  }

  // The move constructor has a single parameter of type A &&.
  A(A &&)
  {
    // Move the data from object a to *this.
    std::cout << "move-ctor\n";
  }
};

int
main()
{
  A a;
  // Calls the copy constructor.
  A b{a};
  // Only the default constructor will be called, because the move
  // constructor will be elided.  Compile with -fno-elide-constructors
  // to see the move constructor called, but a compiler will ignore
  // your requests where the elision is mandated by C++17.
  A c{A()};
  // Calls the move constructor.
  A d{std::move(a)};
}

Implementacja przeciążeń konstruktora

Konstruktor przenoszący powinien inicjalizować obiekty bazowe i składowe z użyciem konstruktorów przenoszących. Dlatego w liście inicjalizacyjnej obiektów bazowych i składowych konstruktora przenoszącego powinny być przekazywane r-wartości jako wyrażenia inicjalizacyjne, żeby wpłynąć na wybór przeciążeń konstruktorów obiektów bazowych i składowych. Do tego celu używamy funkcji std::move, jak pokazano w przykładzie niżej, w którym dla porównania zaimplementowano także konstruktor kopiujący.

#include <string>
#include <utility>

struct A {};

struct B: A
{
  std::string m_s;

  B() {}

  // The copy constructor ------------------------------------------

  // The implementation of the copy constructor has to copy the base
  // and member objects of the source object.
  B(const B &source): A(source), m_s(source.m_s)
  {
  }

  // Above is the default implementation which we can get with:
  // B(const B &) = default;

  // The move constructor ------------------------------------------

  // The implementation of the move constructor has to use the
  // std::move function to move the base and member objects of the
  // source object, otherwise they would be copied.
  B(B &&source): A(std::move(source)), m_s(std::move(source.m_s))
  {
  }

  // Above is the default implementation which we can get with:
  // B(B &&) = default;
};

int
main()
{
  B b1;
  B b2(b1);
  B b3(std::move(b1));
  B b4{B()};
}

Operator przypisania: kopiujący i przenoszący

Klasa może mieć kopiujący lub przenoszący operator przypisania. Może mieć oba albo żadnego. Operator kopiujący i przenoszący są przeciążeniami operatora przypisania.

Przenoszący operator przypisania klasy T ma jeden parametr typu T &&.

Prosty przykład:

W przykładzie niżej klasa ma zdefiniowane dwa przeciążenia operatora przypisania:

#include <iostream>

using namespace std;

struct A
{
  A()
  {
    cout << "default ctor\n";
  }

  // The copy assignment operator:
  // * has a single parameter of type const A &,
  // * returns A &.
  A &
  operator=(const A &)
  {
    cout << "copy assign\n";
    return *this;
  }

  // The move assignment operator:
  // * has a single parameter of type A &&,
  // * returns A &.
  A &
  operator=(A &&)
  {
    cout << "move assign\n";
    return *this;
  }
};

int
main()
{
  A a, b;
  // Calls the copy assignment operator.
  a = b;
  // Calls the move assignment operator.
  a = A();
}

Typ wyniku przenoszącego operatora przypisania

Jeżeli x i y są typu A, to wyrażenie x = y = A() powinno przenieść wartość z obiektu tymczasowego A() do y, a następnie powinno skopiować wartość z y do x. To wyrażenie jest opracowywane od prawej do lewej strony, ponieważ operator przypisania ma prawe wiązanie.

Dlatego przenoszący operator przypisania powinien zwracać l-referencję, a nie r-referencję. Jeżeli operator zwracałby r-referencję, to wtedy to wyrażenie przenosiłoby wartość z obiektu tymczasowego A() do y (tak jak oczekujemy), ale potem przenosiłoby wartość z y do x, a przecież oczekiwalibyśmy kopiowania. Implementacja poniżej.

#include <iostream>

struct A
{
  A &operator=(const A &)
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    return *this;
  }

  A &operator=(A &&)
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    return *this;
  }
};

int
main()
{
  A x, y, z;
  // No biggie.
  x = y = z;
  // This works as expected.
  x = y = A();
  // Does not work as expected.  I would expect two moves.
  x = A() = A();
  // Again, I would expect two moves.
  A() = A() = A();

  // This shouldn't compile.  Yet it does!
  A &r = A() = A();
}

Jednak z powyższą implementacją, wyrażenie x = A() = A() jest niepoprawnie opracowywane. Wyrażenie A() = A() co prawda przeniesie wartość z prawego obiektu do lewego, ale to wyrażenie jest l-wartością (ponieważ przenoszący operator przypisania zwraca l-referencję), która będzie wyrażeniem źródłowym operatora przypisania do zmiennej x, ale operatora przypisania kopiującego, a nie oczekiwanego przenoszącego.

Co ciekawe, ponieważ przenoszący operator przypisania zwraca l-referencję, to jego wynikiem możemy zainicjalizować niestałą l-referencję: A &l = A() = A();. Taka inicjalizacja kompiluje się, choć nie powinna, skoro A &l = A(); się nie kompiluje. To jest uchybienie.

Żeby zaradzić powyższemu niepoprawnemu opracowaniu i temu uchybieniu, należy przeciążyć przenoszący operator przypisania osobno dla l-wartości i r-wartości. Poprawna implementacja poniżej.

#include <iostream>

struct A
{
  A &operator=(const A &)
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    return *this;
  }

  A &operator=(A &&) &
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    return *this;
  }

  A &&operator=(A &&) &&
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    return std::move(*this);
  }
};

int
main()
{
  A x, y, z;
  // No biggie.
  x = y = z;
  // This works as expected.
  x = y = A();
  // Now this works as expected.
  x = A() = A();
  // Again, as expected.
  A() = A() = A();
  
  // This shouldn't compile.  And now it doesn't.
  // A &r = A() = A();
}

Implementacja przeciążeń operatora przypisania

Przenoszący operator przypisania powinien przypisywać obiektom bazowym i składowym z użyciem przenoszących operatorów przypisania. Dlatego wyrażeniami źródłowymi operatorów przypisania dla obiektów bazowych i składowym powinny być r-wartości, żeby wpłynąć na wybór przeciążeń operatorów przypisania. Do tego celu używamy funkcji std::move, jak pokazano w przykładzie niżej, w którym dla porównania zaimplementowano także kopiujący operator przypisania.

#include <string>
#include <utility>

struct A {};

struct B: A
{
  std::string m_s;

  B() {}

  // The copy assignment operator ------------------------------------

  // The copy assignment operator has to copy the base and the member
  // objects of the source object.
  B & operator=(const B &source)
  {
    A::operator=(source);
    // We can assign (as above) to the base object this way too:
    // static_cast<A &>(*this) = source;
    m_s = source.m_s;
    return *this;
  }

  // Above is the default implementation which we can get with:
  // B &operator=(const B &) = default;

  // The move assignment operator ------------------------------------

  // The implementation of the move assignment operator has to use the
  // std::move function to move the base and the member objects of the
  // source object, otherwise they would be copied.
  B & operator=(B &&source)
  {
    A::operator=(std::move(source));
    // We can assign (as above) to the base object this way too:
    // static_cast<A &>(*this) = std::move(source);
    m_s = std::move(source.m_s);
    return *this;
  }

  // Above is the default implementation which we can get with:
  // B &operator=(B &&) = default;
};

int
main()
{
  B b1, b2;
  b1 = b2;
  b1 = std::move(b2);
  b1 = B();
}

Wybór przeciążenia

Wybór przeciążenia (kopiującego albo przenoszącego) konstruktora czy operatora przypisania zależy od kategorii wartości wyrażenia źródłowego i dostępności przeciążeń. Stosowane są tu zasady wyboru przeciążenia funkcji w zależności od referencyjnego typu parametru przeciążenia.

Składowe specjalne

Składowymi specjalnymi są:

Składowa specjalna może być niezadeklarowana (ang. undeclared) albo zadeklarowana (ang. declared). Funkcja może być zadeklarowana:

Jeżeli składowa jest zadeklarowana jako usunięta (nieważne, czy jawnie czy niejawnie), to jest brana pod uwagę w wyborze przeciążenia, ale kiedy jest wybrana, to kompilacja kończy się błędem.

Jawnie domyślna składowa

Programista może jawnie zażądać domyślnej implementacji składowej specjalnej z użyciem = default:

struct A
{
  // We need to include the default constructor, because the
  // definition of the argument constructor below would inhibit the
  // generation of the default constructor.
  A() = default;

  A(int x)
  {
  }
};

int
main()
{
  A a, b(1);
}

Domyślna implementacja

Wszystkie obiekty bazowe i składowe w domyślnej implementacji:

Jawnie usunięta składowa

Programista może jawnie usunąć składową z użyciem = delete:

// This example is wierd (I haven't yet seen a destructor deleted),
// but it shows how to explicitly delete a special member function.
struct A
{
  ~A() = delete;
};

int
main()
{
  // This compiles, because we're not deleting the object.
  A *p = new A();
  // A a; // Error: a local variable has to have a destructor called.
}

Zasady dla składowych specjalnych

Wszystkie składowe specjalne są niejawnie domyślnie zaimplementowane (jeżeli są potrzebne), chyba że będzie zastosowana:

Te zasady mają na celu bezproblemową integrację semantyki przeniesienia zarówno w starym, jak i nowym kodzie. Typ, który nie zarządza swoimi zasobami w jakiś nietypowy sposób (chodzi o składowe specjalne), będzie miał zaimplementowane domyślnie semantyki kopiowania i przeniesienia.

Typ tylko do przenoszenia

Obiekty typu tylko do przenoszenia mogą być tylko przenoszone i nie mogą być kopiowane. Oto przykład typu tylko do przenoszenia:

#include <utility>

// A move-only type.  We do not have to explicitly delete the copy
// constructor, and the copy assignment operator, because they will be
// implicitly deleted, since the move constructor and the move
// assignment operator are explicitly defaulted.
struct A
{
  A() = default;
  A(A &&) = default;
  A & operator=(A &&) = default;
};

int
main()
{
  A a;
  // A b(a); // Error: we cannot copy initialize.
  A b(std::move(a));
  // b = a; // Error: we cannot copy assign.
  b = std::move(a);
}

Konsekwencje semantyki przeniesienia

Inicjalizacja parametrów funkcji

Parametr funkcji jest inicjalizowany z użyciem argumentu wywołania. Dla parametru klasowego typu niereferencyjnego, wybór przeciążenia konstruktora będzie zależał od kategorii argumentu i dostępności przeciążeń.

Jeżeli będzie przekazywany obiekt tymczasowy do funkcji, to konstruktor przenoszący nie będzie wywołany, a pominięty.

Niejawne przeniesienie zwracanej wartości

Jeżeli unikanie konstruktorów (albo optymalizacja wartości powrotu) nie może być zastosowana, a zwracany obiekt będzie niszczony po powrocie z funkcji, to wartość zwracanego obiektu może być niejawnie przeniesiona: instrukcja return t; będzie niejawnie zamieniana na return std::move(t);. Tylko wyrażenia będące nazwą zmiennej są tak konwertowane (z l-wartości na r-wartość), inne wyrażenia nie.

Nie powinniśmy zawsze jawnie konwertować kategorii wartości wyrażenia (np. z użyciem funkcji std::move) instrukcji powrotu, bo wtedy uniemożliwimy unikanie konstruktorów (albo optymalizację wartości powrotu).

Poniżej omówione są dwa przypadki, w których optymalizacja wartości powrotu jest niemożliwa, ale w których zwracana wartość jest niejawnie przenoszona.

Przypadek 1

Kiedy zwracamy parametr funkcji, nie można zastosować optymalizacji wartości powrotu (bo parametr nie może być stworzony w miejscu dla zwracanej wartości), ale będzie zastosowane niejawne przeniesienie wartości, bo:

Oto przykład:

#include <iostream>

using namespace std;

struct A
{
  A() = default;

  A(A &&t)
  {
    cout << "move-ctor\n";
  }

};
  
A foo(A a)
{
  return a;
}

int
main()
{
  foo(A());
}

Przypadek 2

Kiedy zwracamy obiekt bazowy lokalnego obiektu, nie można zostosować optymalizacji wartości powrotu, bo miejsce dla wracanej wartości jest przewidziane dla typu bazowego. Wartość obiektu bazowego może być jednak przeniesiona, bo:

Będzie przenoszona tylko wartość obiektu bazowego, a nie całego obiektu, co nazywamy cięciem obiektu (ang. object slicing), bo wycinamy wartość obiektu bazowego, żeby ją przenieść.

#include <iostream>

struct A
{
  A() = default;

  A(const A &)
  {
    std::cout << "copy-ctor\n";
  }

  A(A &&)
  {
    std::cout << "move-ctor\n";
  }
};

struct B: A {};

A foo()
{
  B b;
  return b;
}

int
main()
{
  foo();
}

Jeżeli obiekt lokalny byłby statyczny (czyli nie byłby niszczony po wyjściu z funkcji), to wartość nie mogłaby zostać niejawnie przeniesiona, a jedynie skopiowana.

Funkcja std::swap

Zakończmy tym, od czego semantyka przeniesienia się zaczęła, funkcją std::swap. W latach dziewięćdziesiątych zauważono, że ta funkcja będzie działała szybciej bez kopiowania. Ale jak, skoro wówczas było tylko kopiowanie? Takie jest źródło semantyki przeniesienia.

Funkcja std::swap przyjmuje przez referencję dwa argumenty i zamienia ich wartości. Ta funkcja jest częścią biblioteki standardowej, ale przykładowa implementacja niżej ilustruje problem wydajnej zamiany wartości:

#include <utility>

struct A
{
};
  
void swap(A &a, A &b)
{
  A tmp = std::move(a);
  a = std::move(b);
  b = std::move(tmp);
}

int
main()
{
  A x, y;
  swap(x, y);
}

Podsumowanie

Semantyka przeniesienia:

Quiz