Dlaczego kopiujemy, a potem przenosimy?

98

Widziałem gdzieś kod, w którym ktoś zdecydował się skopiować obiekt, a następnie przenieść go do członka danych klasy. Wprawiło mnie to w zakłopotanie, ponieważ myślałem, że celem przeniesienia jest uniknięcie kopiowania. Oto przykład:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Oto moje pytania:

  • Dlaczego nie bierzemy odniesienia do r-wartości str?
  • Czy kopia nie będzie droga, zwłaszcza biorąc pod uwagę coś takiego std::string?
  • Jaki byłby powód, dla którego autor zdecydowałby się na wykonanie kopii, a następnie na ruch?
  • Kiedy mam to zrobić samodzielnie?
user2030677
źródło
wygląda mi na głupią pomyłkę, ale będę zainteresowany, czy ktoś z większą wiedzą na ten temat ma coś do powiedzenia na ten temat.
Dave
Te pytania i odpowiedzi, których początkowo zapomniałem połączyć, mogą również odnosić się do tematu.
Andy Prowl

Odpowiedzi:

97

Zanim odpowiem na twoje pytania, wydaje się, że się mylisz: branie według wartości w C ++ 11 nie zawsze oznacza kopiowanie. Jeśli przekazana zostanie wartość r, zostanie ona przeniesiona (pod warunkiem, że istnieje realny konstruktor przenoszenia), a nie zostanie skopiowana. I std::stringma konstruktora ruchu.

W przeciwieństwie do C ++ 03, w C ++ 11 często idiomatyczne jest przyjmowanie parametrów według wartości, z powodów, które opiszę poniżej. Zobacz również te pytania i odpowiedzi dotyczące StackOverflow, aby uzyskać bardziej ogólny zestaw wskazówek dotyczących akceptowania parametrów.

Dlaczego nie bierzemy odniesienia do r-wartości str?

Ponieważ uniemożliwiłoby to przekazywanie wartości lv, takich jak:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

Gdyby Stylko miał konstruktor, który akceptuje rvalues, powyższe nie skompilowałoby się.

Czy kopia nie będzie droga, zwłaszcza biorąc pod uwagę coś takiego std::string?

Jeśli przekażesz wartość r, zostanie ona przeniesiona do str, a ostatecznie zostanie przeniesiona do data. Kopiowanie nie zostanie wykonane. Z drugiej strony, jeśli przekażesz lwartość, ta lwartość zostanie skopiowana do str, a następnie przeniesiona do data.

Podsumowując, dwa ruchy dla rvalues, jedna kopia i jeden ruch dla lvalues.

Jaki byłby powód, dla którego autor zdecydowałby się na wykonanie kopii, a następnie na ruch?

Po pierwsze, jak wspomniałem powyżej, pierwsza nie zawsze jest kopią; a to powiedziawszy, odpowiedź brzmi: „ Ponieważ jest wydajne (przemieszczanie std::stringprzedmiotów jest tanie) i proste ”.

Przy założeniu, że ruchy są tanie (pomijając tutaj SSO), można je praktycznie pominąć, biorąc pod uwagę ogólną wydajność tego projektu. Jeśli to zrobimy, mamy jedną kopię dla lwartości (tak jak byśmy mieli, gdybyśmy przyjęli odniesienie do constlwartości) i żadnych kopii dla rwartości (podczas gdy nadal mielibyśmy kopię, gdybyśmy zaakceptowali odniesienie do lwartości const).

Oznacza to, że przyjmowanie według wartości jest tak samo dobre, jak przyjmowanie przez odniesienie do constlwartości, kiedy podawane są l-wartości, a lepsze, gdy podawane są wartości r.

PS: Aby podać kontekst, myślę, że jest to pytanie i odpowiedź, do której odnosi się OP.

