Почему мы копируем, а затем двигаемся?

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

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

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

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

Ответ 1

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

В отличие от С++ 03, в С++ 11 часто идиоматично брать параметры по значению по причинам, которые я буду объяснять ниже. Также см. fooobar.com/questions/58832/... для более общего набора рекомендаций по принятию параметров.

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

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

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

Если S имел только конструктор, который принимает значения r, то выше не будет компилироваться.

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

Если вы передадите rvalue, это будет перемещено в str, и это будет в конечном итоге перенесено в data. Никакое копирование не будет выполнено. Если вы передадите lvalue, с другой стороны, это lvalue будет скопировано в str, а затем переместится в data.

Итак, чтобы подвести итог, два хода для rvalues, один экземпляр и один шаг для lvalues.

Какова была бы причина, по которой автор решил сделать копию, а затем двигаться?

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

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

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

P.S.: Чтобы обеспечить некоторый контекст, я считаю, это Q & A, на которое ссылается OP.

Ответ 2

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

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

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

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

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

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

то есть версия С++ 03 "семантика перемещения", а swap часто может быть оптимизирована так, чтобы ее было очень дешево (очень похоже на move). Его также следует проанализировать в контексте:

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

и заставляет вас сформировать не-временный std::string, а затем отбросить его. (Временный std::string не может связываться с неконстантной ссылкой). Однако только одно распределение. Версия С++ 11 принимает && и требует, чтобы вы вызывали ее с помощью 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

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

Довольно ясно, что этот метод перегрузки по крайней мере эффективен, если не более, чем два предыдущих стиля С++ 03. Я дам эту версию с 2-перегрузками "наиболее оптимальной" версией.

Теперь мы рассмотрим вариант с копией:

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 аргументом, 4x с 2, 8x с 3, 16x с 4, 1024x с 10 аргументами.

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

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

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

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

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

Ответ 3

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

Ответ 4

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

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)) {}

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

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