Co to jest move semantics?

Moja pierwsza odpowiedź była niezwykle uproszczonym wprowadzeniem do move semantics, a wiele szczegółów zostało pominiętych celowo, aby zachować prostotę.Jednakże, jest o wiele więcej do move semantics, i pomyślałem, że nadszedł czas na drugą odpowiedź, aby wypełnić luki.Pierwsza odpowiedź jest już dość stara, i nie czułem się dobrze, aby po prostu zastąpić ją zupełnie innym tekstem. Myślę, że nadal dobrze służy jako pierwsze wprowadzenie. Ale jeśli chcesz kopać głębiej, czytaj dalej 🙂

Stephan T. Lavavej poświęcił czas, aby dostarczyć cenne uwagi. Bardzo dziękuję, Stephan!

Wprowadzenie

Semantyka move pozwala obiektowi, pod pewnymi warunkami, przejąć na własność zewnętrzne zasoby innego obiektu. Jest to ważne na dwa sposoby:

  1. Zamieniając drogie kopie w tanie ruchy. Zobacz moją pierwszą odpowiedź dla przykładu. Zauważ, że jeśli obiekt nie zarządza co najmniej jednym zasobem zewnętrznym (bezpośrednio lub pośrednio przez swoje obiekty członkowskie), semantyka move nie będzie oferować żadnych korzyści w stosunku do semantyki copy. W takim przypadku kopiowanie obiektu i przenoszenie obiektu oznacza dokładnie to samo:

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Implementacja bezpiecznych typów „move-only”; to znaczy typów, dla których kopiowanie nie ma sensu, ale przenoszenie tak. Przykłady obejmują zamki, uchwyty plików i inteligentne wskaźniki z unikalną semantyką własności. Uwaga: Ta odpowiedź omawia std::auto_ptr, przestarzały szablon biblioteki standardowej C++98, który został zastąpiony przez std::unique_ptr w C++11. Pośredni programiści C++ są prawdopodobnie przynajmniej w pewnym stopniu zaznajomieni z std::auto_ptr, a ze względu na „semantykę przenoszenia”, którą wyświetla, wydaje się dobrym punktem wyjścia do omówienia semantyki przenoszenia w C++11. YMMV.

Co to jest move?

Biblioteka standardowa C++98 oferuje inteligentny wskaźnik z unikalną semantyką własności o nazwie std::auto_ptr<T>. Na wypadek gdybyś nie znał auto_ptr, jego celem jest zagwarantowanie, że dynamicznie alokowany obiekt jest zawsze zwalniany, nawet w obliczu wyjątków:

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

Niezwykłą rzeczą w auto_ptr jest jego zachowanie „kopiujące”:

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Zauważ, jak inicjalizacja b za pomocą a nie kopiuje trójkąta, ale zamiast tego przenosi własność trójkąta z a na b. Mówimy również „a jest przenoszony do b” lub „trójkąt jest przenoszony z a do b„. Może to brzmieć myląco, ponieważ sam trójkąt zawsze pozostaje w tym samym miejscu w pamięci.

Przesunąć obiekt oznacza przenieść własność jakiegoś zasobu, którym zarządza, na inny obiekt.

Konstruktor kopii z auto_ptr prawdopodobnie wygląda coś takiego (nieco uproszczonego):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Niebezpieczne i nieszkodliwe ruchy

Niebezpieczną rzeczą w auto_ptr jest to, że to, co składniowo wygląda jak kopia, jest w rzeczywistości ruchem. Próba wywołania funkcji członka na przeniesionym auto_ptr wywoła niezdefiniowane zachowanie, więc musisz być bardzo ostrożny, aby nie używać auto_ptr po tym, jak został przeniesiony z:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Ale auto_ptr nie zawsze jest niebezpieczny. Funkcje fabryczne są doskonałym przypadkiem użycia dla auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Zauważ, jak oba przykłady podążają za tym samym wzorcem syntaktycznym:

auto_ptr<Shape> variable(expression);double area = expression->area();

A jednak jeden z nich wywołuje niezdefiniowane zachowanie, podczas gdy drugi nie. Jaka jest więc różnica między wyrażeniami a i make_triangle()? Czy oba nie są tego samego typu? W rzeczy samej są, ale mają różne kategorie wartości.

