C++ zawsze tworzył szybkie programy. Niestety, aż do C++11 istniała pewna uporczywa brodawka, która spowalniała wiele programów w C++: tworzenie obiektów tymczasowych. Czasami te tymczasowe obiekty mogą zostać zoptymalizowane przez kompilator (na przykład optymalizacja wartości zwracanej). Ale nie zawsze tak się dzieje i może to prowadzić do kosztownego kopiowania obiektów. Co mam na myśli?

Powiedzmy, że masz następujący kod:

#include <iostream>using namespace std;vector<int> doubleValues (const vector<int>& v){ vector<int> new_values; new_values.reserve(v.size()); for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr ) { new_values.push_back( 2 * *itr ); } return new_values;}int main(){ vector<int> v; for ( int i = 0; i < 100; i++ ) { v.push_back( i ); } v = doubleValues( v );}

Jeśli wykonałeś wiele prac związanych z wysoką wydajnością w C++, przepraszam za ból, który to spowodowało. Jeśli nie – cóż, przejdźmy przez to, dlaczego ten kod jest okropnym kodem C++03. (Reszta tego poradnika będzie o tym, dlaczego jest to dobry kod C++11.) Problem tkwi w kopiach. Kiedy wywoływana jest funkcja doubleValues, konstruuje ona wektor new_values i wypełnia go. Samo to nie jest może idealną wydajnością, ale jeśli chcemy zachować nasz oryginalny wektor w nienaruszonym stanie, potrzebujemy drugiej kopii. Ale co się dzieje, gdy trafimy na instrukcję return?

Cała zawartość new_values musi zostać skopiowana! W zasadzie mogłyby tu być nawet dwie kopie: jedna do obiektu tymczasowego, który ma zostać zwrócony, a druga, gdy operator przypisania wektora wykona się w wierszu v = doubleValues( v );. Pierwsza kopia może zostać automatycznie zoptymalizowana przez kompilator, ale nie da się uniknąć sytuacji, w której przypisanie do v będzie musiało ponownie skopiować wszystkie wartości, co wymaga nowej alokacji pamięci i kolejnej iteracji nad całym wektorem.

Przykład ten może być nieco wymyślony – i oczywiście można znaleźć sposoby na uniknięcie tego rodzaju problemów – na przykład przechowując i zwracając wektor przez wskaźnik lub przekazując wektor do wypełnienia. Rzecz w tym, że żaden z tych stylów programowania nie jest szczególnie naturalny. Co więcej, podejście, które wymaga zwracania wskaźnika, wprowadza co najmniej jeszcze jedną alokację pamięci, a jednym z celów projektowych języka C++ jest unikanie alokacji pamięci.

Najgorsze w tej całej historii jest to, że obiekt zwracany przezdoubleValues jest wartością tymczasową, która nie jest już potrzebna. Gdy mamy linię v = doubleValues( v ), wynik działania doubleValues( v ) po skopiowaniu zostanie po prostu wyrzucony! W teorii, powinno być możliwe pominięcie całego kopiowania i po prostu przechwycenie wskaźnika wewnątrz wektora tymczasowego i zatrzymanie go w v. W efekcie, dlaczego nie możemy przenieść obiektu? W C++03, odpowiedź jest taka, że nie było sposobu, aby stwierdzić, czy obiekt jest tymczasowy czy nie, trzeba było wykonać ten sam kod w operatorze przypisania lub konstruktorze kopiowania, niezależnie od tego, skąd pochodziła wartość, więc żadna kradzież nie była możliwa. W C++11, odpowiedź brzmi – możesz!

Po to właśnie są referencje rvalue i semantyka move! Semantyka move pozwala na uniknięcie niepotrzebnych kopii podczas pracy z obiektami tymczasowymi, które wkrótce znikną, a których zasoby mogą być bezpiecznie pobrane z tego obiektu tymczasowego i użyte przez inny.

Semantyka move opiera się na nowej właściwości języka C++11, zwanej referencjami rvalue, którą będziesz chciał zrozumieć, aby naprawdę docenić to, co się dzieje. Na koniec wrócimy do semantyki move i tego, jak można ją zaimplementować za pomocą referencji rvalue.

Rvalues i lvalues – bitter rivals, or best of friends?

W C++ istnieją rvalue i lvalues. Wartość l jest wyrażeniem, którego adres może być pobrany, wartością lokalizatora – w istocie, wartość l zapewnia (pół)trwały kawałek pamięci. Możesz dokonywać przypisań do lwartości. Przykład:

