Почему мы копируем, а затем перемещаем?

98

Я где-то видел код, в котором кто-то решил скопировать объект и впоследствии переместить его в член данных класса. Это привело меня в замешательство, поскольку я думал, что весь смысл перемещения заключается в том, чтобы избежать копирования. Вот пример:

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

Вот мои вопросы:

  • Почему мы не берем ссылку на rvalue str?
  • Разве копия не будет дорогой, особенно учитывая что-то вроде std::string?
  • По какой причине автор решил сделать копию, а затем переехать?
  • Когда мне самому это сделать?
user2030677
источник
для меня это выглядит глупой ошибкой, но мне будет интересно узнать, не может ли кто-нибудь, обладающий большими знаниями по этому вопросу, что-нибудь сказать по этому поводу.
Дэйв
Эти вопросы и ответы, которые я изначально забыл связать, также могут иметь отношение к теме.
Энди Проул

Ответы:

97

Прежде чем я отвечу на ваши вопросы, вы, кажется, ошибаетесь в одном: принятие по значению в C ++ 11 не всегда означает копирование. Если передано rvalue, оно будет перемещено (при наличии жизнеспособного конструктора перемещения), а не скопировано. И у std::stringнего есть конструктор перемещения.

В отличие от C ++ 03, в C ++ 11 часто бывает идиоматично принимать параметры по значению по причинам, которые я объясню ниже. Также смотрите эти вопросы и ответы на StackOverflow, чтобы получить более общий набор рекомендаций о том, как принимать параметры.

Почему мы не берем ссылку на rvalue str?

Потому что это сделало бы невозможным передачу lvalue, например:

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

Если бы был Sтолько конструктор, принимающий rvalue, вышеперечисленное не компилировалось бы.

Разве копия не будет дорогой, особенно учитывая что-то вроде std::string?

Если вы передадите rvalue, оно будет перемещено в str, и в конечном итоге будет перемещено в data. Копирование не производится. Если передать именующее выражение, с другой стороны, что - значение будет скопировано в str, а затем переехал в data.

Подводя итог, два шага для rvalues, одна копия и один ход для lvalues.

По какой причине автор решил сделать копию, а затем переехать?

Во-первых, как я упоминал выше, первый не всегда является копией; и при этом ответ таков: « Потому что это эффективно (перемещение std::stringобъектов дешево) и просто ».

Если предположить, что перемещения являются дешевыми (без учета SSO), их можно практически не учитывать при рассмотрении общей эффективности этой конструкции. Если мы это сделаем, у нас будет одна копия для lvalues ​​(как у нас было бы, если бы мы приняли ссылку lvalue на const) и никаких копий для rvalues ​​(хотя у нас все еще была бы копия, если бы мы приняли ссылку lvalue на const).

Это означает, что принимать по значению так же хорошо, как по ссылке lvalue, constкогда предоставляются lvalue , и лучше, когда предоставляются rvalue.

PS: Чтобы обеспечить некоторый контекст, я считаю, что это вопросы и ответы, о которых говорит OP.

Энди Проул
источник
2
Стоит упомянуть, что это шаблон C ++ 11, который заменяет const T&передачу аргументов: в худшем случае (lvalue) это то же самое, но в случае временного вам нужно только переместить временное. Беспроигрышный вариант.
syam
3
@ user2030677: От этой копии никуда не деться, если только вы не храните ссылку.
Бенджамин Линдли
5
@ user2030677: Какая разница, сколько стоит копия, если она вам нужна (и вы это делаете, если хотите держать копию в своем dataчлене)? У вас будет копия, даже если вы возьмете ссылку lvalue наconst
Энди Проул
3
@BenjaminLindley: Предварительно я написал: «Если исходить из того, что ходы дешевы, их можно практически не учитывать при рассмотрении общей эффективности этой конструкции ». Так что да, при переезде возникнут накладные расходы, но их следует считать незначительными, если нет доказательств того, что это реальная проблема, оправдывающая изменение простого дизайна на что-то более эффективное.
Энди Проул
1
@ user2030677: Но это совсем другой пример. В примере из вашего вопроса вы всегда держите копию data!
Энди Проул
51

Чтобы понять, почему это хороший шаблон, мы должны изучить альтернативы как в C ++ 03, так и в C ++ 11.

У нас есть метод C ++ 03 для получения std::string const&:

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

в этом случае всегда будет выполняться одна копия. Если вы строите из необработанной строки C, std::stringбудет построено, а затем снова скопировано: два распределения.

Существует метод C ++ 03, позволяющий взять ссылку на a std::string, а затем заменить ее на локальную std::string:

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

это версия C ++ 03 «семантики перемещения», и swapее часто можно оптимизировать, чтобы сделать ее очень дешевой (во многом как a move). Это также следует анализировать в контексте:

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

