Почему мы копируем, а затем двигаемся?
Я видел код где-то, где кто-то решил скопировать объект, а затем переместить его в член данных класса. Это оставило меня в замешательстве в том, что я думал, что все дело в том, чтобы избежать копирования. Вот пример:
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)) {}
Магия шаблона будет выбирать перемещение или копирование в зависимости от параметра, который вы передаете. Он в основном расширяется до первой версии, где оба конструктора были написаны вручную. Для получения дополнительной информации см. Пост Скотта Мейера в универсальных ссылках.
С точки зрения производительности идеальная версия пересылки превосходит вашу версию, поскольку она позволяет избежать ненужных ходов. Однако можно утверждать, что ваша версия легче читать и писать. В любом случае возможное влияние производительности не должно иметь большого значения в большинстве ситуаций, поэтому в конце концов, похоже, это вопрос стиля.