为什么我们复制然后移动?
我看到代码在某个地方有人决定复制一个对象,然后把它移动到一个类的数据成员。 这使我感到困惑,因为我认为整个移动的目的是避免复制。 这是一个例子:
struct S { S(std::string str) : data(std::move(str)) {} };
这是我的问题:
- 为什么我们不采取右值参考
str
? - 不会有一个副本是昂贵的,尤其是给一些像
std::string
? - 那么作者决定做一个副本然后呢是什么呢?
- 我应该什么时候自己做这个?
在我回答你的问题之前,有一件事你似乎错了:在C ++ 11中取值并不总是意味着复制。 如果右值被传递,那么将被移动 (提供一个可行的移动构造函数)而不是被复制。 而std::string
确实有一个移动构造函数。
与C ++ 03不同的是,在C ++ 11中,通过值来获取参数通常是习惯用法,因为我将在下面解释。 关于如何接受参数,另请参阅StackOverflow上的这个问答关于更一般性的指导方针。
为什么我们不采取右值参考
str
?
因为这会使得不可能通过左值,例如:
std::string s = "Hello"; S obj(s); // s is an lvalue, this won't compile!
如果S
只有一个接受rvalues的构造函数,上面的代码就不会编译。
不会有一个副本是昂贵的,尤其是给一些像
std::string
?
如果你传递一个右值,那么它将被移入 str
,并且最终将被移入data
。 不会执行复制。 另一方面,如果你传递一个左值,那么左值将被复制到str
,然后移入data
。
所以总结一下,左值的两个动作,一个副本和一个左值的移动。
那么作者决定做一个副本然后呢是什么呢?
首先,正如我上面提到的,第一个并不总是一个副本; 这就是说,答案是:“ 因为它是高效的( std::string
对象的移动很便宜)而且简单 ”。
在假设移动很便宜(忽略SSO)的情况下,考虑到这种devise的整体效率,可以忽略它们。 如果我们这样做,我们有一个左值副本(如果我们接受左值引用const
),没有副本的右值(虽然如果我们接受左值引用const
仍然有一个副本)。
这意味着,当提供左值时,通过值的取值与通过左值引用const
一样好,而在提供右值时更好。
PS:为了提供一些背景,我相信这是 OP所指的问答 。
为了理解为什么这是一个好的模式,我们应该检查在C ++ 03和C ++ 11中的替代scheme。
我们有一个std::string const&
的C ++ 03方法:
struct S { std::string data; S(std::string const& str) : data(str) {} };
在这种情况下, 总会有一个副本执行。 如果你从一个原始的Cstring构造,一个std::string
将被构造,然后再次被复制:两个分配。
有一个引用std::string
的C ++ 03方法,然后将其交换到本地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
不能绑定到非const引用)。 但是,只有一个分配完成。 C ++ 11版本将采用&&
并要求您使用std::move
或临时调用它:这要求调用者在调用之外显式创build一个副本,并将该副本移动到函数或构造函数中。
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
很清楚的是,这种超载技术至less和上面两种C ++ 03风格一样有效,甚至更好。 我将这个2-overload版本称为“最优化”版本。
现在,我们将检查通过复制版本:
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
很便宜,那么这个版本的性能几乎与最优版本相同,但是代码却less了两倍。
如果你正在说2到10个参数,那么代码的减less是指数的 – 一个参数减less2倍,2个8x,3个16x,1024个10个参数。
现在,我们可以通过完美转发和SFINAE来解决这个问题,允许您编写一个带有10个参数的构造函数或函数模板,SFINAE确保参数是适当的types,然后将它们移动或复制到本地状态。 虽然这可以防止程序大小问题增加了一千倍,但仍然可以从这个模板生成一大堆函数。 (模板函数实例化生成函数)
许多生成的函数意味着更大的可执行代码大小,这本身会降低性能。
对于几个步骤的代价,我们可以得到更短的代码和几乎相同的性能,并且通常更容易理解代码。
现在,这只能工作,因为我们知道,当函数(在这种情况下,一个构造函数)被调用,我们将需要该参数的本地副本。 这个想法是,如果我们知道我们将要做一个副本,我们应该让调用者知道我们正在制作一个副本,把它放在我们的参数列表中。 然后,他们可以优化他们将要给我们的一个副本(例如,进入我们的论点)。
“按价值收购”技术的另一个优势是,移动构造函数经常是不被接受的,也就是说,那些带有价值的参数和移出参数的函数通常是不会被接受的,将任何throw
的东西移出它们的主体并进入调用(有时候可以通过直接施工来避免它,或者构build这些项目并进入论证范围,以控制投掷发生的位置),使得方法不成立往往是值得的。
这可能是故意的,与复制和交换习惯相似。 基本上,因为string在构造函数之前被复制,所以构造函数本身是exception安全的,因为它只交换(移动)临时stringstr。
你不想通过编写一个移动构造函数和一个拷贝来重复自己:
S(std::string&& str) : data(std::move(str)) {} S(const std::string& str) : data(str) {}
这是很多样板代码,特别是如果你有多个参数。 您的解决scheme避免了不必要移动的成本重复。 (但是移动操作应该很便宜。)
竞争成语是使用完美的转发:
template <typename T> S(T&& str) : data(std::forward<T>(str)) {}
模板魔术将根据您传入的参数select移动或复制。它基本上扩展到第一个版本,其中两个构造函数都是手写的。 有关背景信息,请参阅Scott Meyer关于通用引用的文章。
从性能方面来说,完美的转发版本要优于你的版本,因为它避免了不必要的移动。 不过,有人可能会认为你的版本更易读写。 无论如何,在大多数情况下,可能的性能影响应该不重要,因此最终似乎是风格问题。