Andy Prowl
źródło
2
Warto wspomnieć, że jest to wzorzec C ++ 11, który zastępuje const T&przekazywanie argumentów: w najgorszym przypadku (lwartość) jest to to samo, ale w przypadku tymczasowego wystarczy przenieść tymczasowy. Win-win.
syam
3
@ user2030677: Nie można obejść tej kopii, chyba że przechowujesz odniesienie.
Benjamin Lindley
5
@ user2030677: Kogo obchodzi, jak droga jest kopia tak długo, jak jej potrzebujesz (i robisz, jeśli chcesz zachować kopię w swoim dataczłonku)? Miałbyś kopię, nawet gdybyś wziął ją przez odniesienie doconst
Andy Prowl
3
@BenjaminLindley: Na wstępie napisałem: „ Zakładając, że ruchy są tanie, można je praktycznie pominąć, biorąc pod uwagę ogólną wydajność tego projektu. ”. Więc tak, byłby narzut związany z przeprowadzką, ale należy to uznać za nieistotny, chyba że istnieje dowód, że jest to prawdziwy problem uzasadniający zmianę prostego projektu na coś bardziej wydajnego.
Andy Prowl
1
@ user2030677: Ale to jest zupełnie inny przykład. W przykładzie z twojego pytania zawsze trzymasz kopię data!
Andy Prowl
51

Aby zrozumieć, dlaczego jest to dobry wzorzec, powinniśmy zbadać alternatywy, zarówno w C ++ 03, jak i C ++ 11.

Mamy metodę C ++ 03 polegającą na zrobieniu std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

w takim przypadku zawsze zostanie wykonana jedna kopia. Jeśli konstruujesz z surowego ciągu C, std::stringzostanie skonstruowany, a następnie skopiowany ponownie: dwie alokacje.

Istnieje metoda C ++ 03 polegająca na pobraniu odwołania do a std::string, a następnie zamianie go na lokalny std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

to jest wersja „semantyki przenoszenia” w języku C ++ 03 i swapczęsto można ją zoptymalizować, aby była bardzo tania (podobnie jak a move). Należy to również analizować w kontekście:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

i zmusza cię do utworzenia nietymczasowego std::string, a następnie odrzuć go. (Tymczasowy std::stringpuszka nie wiążą się const odnośnik). Dokonuje się jednak tylko jednego przydziału. Wersja C ++ 11 &&wymagałaby wywołania go z std::movelub z wartością tymczasową: wymaga to jawnego utworzenia kopii poza wywołaniem i przeniesienia tej kopii do funkcji lub konstruktora.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Posługiwać się:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Następnie możemy wykonać pełną wersję C ++ 11, która obsługuje zarówno kopiowanie, jak i move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Następnie możemy sprawdzić, jak to jest używane:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

Jest całkiem jasne, że ta technika 2 przeciążenia jest co najmniej tak samo wydajna, jeśli nie bardziej, niż dwa powyższe style C ++ 03. Nazwę tę wersję z 2 przeciążeniami jako „najbardziej optymalną”.

Teraz przyjrzymy się wersji do pobrania:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

w każdym z tych scenariuszy:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Jeśli porównasz tę wersję obok siebie z „najbardziej optymalną” wersją, zrobimy dokładnie jedną dodatkową move! Ani razu nie robimy nic więcej copy.

Więc jeśli założymy, że movejest tania, ta wersja zapewnia nam prawie taką samą wydajność, jak wersja najbardziej optymalna, ale 2 razy mniej kodu.

A jeśli bierzesz powiedzmy od 2 do 10 argumentów, redukcja kodu jest wykładnicza - 2x mniej z 1 argumentem, 4x z 2, 8x z 3, 16x z 4, 1024x z 10 argumentami.

Teraz możemy obejść ten problem poprzez doskonałe przekazywanie i SFINAE, pozwalające na napisanie pojedynczego konstruktora lub szablonu funkcji, który przyjmuje 10 argumentów, robi SFINAE, aby upewnić się, że argumenty są odpowiedniego typu, a następnie przenosi lub kopiuje je do stan lokalny zgodnie z wymaganiami. Chociaż zapobiega to tysiąckrotnemu wzrostowi rozmiaru programu, nadal może istnieć cały stos funkcji generowanych z tego szablonu. (instancje funkcji szablonu generują funkcje)