и заставляет вас сформировать невременное std::string, а затем отбросить его. (Временное std::stringне может быть привязано к неконстантной ссылке). Однако выполняется только одно распределение. Версия C ++ 11 примет a &&и потребует, чтобы вы вызывали ее с помощью std::moveили с помощью временного: для этого требуется, чтобы вызывающий объект явно создал копию вне вызова и переместил эту копию в функцию или конструктор.

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

Использование:

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

Затем мы можем сделать полную версию C ++ 11, которая поддерживает как копирование, так и 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
};

Затем мы можем изучить, как это используется:

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

Совершенно очевидно, что этот метод двух перегрузок по крайней мере так же эффективен, если не больше, чем два вышеуказанных стиля C ++ 03. Я назову эту версию с двумя перегрузками «самой оптимальной» версией.

Теперь мы рассмотрим версию с дублированием:

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

в каждом из этих сценариев:

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

Если сравнить эту параллельную версию с «самой оптимальной» версией, мы сделаем ровно одну дополнительную move! Ни разу не делаем лишнего copy.

Так что, если предположить, что moveэто дешево, эта версия дает нам почти такую ​​же производительность, что и наиболее оптимальная версия, но в 2 раза меньше кода.

И если вы берете, скажем, от 2 до 10 аргументов, сокращение кода будет экспоненциальным - в 2 раза меньше с 1 аргументом, в 4 раза с 2, 8x с 3, 16x с 4, 1024x с 10 аргументами.

Теперь мы можем обойти это с помощью идеальной пересылки и SFINAE, позволяя вам написать единственный конструктор или шаблон функции, который принимает 10 аргументов, выполняет SFINAE, чтобы гарантировать, что аргументы имеют соответствующие типы, а затем перемещает или копирует их в местное государство по мере необходимости. Хотя это предотвращает тысячукратное увеличение размера программы, из этого шаблона все же может быть сгенерирована целая куча функций. (экземпляры шаблонных функций генерируют функции)

А большое количество сгенерированных функций означает больший размер исполняемого кода, что само по себе может снизить производительность.

За несколько moveсекунд мы получаем более короткий код и почти такую ​​же производительность, а зачастую и более простой для понимания код.

Теперь это работает только потому, что мы знаем, что при вызове функции (в данном случае конструктора) нам потребуется локальная копия этого аргумента. Идея в том, что если мы знаем, что собираемся делать копию, мы должны сообщить вызывающей стороне, что мы делаем копию, поместив ее в наш список аргументов. Затем они могут оптимизировать тот факт, что они собираются дать нам копию (например, перейдя к нашему аргументу).

Еще одно преимущество техники «принимать по значению» состоит в том, что конструкторы перемещения часто не являются исключениями. Это означает, что функции, которые принимают значение по значению и выходят из своего аргумента, часто могут быть без исключения, перемещая любые throws из своего тела в вызывающую область. (кто может избежать этого через прямое построение иногда или создавать элементы и moveв аргумент, чтобы контролировать, где происходит выброс). Часто стоит сделать методы nothrow.

Якк - Адам Неврамонт
источник
Я бы также добавил, что если мы знаем, что сделаем копию, мы должны позволить компилятору сделать это, потому что компилятор всегда знает лучше.
Rayniery
6
С тех пор, как я написал это, мне было указано еще одно преимущество: часто конструкторы копирования могут выбрасывать, а конструкторы перемещения - часто noexcept. Принимая данные за копией, вы можете сделать свою функцию noexcept, и любая конструкция копии, вызывающая потенциальные выбросы (например, из памяти), происходит вне вызова вашей функции.
Yakk - Adam Nevraumont
Зачем вам нужна версия "lvalue non-const, copy" в методе перегрузки 3? Разве "lvalue const, copy" не обрабатывает также случай, отличный от const?
Бруно Мартинес
@BrunoMartinez, мы этого не делаем!
Yakk - Adam Nevraumont
13

Вероятно, это сделано намеренно и похоже на идиому копирования и обмена . В основном, поскольку строка копируется перед конструктором, сам конструктор безопасен в отношении исключений, поскольку он только меняет местами (перемещает) временную строку str.

Джо
источник
+1 для параллельного копирования и обмена. В самом деле, у него много общего.
syam
11

Вы же не хотите повторяться, написав конструктор для перемещения и один для копии:

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

Это очень шаблонный код, особенно если у вас несколько аргументов. Ваше решение позволяет избежать этого дублирования за счет ненужного переезда. (Однако операция перемещения должна быть довольно дешевой.)

Конкурирующая идиома - использовать идеальную переадресацию:

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

Магия шаблона будет перемещать или копировать в зависимости от переданного вами параметра. По сути, она расширяется до первой версии, где оба конструктора были написаны вручную. Для получения дополнительной информации см. Сообщение Скотта Мейера об универсальных ссылках .

С точки зрения производительности идеальная версия для пересылки превосходит вашу версию, поскольку позволяет избежать ненужных ходов. Однако можно возразить, что вашу версию легче читать и писать. В любом случае возможное влияние на производительность не должно иметь значения в большинстве ситуаций, так что в конечном итоге это, похоже, вопрос стиля.

Филипп Классен
источник