Semantyka przenoszenia (Move semantics)

Motywacja dla semantyki przenoszenia

  • Optymalizacja wydajności:
    • możliwość rozpoznania kiedy mamy do czynienia z obiektem tymczasowym (temporary object)
    • możliwość wskazania, że obiekt nie będzie dalej używany - jego czas życia wygasa (expiring object)
  • Możliwość implementacji obiektów, które nie powinny być kopiowane, ale umożliwiają transfer prawa własności do zasobu:
    • auto_ptr<T> w C++98 symulował semantykę przenoszenia za pomocą konstruktora kopiującego i operatora przypisania
    • obiekty kontrolujące zasoby systemowe, które nie mogą być łatwo kopiowane - wątki, pliki, strumienie, itp.
void create_and_insert(vector<string>& coll)
{
    string str = "text";

    coll.push_back(str); // insert a copy of str
                         // str is used later

    coll.push_back(str + str); // insert a copy of temporary value
                               // unnecessary copy in C++98

    coll.push_back("text"); // insert a copy of temporary value
                            // unnecessary copy in C++98

    coll.push_back(str);  // insert a copy of str
                          // unnecessary copy in C++98

    // str is no longer used
}

lvalues i rvalues

Aby umożliwić implementację semantyki przenoszenia C++11 wprowadza podział obiektów na:

  • lvalue
    • obiekt posiada nazwę
    • można pobrać adres obiektu
  • rvalue
    • nie można pobrać adresu
    • zwykle nienazwany obiekt tymczasowy (np. obiekt zwrócony z funkcji)
    • z obiektów rvalue możemy transferować stan pozostawiając je w poprawnym, ale nieokreślonym stanie

Przykłady:

double dx;
double* ptr; // dx and ptr are lvalues

std::string foo(std::string str); // foo and str are lvalues

// foo's return is rvalue
foo("Hello"); // temp string created for call is rvalue

std::vector<int> vec; // vec is lvalue

vi[5] = 0; // vi[5] is lvalue

Operacja przenoszenia, która wiąże się ze zmianą stanu jest niebezpieczna dla obiektów lvalue, ponieważ obiekt może zostać użyty po wykonaniu takiej operacji.

Operacje przenoszenia są bezpieczne dla obiektów rvalue.

Referencje rvalue - rvalue references

C++11 wprowadza referencje do rvalue - rvalue references, które zachowują się podobnie jak klasyczne referencje z C++98 (zwane w C++11 lvalue references).

  • składnia: T&&
  • muszą zostać zainicjowane i nie mogą zmienić odniesienia
  • służą do identyfikacji operacji, które implementują przenoszenie

Wprowadzenie referencji do rvalue rozszerza reguły wiązania referencji:

  • Tak jak w C++98:
    • lvalues mogą być wiązane do lvalue references
    • rvalues mogą być wiązanie do const lvalue references
  • W C++11:
    • rvalues mogą być wiązane do rvalue references
    • lvalues nie mogą być wiązane do rvalue references

Ważne

Stałe obiekty lvalue lub rvalue mogą być wiązane tylko z referencjami do obiektów const (const T&& są poprawne składniowo, ale nie mają sensu).

Implementacja semantyki przenoszenia

Używając rvalue references możemy zaimplementować semantykę przenoszenia.

template <typename T>
class vector
{
public:
    void push_back(const T& item);  // inserts a copy of item

    void push_back(T&& item);  // moves item into container
};

// ...

void create_and_insert(vector<string>& coll)
{
    string str = "text";

    coll.push_back(str); // insert a copy of str
                         // str is used later

    coll.push_back(str + str); // rvalue binds to push_back(string&&)
                               // temp is moved into container

    coll.push_back("text"); // rvalue binds to push_back(string&&)
                            // tries to move temporary objecy into container

    coll.push_back(std::move(str));  // tries to move str object into container

    // str is no longer used
}

Innym przykładem mało wydajnej implementacji z wykorzystaniem kopiowania jest implementacja swap w C++98:

template <typename T>
void swap(T& a, T& b)
{
    T temp = a;  // copy a to temp
    a = b; // copy b to a
    b = temp; // copy temp to b
} // destroy temp

Funkcja swap() może zostać wydajniej zaimplementowana w C++11 z wykorzystaniem semantyki przenoszenia - zamiast kopiować przenosimy wewnętrzny stan obiektów (np. wskaźniki do zasobów):

#include <utility>

template <typename T>
void swap(T& a, T& b)
{
    T temp {std::move(a)};  // tries to move a to temp
    a = std::move(b); // tries to move b to a
    b = std::move(temp); // tries to move temp to b
} // destroy temp