A wiele generowanych funkcji oznacza większy rozmiar kodu wykonywalnego, co samo w sobie może zmniejszyć wydajność.

Kosztem kilku movesekund otrzymujemy krótszy kod i prawie taką samą wydajność, a często łatwiejszy do zrozumienia kod.

Teraz to działa tylko dlatego, że wiemy, kiedy wywoływana jest funkcja (w tym przypadku konstruktor), że będziemy potrzebować lokalnej kopii tego argumentu. Chodzi o to, że jeśli wiemy, że będziemy robić kopię, powinniśmy poinformować dzwoniącego, że robimy kopię, umieszczając ją na naszej liście argumentów. Następnie mogą zoptymalizować fakt, że dadzą nam kopię (na przykład przechodząc do naszej argumentacji).

Inną zaletą techniki „weź według wartości” jest to, że często konstruktory przenoszenia nie są wyjątkiem. Oznacza to, że funkcje, które pobierają wartość i wychodzą z argumentu, często nie są wyjątkiem, przenosząc dowolne throws z ciała do zakresu wywołującego (kto może czasami tego uniknąć poprzez bezpośrednią konstrukcję lub skonstruować przedmioty i movewprowadzić je do argumentu, aby kontrolować, gdzie ma miejsce rzucanie) .Robienie metod zamiast rzutów często jest tego warte.

Yakk - Adam Nevraumont
źródło
Dodałbym też, że jeśli wiemy, że zrobimy kopię, to niech zrobi to kompilator, bo kompilator zawsze wie lepiej.
Rayniery
6
Odkąd to napisałem, zwrócono mi uwagę na kolejną zaletę: często konstruktorzy kopiujący mogą rzucać, podczas gdy konstruktorzy przenoszący często noexcept. Biorąc dane po kopii, możesz utworzyć swoją funkcję noexcepti sprawić , że każda konstrukcja kopii spowoduje potencjalne wyrzuty (na przykład brak pamięci) poza wywołaniem funkcji.
Yakk - Adam Nevraumont
Dlaczego potrzebujesz wersji „lvalue non-const, copy” w technice 3 overload? Czy „lvalue const, copy” nie obsługuje również przypadku innego niż stała?
Bruno Martinez
@BrunoMartinez nie!
Yakk - Adam Nevraumont
13

Jest to prawdopodobnie zamierzone i podobne do idiomu kopiuj i zamień . Zasadniczo, ponieważ ciąg jest kopiowany przed konstruktorem, sam konstruktor jest bezpieczny pod względem wyjątków, ponieważ zamienia (przesuwa) tylko tymczasowy ciąg str.

Joe
źródło
+1 za równoległe kopiowanie i zamianę. Rzeczywiście ma wiele podobieństw.
syam
11

Nie chcesz się powtarzać, pisząc konstruktora dla ruchu i jednego dla kopii:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

To jest bardzo standardowy kod, zwłaszcza jeśli masz wiele argumentów. Twoje rozwiązanie pozwala uniknąć tego powielania kosztem niepotrzebnej przeprowadzki. (Jednak operacja przenoszenia powinna być dość tania).

Konkurencyjny idiom to użycie idealnego przekazywania:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

Magia szablonu wybierze przeniesienie lub skopiowanie w zależności od przekazanego parametru. Zasadniczo rozwija się do pierwszej wersji, w której oba konstruktory zostały napisane ręcznie. Dodatkowe informacje można znaleźć w poście Scotta Meyera dotyczącym uniwersalnych odniesień .

Z punktu widzenia wydajności, idealna wersja przekazująca jest lepsza od twojej wersji, ponieważ pozwala uniknąć niepotrzebnych ruchów. Można jednak argumentować, że twoja wersja jest łatwiejsza do czytania i pisania. W każdym razie możliwy wpływ na wydajność nie powinien mieć znaczenia w większości sytuacji, więc ostatecznie wydaje się, że jest to kwestia stylu.

Philipp Claßen
źródło