Kategorie wartości

Oczywiście, musi istnieć jakaś głęboka różnica między wyrażeniem a, które oznacza zmienną auto_ptr, a wyrażeniem make_triangle(), które oznacza wywołanie funkcji, która zwraca auto_ptr według wartości, tworząc w ten sposób świeży tymczasowy obiekt auto_ptr za każdym razem, gdy jest wywoływana. a jest przykładem wartości l, podczas gdy make_triangle() jest przykładem wartości r.

Przejście z wartości l, takich jak a, jest niebezpieczne, ponieważ moglibyśmy później spróbować wywołać funkcję członkowską poprzez a, wywołując niezdefiniowane zachowanie. Z drugiej strony, przenoszenie z rvalues takich jak make_triangle() jest całkowicie bezpieczne, ponieważ po tym, jak konstruktor kopiujący wykona swoją pracę, nie możemy ponownie użyć tymczasowego. Nie ma żadnego wyrażenia, które oznaczałoby wspomnianą wartość tymczasową; jeśli po prostu napiszemy make_triangle() ponownie, otrzymamy inną wartość tymczasową. W rzeczywistości, przeniesiona-przeniesiona tymczasowa zniknęła już w następnej linii:

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Zauważ, że litery l i r mają historyczne pochodzenie w lewej i prawej stronie przypisania. Nie jest to już prawdą w C++, ponieważ istnieją lwartości, które nie mogą pojawić się po lewej stronie przypisania (jak tablice lub typy zdefiniowane przez użytkownika bez operatora przypisania), i istnieją rwartości, które mogą (wszystkie rwartości typów klasowych z operatorem przypisania).

An rwartość typu klasowego jest wyrażeniem, którego ocena tworzy obiekt tymczasowy. W normalnych okolicznościach, żadne inne wyrażenie wewnątrz tego samego zakresu nie oznacza tego samego obiektu tymczasowego.

Odniesienia do wartości

Zrozumieliśmy teraz, że przejście z lwartości jest potencjalnie niebezpieczne, ale przejście z rwartości jest nieszkodliwe. Gdyby C++ posiadał wsparcie językowe do odróżniania argumentów lwartościowych od argumentów r-wartościowych, moglibyśmy albo całkowicie zabronić przechodzenia z l-wartości, albo przynajmniej sprawić, że przechodzenie z l-wartości będzie jawne w miejscu wywołania, tak że nie będziemy już przechodzić przez przypadek.

Odpowiedzią C++11 na ten problem są referencje r-wartościowe. Referencja rvalue jest nowym rodzajem referencji, która wiąże się tylko z rvalues, a jej składnia to X&&. Stara dobra referencja X& jest teraz znana jako referencja lwartościowa. (Zauważ, że X&& nie jest referencją do referencji; nie ma czegoś takiego w C++.)

Jeśli dorzucimy do tego const, to mamy już cztery różne rodzaje referencji. Z jakimi wyrażeniami typu X mogą się one wiązać?

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

W praktyce możesz zapomnieć o const X&&. Bycie ograniczonym do odczytu z rvalues nie jest zbyt użyteczne.

An rvalue reference X&& jest nowym rodzajem referencji, która wiąże się tylko z rvalues.

Implicit conversions

Rvalue references przeszły przez kilka wersji. Od wersji 2.1, referencja rvalue X&& wiąże się również ze wszystkimi kategoriami wartości innego typu Y, pod warunkiem, że istnieje niejawna konwersja z Y na X. W takim przypadku tworzona jest wartość tymczasowa typu X, a referencja rvalue jest wiązana z tą wartością tymczasową:

void some_function(std::string&& r);some_function("hello world");

W powyższym przykładzie "hello world" jest wartością lvalue typu const char. Ponieważ istnieje niejawna konwersja z const char przez const char* do std::string, tworzony jest tymczasowy typ std::string, a r jest związany z tym tymczasowym. Jest to jeden z przypadków, w których rozróżnienie między rvalues (wyrażeniami) i temporaries (obiektami) jest nieco rozmyte.

Konstruktory move