int a;a = 1; // here, a is an lvalue

Możesz również mieć lwartości, które nie są zmiennymi:

int x;int& getRef () { return x;}getRef() = 4;

Tutaj, getRef zwraca referencję do zmiennej globalnej, więc zwraca wartość, która jest przechowywana w stałej lokalizacji. (Mógłbyś dosłownie napisać & getRef(), gdybyś chciał, i dałoby ci to adres x.)

Wartości są – cóż, r-wartości nie są l-wartościami. Wyrażenie jest rvalue, jeśli jego wynikiem jest obiekt tymczasowy. Na przykład:

int x;int getVal (){ return x;}getVal();

Tutaj, getVal() jest rvalue – zwracana wartość nie jest referencją do x, jest to tylko wartość tymczasowa. Staje się to nieco bardziej interesujące, jeśli użyjemy prawdziwych obiektów zamiast liczb:

string getName (){ return "Alex";}getName();

Here, getName zwraca łańcuch znaków, który jest skonstruowany wewnątrz funkcji. Możesz przypisać wynik funkcji getName do zmiennej:

string name = getName();

Ale przypisujesz z obiektu tymczasowego, a nie z jakiejś wartości, która ma stałą lokalizację. getName() jest wartością rvalue.

Wykrywanie obiektów tymczasowych za pomocą referencji rvalue

Ważną rzeczą jest to, że wartości rvalues odnoszą się do obiektów tymczasowych – tak jak wartość zwracana przez doubleValues. Czy nie byłoby wspaniale, gdybyśmy mogli wiedzieć, bez cienia wątpliwości, że wartość zwracana z wyrażenia jest tymczasowa, i w jakiś sposób napisać kod, który jest przeciążony, aby zachowywał się inaczej dla obiektów tymczasowych? Dlaczego, tak, tak, rzeczywiście byłoby. I do tego właśnie służą referencje rvalue. Referencja rvalue to referencja, która będzie wiązała się tylko z obiektem tymczasowym. Co mam na myśli?

Przed C++11, jeśli miałeś obiekt tymczasowy, mogłeś użyć „regularnego” lub „lvalue reference”, aby go powiązać, ale tylko jeśli było to const:

const string& name = getName(); // okstring& name = getName(); // NOT ok

Intuicja jest taka, że nie możesz użyć „mutowalnego” odniesienia, ponieważ, jeśli byś to zrobił, byłbyś w stanie zmodyfikować jakiś obiekt, który zaraz zniknie, a to byłoby niebezpieczne. Przy okazji zauważ, że trzymanie się stałej referencji do obiektu tymczasowego zapewnia, że obiekt tymczasowy nie zostanie natychmiast zniszczony. Jest to miła gwarancja C++, ale nadal jest to obiekt tymczasowy, więc nie chcesz go modyfikować.

W C++11 istnieje jednak nowy rodzaj referencji, „referencja do wartości r”, która pozwala na wiązanie mutowalnych referencji do wartości r, ale nie do wartości l. Innymi słowy, referencje rvalue są idealne do wykrywania, czy wartość jest obiektem tymczasowym, czy nie. Referencje rvalue używają składni && zamiast po prostu &, i mogą być stałe i niestałe, tak jak referencje lvalue, chociaż rzadko będziesz widział referencję const rvalue (jak zobaczymy, mutablereferencje są w pewnym sensie o to chodzi):

const string&& name = getName(); // okstring&& name = getName(); // also ok - praise be!

Jak na razie to wszystko jest dobre i dobre, ale jak to pomaga? Najważniejszą rzeczą dotyczącą referencji lvalue vs rvalue jest to, co dzieje się, gdy piszesz funkcje, które przyjmują referencje lvalue lub rvalue jako argumenty. Powiedzmy, że mamy dwie funkcje:

printReference (const String& str){ cout << str;}printReference (String&& str){ cout << str;}

Teraz zachowanie staje się interesujące – funkcja printReference przyjmująca referencję lwartościową przyjmie każdy argument, który jej podano, niezależnie od tego, czy będzie to wartość lwartościowa czy rwartościowa, i niezależnie od tego, czy wartość lwartościowa lub rwartościowa jest mutowalna czy nie. Jednakże, w obecności drugiego przeciążenia, printReference przyjmującego referencję rvalue, otrzyma wszystkie wartości z wyjątkiem mutablervalue-references. Innymi słowy, jeśli napiszemy:

string me( "alex" );printReference( me ); // calls the first printReference function, taking an lvalue referenceprintReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference

Teraz mamy sposób na określenie, czy zmienna referencyjna odnosi się do obiektu tymczasowego czy stałego. Wersja referencyjna metody rvalue jest jak tajne wejście tylnymi drzwiami do klubu, do którego możesz wejść tylko wtedy, gdy jesteś obiektem tymczasowym (nudny klub, jak sądzę). Teraz, gdy mamy już naszą metodę określania, czy obiekt był tymczasowy czy stały, jak możemy jej użyć?

Konstruktor move i operator przypisania move

Najczęstszym wzorcem, jaki spotkasz podczas pracy z referencjami rvalue, jest tworzenie konstruktora move i operatora przypisania move (które działają według tych samych zasad). Konstruktor move, podobnie jak konstruktor copy, przyjmuje instancję obiektu jako swój argument i tworzy nową instancję opartą na oryginalnym obiekcie. Jednakże, konstruktor move może uniknąć realokacji pamięci, ponieważ wiemy, że otrzymał obiekt tymczasowy, więc zamiast kopiować pola obiektu, przeniesiemy je.

Co to znaczy przenieść pole obiektu? Jeśli pole jest primitivetype, jak int, to po prostu je kopiujemy. Ciekawiej robi się, gdy pole jest wskaźnikiem: tutaj, zamiast alokować i inicjalizować nową pamięć, możemy po prostu ukraść wskaźnik i wyzerować wskaźnik w obiekcie tymczasowym! Wiemy, że obiekt tymczasowy nie będzie już potrzebny, więc możemy wyjąć spod niego wskaźnik.

Wyobraźmy sobie, że mamy prostą klasę ArrayWrapper, taką jak ta:

class ArrayWrapper{ public: ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; } private: int *_p_vals; int _size;};

Zauważmy, że konstruktor kopiujący musi zarówno przydzielić pamięć, jak i skopiować każdą wartość z tablicy, po kolei! To dużo pracy jak na kopię. Dodajmy konstruktor move i zyskajmy ogromną wydajność.

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _size( 64 ) {} ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _size( other._size ) { other._p_vals = NULL; other._size = 0; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; int _size;};

Wow, konstruktor move jest właściwie prostszy niż konstruktor copy! To nie lada wyczyn. Główne rzeczy, które należy zauważyć to:

  1. Parametr jest referencją non-const rvalue
  2. other._p_vals jest ustawiony na NULL

Druga obserwacja wyjaśnia pierwszą – nie moglibyśmy ustawić other._p_vals naNULL, gdybyśmy wzięli referencję const rvalue. Ale dlaczego musimy ustawićother._p_vals = NULL? Powodem jest destruktor – gdy obiekt tymczasowy wyjdzie poza zakres, tak jak wszystkie inne obiekty C++, jego destruktor zostanie uruchomiony. Gdy destruktor zostanie uruchomiony, zwolni on _p_vals. Te same _p_vals, które właśnie skopiowaliśmy! Jeśli nie ustawimy other._p_vals na NULL, przeniesienie nie będzie tak naprawdę przeniesieniem – będzie to po prostu kopia, która wprowadzi awarię później, gdy zaczniemy używać zwolnionej pamięci. To jest cały sens konstruktora move: uniknąć acopy przez zmianę oryginalnego, tymczasowego obiektu!

Ponownie, zasady przeciążania działają tak, że konstruktor move jest wywoływany tylko dla tymczasowego obiektu – i tylko tymczasowego obiektu, który może być modyfikowany. Oznacza to, że jeśli masz funkcję, która zwraca obiekt const, spowoduje to uruchomienie konstruktora copy zamiast konstruktora move–nie pisz kodu w ten sposób:

const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!

Jest jeszcze jedna sytuacja, której nie omówiliśmy jak obsłużyć w konstruktorze move–gdy mamy pole, które jest obiektem. Na przykład, wyobraźmy sobie, że zamiast pola rozmiaru, mamy pole metadanych, które wygląda tak:

