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


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

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

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

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

4 ответа:

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

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

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

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

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

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

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

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

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

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

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

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

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

С. П.: Чтобы обеспечить некоторый контекст, я считаю это вопрос и ответ ОП имеет в виду.

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

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

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

в этом случае всегда будет выполнена одна копия. Если вы строите из необработанной строки C, a 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 часто может быть оптимизирован, чтобы быть очень дешевым (подобно move). Он также должен быть проанализирован в контексте:

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

и заставляет вас формировать нестационарный std::string, то откажитесь от нее. (Временный std::string невозможно привязать к неконстантной ссылке). Однако выполняется только одно распределение. Версия C++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

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

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

теперь мы рассмотрим версию take-by-copy:

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 аргументами.

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

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

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

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

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

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

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

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

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

конкурирующая идиома заключается в использовании perfect forwarding:

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

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

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