Przydatnym przykładem funkcji z parametrem X&& jest konstruktor move X::X(X&& source). Jego celem jest przeniesienie własności zarządzanego zasobu ze źródła do bieżącego obiektu.

W C++11, std::auto_ptr<T> został zastąpiony przez std::unique_ptr<T>, który korzysta z referencji rvalue. Ja rozwinę i omówię uproszczoną wersję unique_ptr. Po pierwsze, enkapsulujemy surowy wskaźnik i przeciążamy operatory -> i *, więc nasza klasa czuje się jak wskaźnik:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

Konstruktor przejmuje własność obiektu, a destruktor go usuwa:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Teraz nadchodzi interesująca część, konstruktor move:

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Ten konstruktor move robi dokładnie to, co robił konstruktor copy auto_ptr, ale może być dostarczony tylko z rvalues:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

Druga linia nie kompiluje się, ponieważ a jest lvalue, ale parametr unique_ptr&& source może być związany tylko z rvalues. To jest dokładnie to, czego chcieliśmy; niebezpieczne ruchy nigdy nie powinny być niejawne. Trzecia linia kompiluje się równie dobrze, ponieważ make_triangle() jest wartością r. Konstruktor move przeniesie własność z obiektu tymczasowego na c. Ponownie, jest to dokładnie to, czego chcieliśmy.

Konstruktor move przenosi własność zarządzanego zasobu na bieżący obiekt.

Operatory przypisania move

Ostatnim brakującym elementem jest operator przypisania move. Jego zadaniem jest zwolnienie starego zasobu i pozyskanie nowego zasobu z jego argumentu:

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Zauważ, jak ta implementacja operatora przypisania move duplikuje logikę zarówno destruktora, jak i konstruktora move. Czy znasz idiom copy-and-swap? Można go również zastosować do semantyki move jako idiom move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Teraz, gdy source jest zmienną typu unique_ptr, zostanie ona zainicjalizowana przez konstruktor move; to znaczy, argument zostanie przeniesiony do parametru. Argument nadal musi być wartością r, ponieważ konstruktor move sam posiada parametr referencyjny rvalue. Gdy przepływ sterowania osiągnie nawias zamykający operator=, source wychodzi poza zakres, zwalniając automatycznie stary zasób.

Operator przypisania move przenosi własność zarządzanego zasobu do bieżącego obiektu, zwalniając stary zasób. Idiom move-and-swap upraszcza implementację.

Przenoszenie z lvalues

Czasami chcemy przenosić z lvalues. To znaczy, czasami chcemy, aby kompilator traktował wartość l jak wartość r, aby mógł wywołać konstruktor move, mimo że może to być potencjalnie niebezpieczne.W tym celu C++11 oferuje szablon funkcji biblioteki standardowej o nazwie std::move wewnątrz nagłówka <utility>.Nazwa ta jest trochę niefortunna, ponieważ std::move po prostu zamienia wartość l na wartość r; sama w sobie niczego nie przenosi. Jedynie umożliwia przesuwanie. Być może powinna się nazywać std::cast_to_rvalue lub std::enable_move, ale utknęliśmy już z tą nazwą.

Oto jak jawnie przechodzimy od wartości l:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Zauważ, że po trzeciej linii, a nie posiada już trójkąta. To w porządku, ponieważ poprzez jawne napisanie std::move(a), jasno wyraziliśmy nasze intencje: „Drogi konstruktorze, rób co chcesz z a w celu zainicjalizowania c; nie obchodzi mnie już a. Feel free to have your way with a.”

std::move(some_lvalue) casts an lvalue to an rvalue, thus enabling a subsequent move.

Xvalues

Zauważ, że mimo iż std::move(a) jest wartością rvalue, jego ocena nie tworzy obiektu tymczasowego. Ten problem zmusił komitet do wprowadzenia trzeciej kategorii wartości. Coś, co może być związane z referencją rvalue, nawet jeśli nie jest rvalue w tradycyjnym sensie, jest nazywane xvalue (eXpiring value). Tradycyjne wartości rvalues zostały przemianowane na prvalues (Pure rvalues).

Obie wartości prvalues i xvalues są wartościami rvalues. Xvalues i lvalues są zarówno glvalues (Generalized lvalues). Relacje te są łatwiejsze do uchwycenia za pomocą diagramu:

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Zauważ, że tylko xvalues są naprawdę nowe; reszta jest po prostu spowodowana zmianą nazw i grupowaniem.