class MetaData{public: MetaData (int size, const std::string& name) : _name( name ) , _size( size ) {} // copy constructor MetaData (const MetaData& other) : _name( other._name ) , _size( other._size ) {} // move constructor MetaData (MetaData&& other) : _name( other._name ) , _size( other._size ) {} std::string getName () const { return _name; } int getSize () const { return _size; } private: std::string _name; int _size;};

Teraz nasza tablica może mieć nazwę i rozmiar, więc być może będziemy musieli zmienić definicję ArrayWrappera tak:

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _metadata( 64, "ArrayWrapper" ) {} ArrayWrapper (int n) : _p_vals( new int ) , _metadata( n, "ArrayWrapper" ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { other._p_vals = NULL; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _metadata( other._metadata ) { for ( int i = 0; i < _metadata.getSize(); ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; MetaData _metadata;};

Czy to działa? Wydaje się to bardzo naturalne, czyż nie, aby po prostu wywołać konstruktor MetaDatamove z wnętrza konstruktora move dla ArrayWrapper? Problem polega na tym, że to po prostu nie działa. Powód jest prosty: wartość other w konstruktorze themove – jest to referencja rvalue. Ale referencja rvalue nie jest w rzeczywistości rvalue. Jest to lvalue, a więc wywoływany jest konstruktor copy, a nie konstruktor themove. To jest dziwne. Wiem – to jest mylące. Oto sposób, w jaki należy o tym myśleć. Wartość r jest wyrażeniem, które tworzy obiekt, który wkrótce wyparuje w powietrze. Jest na swoich ostatnich nogach w życiu – lub wkrótce spełni swój życiowy cel. Nagle przekazujemy obiekt tymczasowy do konstruktora move, a on nabiera nowego życia w nowym zakresie. W kontekście, w którym wyrażenie rvalue zostało obliczone, obiekt tymczasowy jest naprawdę skończony. Innymi słowy, możemy użyć zmiennej other więcej niż raz w funkcji, a obiekt tymczasowy ma zdefiniowaną lokalizację, która naprawdę trwa przez cały czas trwania funkcji. Jest to wartość lvalue w prawdziwym znaczeniu terminu locator value, możemy zlokalizować obiekt pod konkretnym adresem, który jest stabilny przez cały czas trwania wywołania funkcji. Możemy, w rzeczywistości, chcieć użyć go później w funkcji. Gdyby konstruktor move był wywoływany za każdym razem, gdy trzymamy obiekt w referencji rvalu, moglibyśmy przez przypadek użyć przeniesionego obiektu!

 // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { // if _metadata( other._metadata ) calls the move constructor, using // other._metadata here would be extremely dangerous! other._p_vals = NULL; }

Postawmy sprawę jasno: zarówno referencje lvalue, jak i rvalue są wyrażeniami lvalue. Różnica polega na tym, że referencja lvalue musi być const, aby trzymać referencję do anrvalue, podczas gdy referencja rvalue może zawsze trzymać referencję do rvalue.To jest jak różnica między wskaźnikiem, a tym, co jest wskazywane. The thingpointed-to pochodzi z rvalue, ale kiedy używamy samej referencji rvalue, itresults in an lvalue.

std::move

Więc jaka jest sztuczka, aby poradzić sobie z tym przypadkiem? Musimy użyć std::move, from<utility>–std::move jest sposobem na powiedzenie, „ok, szczerze mówiąc wiem, że mam lwartość, ale chcę, żeby to była rwartość.” std::move samo w sobie niczego nie przenosi; po prostu zamienia lwartość w rwartość, tak że można wywołać konstruktor move. Nasz kod powinien wyglądać tak:

#include <utility> // for std::move // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( std::move( other._metadata ) ) { other._p_vals = NULL; }

I oczywiście powinniśmy naprawdę wrócić do MetaData i poprawić jej własny konstruktor move, tak aby używał std::move na łańcuchu, który trzyma:

 MetaData (MetaData&& other) : _name( std::move( other._name ) ) // oh, blissful efficiency : _size( other._size ) {}

Operator przypisania move

Tak jak mamy konstruktor move, powinniśmy również mieć operator przypisania move. Możesz go łatwo napisać używając tych samych technik, co do tworzenia konstruktora move.

Konstruktory move i konstruktory generowane niejawnie

Jak wiesz, w C++, gdy zadeklarujesz dowolny konstruktor, kompilator nie będzie już generował dla ciebie konstruktora domyślnego. Tak samo jest tutaj: dodanie konstruktora przenoszącego do klasy będzie wymagało od Ciebie zadeklarowania i zdefiniowania własnego konstruktora domyślnego. Z drugiej strony, zadeklarowanie konstruktora move nie przeszkadza kompilatorowi w dostarczeniu niejawnie wygenerowanego konstruktora kopiującego, a zadeklarowanie operatora przypisania move nie przeszkadza w utworzeniu standardowego operatora przypisania.

Jak działa std::move

Możesz się zastanawiać, jak napisać funkcję taką jak std::move? Jak uzyskać tę magiczną właściwość przekształcania wartości l w referencję do wartości? Odpowiedzią, jak możesz się domyślać, jest typecasting. Faktyczna deklaracja dla std::move jest nieco bardziej skomplikowana, ale w istocie jest to po prostu static_cast do referencji rvalue. Oznacza to, że tak naprawdę nie musisz używać move – ale powinieneś, ponieważ jest o wiele bardziej jasne, co masz na myśli. Fakt, że rzut jest wymagany, jest, nawiasem mówiąc, bardzo dobrą rzeczą! Oznacza to, że nie można przypadkowo przekonwertować wartości l na wartość r, co byłoby niebezpieczne, ponieważ mogłoby pozwolić na przypadkowe przeniesienie. Musisz jawnie użyć std::move (lub cast), aby przekonwertować lwartość na referencję do wartości, a referencja do wartości r nigdy nie będzie związana z lwartością na jej własną rękę.

Zwracanie jawnej referencji do wartości r z funkcji

Czy są kiedykolwiek sytuacje, w których powinieneś napisać funkcję, która zwraca referencję do wartości r? Co to w ogóle znaczy zwracać referencję wartości r? Czy funkcje, które zwracają obiekty według wartości, nie są już rwartościami?

Na drugie pytanie odpowiedzmy najpierw: zwracanie jawnego odniesienia do wartości r jest inne niż zwracanie obiektu według wartości. Weźmy następujący prosty przykład:

int x;int getInt (){ return x;}int && getRvalueInt (){ // notice that it's fine to move a primitive type--remember, std::move is just a cast return std::move( x );}

W pierwszym przypadku, pomimo tego, że getInt() jest rvalue, mamy do czynienia z kopią zmiennej x. Możemy to nawet zobaczyć, pisząc małą funkcję pomocniczą:

void printAddress (const int& v) // const ref to allow binding to rvalues{ cout << reinterpret_cast<const void*>( & v ) << endl;}printAddress( getInt() ); printAddress( x );

Gdy uruchomisz ten program, zobaczysz, że wydrukowane są dwie oddzielne wartości.

Z drugiej strony,

printAddress( getRvalueInt() ); printAddress( x );

drukuje tę samą wartość, ponieważ jawnie zwracamy tutaj wartość rvalue.

Więc zwracanie referencji rvalue jest inną rzeczą niż nie zwracanie referencji rvalue, ale ta różnica przejawia się najbardziej zauważalnie, jeśli masz wcześniej istniejący obiekt, który zwracasz zamiast tymczasowego obiektu utworzonego w funkcji (gdzie kompilator prawdopodobnie wyeliminuje kopię dla ciebie).

Teraz na pytanie, czy chcesz to zrobić. Odpowiedź brzmi: prawdopodobnie nie. W większości przypadków po prostu zwiększa to prawdopodobieństwo, że skończysz z dryfującym odniesieniem (przypadek, w którym odniesienie istnieje, ale tymczasowy obiekt, do którego się odnosi, został zniszczony). Problem jest dość podobny do niebezpieczeństwa związanego ze zwracaniem referencji lvalue – obiekt, do którego się odwołujemy, może już nie istnieć. Zwracanie referencji rvalue miałoby sens głównie w bardzo rzadkich przypadkach, gdy masz funkcję członkowską i musisz zwrócić wynik wywołaniastd::move na polu klasy z tej funkcji – a jak często zamierzasz to robić?

Semantyka move i biblioteka standardowa

Wracając do naszego oryginalnego przykładu – używaliśmy wektora, a my nie mamy kontroli nad klasą wektorową i tym, czy ma ona konstruktor move, czy nie, operator przypisania move. Na szczęście, komitet normalizacyjny jest mądry i semantyka move została dodana do biblioteki standardowej. Oznacza to, że możesz teraz efektywnie zwracać wektory, mapy, łańcuchy i wszelkie inne obiekty biblioteki standardowej, korzystając w pełni z semantyki move.

Moveable objects in STL containers

W rzeczywistości biblioteka standardowa idzie o krok dalej. Jeśli włączysz funkcję moveemantics w swoich własnych obiektach, tworząc operatory przypisania move i konstruktory move, to gdy będziesz przechowywać te obiekty w kontenerze, STL automatycznie użyje std::move, automatycznie wykorzystując klasy z funkcją move, aby wyeliminować nieefektywne kopie.

Biblioteka standardowa idzie o krok dalej.

Articles

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.