Semantyka przenoszenia w klasach

Aby zaimplementować semantykę przenoszenia dla klasy należy zapewnić jej:

  • konstruktor przenoszący - przyjmujący jako argument rvalue reference
  • przenoszący operator przypisania - przyjmujący jako argument rvalue reference

Ważne

Konstruktor przenoszący i przenoszący operator przypisania są nowymi specjalnymi funkcjami składowymi klas w C++11.

Funkcje specjalne klas w C++11

W C++11 istnieje sześć specjalnych funkcji składowych klasy:

  • konstruktor domyślny - X();
  • destruktor - ~X();
  • konstruktor kopiujący - X(const X&);
  • kopiujący operator przypisania - X& operator=(const X&);
  • konstruktor przenoszący - X(X&&);
  • przenoszący operator przypisania - X& operator=(X&&);

Specjalne funkcje mogą być:

  • nie zadeklarowane - not declared
  • niejawnie zadeklarowane - implicitly declared
  • zadeklarowane przez użytkownika - user declared

Specjalne funkcje zdefiniowane jako = default są traktowane jako user declared.

Domyślna implementacja semantyki przenoszenia w klasach

Klasy domyślnie implementują semantykę przenoszenia.

  • Domyślny konstruktor przenoszący przenosi każdą składową klasy.
  • Domyślny przenoszący operator przypisania deleguje semantykę przenoszenia do każdej składowej klasy

Konceptualny kod domyślnego konstruktora przenoszącego i przenoszącego operatora przypisania:

class X : public Base
{
    Member m_;

    X(X&& x) : Base(static_cast<Base&&>(x)), m_(static_cast<Member&&>(x.m_))
    {}

    X& operator=(X&& x)
    {
        Base::operator=(static_cast<Base&&>(x));
        m_ = static_cast<X&&>(x.m_);

        return *this;
    }
};

Przenoszące funkcje specjalne implementowane przez użytkownika:

class X : public Base
{
    Member m_;

    X(X&& x) : Base(std::move(x)), m_(std::move(x.m_))
    {
        x.set_to_resourceless_state();
    }

    X& operator=(X&& x)
    {
        Base::operator=(std::move(x));
        m_ = std::move(x.m_);
        x.set_to_resourceless_state();

        return *this;
    }
};

Jeśli klasa nie zapewnia prawidłowej semantyki przenoszenia - w rezultacie wykonania operacji move() odbywa się kopiowanie.

Reguła =default

Jeżeli jedna z poniższych funkcji specjalnych klasy jest user declared

  • konstruktor kopiujący
  • kopiujący operator przypisania
  • destruktor
  • jedna z przenoszących funkcji specjalnych

specjalne funkcje przenoszące nie są generowane przez kompilator i operacja przenoszenia jest implementowana poprzez kopiowanie elementu (fallback to copy).

Klasa:

class Gadget // default copy and move semantics enabled
{

};

nie jest równoważna klasie:

class Gadget // default move semantics disabled (copy is still allowed)
{
    ~Gadget() = default;
};

Aby umożliwić przenoszenie należy zdefiniować (najlepiej) wszystkie funkcje specjalne.

class Gadget
{
    Gadget(const Gadget&) = default;
    Gadget& operator=(const Gadget&) = default;
    Gadget(Gadget&&) = default;
    Gadget& operator=(Gadget&&) = default;
    ~Gadget() = default;
};

Reguła „Rule of Five”

Jeśli w klasie jest konieczna implementacja jednej z poniższych specjalnych funkcji składowych:

  • konstruktora kopiującego
  • konstruktora przenoszącego
  • kopiującego operatora przypisania
  • przenoszącego operatora przypisania
  • destruktora

najprawdopodobniej należy zaimplementować wszystkie.

Ta regułą stosuje się również do funkcji specjalnych zdefiniowanych jako default.

Implementacja funkcji std::move()

Implementacja funkcji std::move() dokonuje rzutowania na rvalue reference - T&&.

template <typename T>
typename std::remove_reference<T>::type&& move(T&& obj) noexcept
{
    using ReturnType = std::remove_reference<T>::type&&;
    return static_cast<ReturnType>(obj);
}

Reference collapsing

W procesie tworzenia instancji szablonu następuje często zwijanie referencji (tzw. reference collapsing)

Jeśli mamy szablon:

template <typename T>
void f(T& item)
{
    // ...
}

Jeśli przekażemy jako parametr szablonu int&, to tworzona początkowo instancja szablonu wygląda następująco:

void f(int& & item);

Reguła zwijania referencji powoduje, że int& & -> int&. W rezultacie instancja szablonu wygląda tak:

void f(int& item);

W C++11 obowiązują następujące reguły reference collapsing

T& & -> T&
T&& & -> T&
T& && -> T&
T&& && -> T&&

Mechanizm dedukcji typów w szablonach

Dla szablonu

template <typename T>
void f(T&&)  // non-const rvalue reference
{
    // ...
}

typ T jest dedukowany w zależności od tego co zostanie przekazane jako argument wywołania funkcji:

  • jeśli przekazany zostanie obiekt lvalue - to parametr szablonu jest referencją lvalue - T&
  • jeśli przekazany zostanie obiekt rvalue - to parametr szablonu nie jest referencją - T

W połączeniu z regułami zwijania referencji:

string str;

f(str);  // lvalue : f<string&>(string& &&) - > f<string&>(string&)

f(string("Hello")); // rvalue : f<string>(string&&)

Forwarding reference

Referencja rvalue T&& użyta w szablonie ma szczególne zastosowanie:

  • dla argumentów lvalue T&& -> T& - wiąże się z wartościami lvalue
  • dla argumentów rvalue T&& pozostaje T&& - wiąże się z wartościami rvalue

Ponieważ mechanizm dedukcji typów w auto jest taki sam jak w szablonach:

string get_line(istream& in);

auto&& line = get_line(cin);  // type of line: string&&

string name = "Ola";

auto&& alias = name; // type of alias: string&

Perfect Forwarding

Przeciążanie funkcji w celu optymalizacji wydajności z wykorzystaniem semantyki przenoszenia i referencji rvalue może prowadzić do nadmiernego rozrostu interfejsów:

class Gadget;

void have_fun(const Gadget&);
void have_fun(Gadget&); // copy semantics
void have_fun(Gadget&&); // move semantics

void use(const Gadget& g)
{
    have_fun(g); // calls have_fun(const Gadget&)
}

void use(Gadget& g)
{
    have_fun(g); // calls have_fun(Gadget&)
}

void use(Gadget&& g)
{
    have_fun(std::move(g)); // calls have_fun(Gadget&&)
}

int main()
{
    const Gadget cg;
    Gadget g;

    use(cg);  // calls use(const Gadget&) then calls have_fun(const Gadget&)
    use(g);   // calls use(Gadget&) then calls have_fun(Gadget&)
    use(Gadget()); // calls use(Gadget&&) then calls have_fun(Gadget&&)
}

Rozwiązaniem jest szablonowa funkcja przyjmująca jako parametr wywołania T&& (forwarding reference) i przekazująca argument do następnej funkcji z wykorzystaniem funkcji std::forward().

class Gadget;

void have_fun(const Gadget&);
void have_fun(Gadget&); // copy semantics
void have_fun(Gadget&&); // move semantics

template <typename Gadget>
void use(Gadget&& g)
{
    have_fun(std::forward<Gadget>(g)); // forwards original type to have_fun()
                                       //- rvalue reference if T is Gadget
                                       // without forward<> only have_fun(const Gadget&)
                                       // and have_fun(Gadget&) would get called
}

int main()
{
    const Gadget cg;
    Gadget g;

    use(cg);  // calls use(const Gadget&) then calls have_fun(const Gadget&)
    use(g);   // calls use(Gadget&) then calls have_fun(Gadget&)
    use(Gadget()); // calls use(Gadget&&) then calls have_fun(Gadget&&)
}

Funkcja std::forward() działa jak warunkowe rzutowanie na T&&, gdy dedukowanym parametrem szablonu jest typ nie będący referencją.

Słowo kluczowe noexcept

Słowo kluczowe noexcept może być użyte

  • w deklaracji funkcji - aby określić, że funkcja nie może rzucić wyjątku
template <typename T>
class vector
{
public:
    iterator begin() noexcept; // it can't throw an exception
};
  • jako operator - który zwraca true jeśli podane jako parametr wyrażenie nie może rzucić wyjątku
void swap(Type& x, Type& y) noexcept(noexcept(x.swap(y)))
{
    x.swap(y);
}
  • Zastępuje specyfikację rzucanych wyjątków z funkcji w C++03
    • nie ma narzutu w czasie wykonania programu
    • jeśli funkcja zadeklarowana jako noexcept rzuci wyjątek wywoływana jest funkcja std::terminate()
  • Umożliwia optymalizację wydajności - np. implementacja push_back() w wektorze