C++98 rvalues są znane jako prvalues w C++11. Zamień mentalnie wszystkie wystąpienia „rvalue” w poprzednich akapitach na „prvalue”.

Przenoszenie poza funkcje

Do tej pory widzieliśmy przenoszenie do zmiennych lokalnych i do parametrów funkcji. Ale przenoszenie jest również możliwe w przeciwnym kierunku. Jeśli funkcja zwraca wartość, to jakiś obiekt w miejscu wywołania (prawdopodobnie zmienna lokalna lub tymczasowa, ale może to być dowolny obiekt) jest inicjalizowany wyrażeniem po instrukcji return jako argument konstruktora przeniesienia:

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Prawdopodobnie zaskakujące jest to, że obiekty automatyczne (zmienne lokalne, które nie są zadeklarowane jako static) mogą być również niejawnie wyprowadzane z funkcji:

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

Jak to się dzieje, że konstruktor move przyjmuje jako argument wartość lvalue result? Zakres result wkrótce się skończy i zostanie zniszczony podczas rozwijania stosu. Nikt nie mógłby potem narzekać, że result jakoś się zmieniło; kiedy przepływ sterowania wraca do wywołującego, result już nie istnieje! Z tego powodu, C++11 ma specjalną regułę, która pozwala na zwracanie automatycznych obiektów z funkcji bez konieczności pisania std::move. W rzeczywistości nigdy nie powinieneś używać std::move do wyprowadzania obiektów automatycznych z funkcji, ponieważ hamuje to „optymalizację nazwanych wartości zwracanych” (NRVO).

Nigdy nie używaj std::move do wyprowadzania obiektów automatycznych z funkcji.

Zauważ, że w obu funkcjach fabrycznych typem zwracanym jest wartość, a nie referencja rvalue. Referencje rvalue są nadal referencjami i jak zawsze, nigdy nie powinieneś zwracać referencji do obiektu automatycznego; osoba dzwoniąca skończyłaby z dryfującą referencją, gdybyś oszukał kompilator, aby zaakceptował twój kod, jak to:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Nigdy nie zwracaj obiektów automatycznych przez referencję rvalue. Przenoszenie jest wykonywane wyłącznie przez konstruktor move, a nie przez std::move, a nie przez samo wiązanie wartości rvalue z referencją rvalue.

Moving into members

Prędzej czy później napiszesz kod taki jak ten:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Podstawowo kompilator będzie narzekał, że parameter jest wartością lvalue. Jeśli spojrzysz na jego typ, zobaczysz referencję rvalue, ale referencja rvalue oznacza po prostu „referencję, która jest związana z wartością rvalue”; nie oznacza to, że sama referencja jest wartością rvalue! W rzeczywistości, parameter jest po prostu zwykłą zmienną z nazwą. Możesz używać parameter tak często, jak chcesz wewnątrz ciała konstruktora, i zawsze oznacza ona ten sam obiekt. Niejawne przeniesienie z niej byłoby niebezpieczne, dlatego język tego zabrania.

Nazwa referencji rvalue jest wartością lvalue, tak jak każda inna zmienna.

Rozwiązaniem jest ręczne umożliwienie przeniesienia:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

Można by argumentować, że parameter nie jest już używana po inicjalizacji member. Dlaczego nie ma specjalnej reguły, aby po cichu wstawić std::move tak samo jak w przypadku wartości zwracanych? Prawdopodobnie dlatego, że byłoby to zbyt duże obciążenie dla implementatorów kompilatorów. Na przykład, co by było, gdyby ciało konstruktora znajdowało się w innej jednostce tłumaczeniowej? W przeciwieństwie do tego, reguła wartości zwracanej musi po prostu sprawdzić tablice symboli, aby określić, czy identyfikator po słowie kluczowym return oznacza obiekt automatyczny.

Można również przekazać parameter według wartości. Dla typów move-only, takich jak unique_ptr, wydaje się, że nie ma jeszcze ustalonego idiomu. Osobiście wolę przekazywać przez wartość, ponieważ powoduje to mniej bałaganu w interfejsie.

Specjalne funkcje członkowskie

C++98 niejawnie deklaruje trzy specjalne funkcje członkowskie na żądanie, czyli wtedy, gdy są gdzieś potrzebne: konstruktor kopiowania, operator przypisania kopiowania i destruktor.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

Referencje wartości przeszły przez kilka wersji. Od wersji 3.0, C++11 deklaruje dwie dodatkowe specjalne funkcje członkowskie na żądanie: konstruktor move i operator przypisania move. Zauważ, że ani VC10 ani VC11 nie są jeszcze zgodne z wersją 3.0, więc będziesz musiał zaimplementować je samodzielnie.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Te dwie nowe specjalne funkcje członkowskie są deklarowane niejawnie tylko wtedy, gdy żadna ze specjalnych funkcji członkowskich nie została zadeklarowana ręcznie. Ponadto, jeśli zadeklarujesz własny konstruktor move lub operator przypisania move, ani konstruktor copy, ani operator przypisania copy nie zostaną zadeklarowane niejawnie.

Co te zasady oznaczają w praktyce?

Jeśli piszesz klasę bez niezarządzanych zasobów, nie ma potrzeby samodzielnego deklarowania żadnej z pięciu specjalnych funkcji członkowskich, a otrzymasz poprawną semantykę copy i semantykę move za darmo. W przeciwnym razie, będziesz musiał zaimplementować specjalne funkcje członkowskie samodzielnie. Oczywiście, jeśli twoja klasa nie korzysta z semantyki przenoszenia, nie ma potrzeby implementowania specjalnych operacji przenoszenia.

Zauważ, że operator przypisania kopiowania i operator przypisania przenoszenia mogą być połączone w jeden, zunifikowany operator przypisania, przyjmujący swój argument przez wartość:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

W ten sposób liczba specjalnych funkcji członkowskich do zaimplementowania spada z pięciu do czterech. Istnieje tutaj kompromis między bezpieczeństwem wyjątków a wydajnością, ale nie jestem ekspertem w tej kwestii.

Dalsze odniesienia (wcześniej znane jako odniesienia uniwersalne)

Rozważmy następujący szablon funkcji:

template<typename T>void foo(T&&);

Można się spodziewać, że T&& będzie wiązać się tylko z wartościami r, ponieważ na pierwszy rzut oka wygląda jak odwołanie do wartości r. Jak się jednak okazuje, T&& wiąże się również z wartościami l:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Jeśli argumentem jest wartość r typu X, T jest dedukowane jako X, stąd T&& oznacza X&&. Ale jeśli argument jest wartością l typu X, z powodu specjalnej reguły, T jest wydedukowane jako X&, stąd T&& oznaczałoby coś takiego jak X& &&. Ale ponieważ C++ nadal nie ma pojęcia referencji do referencji, typ X& && jest zwinięty do X&. Na początku może to brzmieć myląco i bezużytecznie, ale zwijanie referencji jest niezbędne do doskonałego przekazywania (które nie będzie tutaj omawiane).

T&& nie jest referencją rvalue, ale referencją przekazującą. Wiąże się również z lvalues, w którym to przypadku T i T&& są zarówno referencjami lvalue.

Jeśli chcesz ograniczyć szablon funkcji do rvalues, możesz połączyć SFINAE z cechami typu:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Implementacja move

Teraz, gdy rozumiesz zwijanie referencji, oto jak std::move jest zaimplementowany:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Jak widać, move akceptuje dowolny rodzaj parametru dzięki referencji przekazującej T&&, a zwraca referencję rvalue. Wywołanie meta-funkcji std::remove_reference<T>::type jest konieczne, ponieważ w przeciwnym razie, dla lwartości typu X, typem zwrotnym byłby X& &&, który załamałby się do X&. Ponieważ t jest zawsze wartością l (pamiętaj, że nazwane odwołanie do wartości rvalue jest wartością lvalue), ale chcemy powiązać t z odwołaniem do wartości rvalue, musimy jawnie rzutować t na właściwy typ zwrotu.Wywołanie funkcji, która zwraca odwołanie do wartości rvalue jest samo w sobie wartością x. Teraz już wiesz skąd się biorą wartości x 😉

.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.