我在某处看到有人决定复制一个对象并随后将其移动到类的数据成员的代码。这让我感到困惑,因为我认为移动的全部目的是避免复制。这是示例:
struct S
{
S(std::string str) : data(std::move(str))
{}
};
以下是我的问题:
- 为什么我们不采用右值引用
str
? - 副本不会很贵,尤其是考虑到类似的东西
std::string
? - 作者决定复制然后搬家的原因是什么?
- 我什么时候应该自己做?
我在某处看到有人决定复制一个对象并随后将其移动到类的数据成员的代码。这让我感到困惑,因为我认为移动的全部目的是避免复制。这是示例:
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
只有一个接受右值的构造函数,上面将无法编译。
副本不会很贵,尤其是考虑到类似的东西
std::string
?
如果您传递一个右值,它将被移入str
,最终将被移入data
。不会进行复制。另一方面,如果您传递一个左值,该左值将被复制到str
,然后移动到data
。
总而言之,右值移动两次,左值移动一次,复制一次。
作者决定复制然后搬家的原因是什么?
首先,正如我上面提到的,第一个并不总是副本;这就是说,答案是:“因为它高效(std::string
物体的移动很便宜)而且简单”。
在移动成本低廉的假设下(此处忽略 SSO),在考虑此设计的整体效率时,它们实际上可以忽略不计。如果我们这样做,我们有一个左值副本(就像我们接受对 的左值引用const
一样)并且没有右值的副本(而如果我们接受对 的左值引用,我们仍然会有一个副本const
)。
这意味着在提供左值const
时,按值取值与按左值引用一样好,而在提供右值时更好。
PS:为了提供一些背景信息,我相信这是OP 所指的问答。
要理解为什么这是一个好的模式,我们应该检查 C++03 和 C++11 中的替代方案。
我们有采用 C++03 的方法std::string const&
:
struct S
{
std::string data;
S(std::string const& str) : data(str)
{}
};
在这种情况下,将始终执行一个副本。如果从原始 C 字符串std::string
构造,将构造 a,然后再次复制:两次分配。
有一种 C++03 方法可以引用 a std::string
,然后将其交换为 local 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
很明显,这 2 种重载技术至少与上述两种 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
很便宜,那么这个版本可以让我们获得与最优化版本几乎相同的性能,但代码少 2 倍。
如果你使用 2 到 10 个参数,代码的减少是指数级的——1 个参数减少 2 倍,2 减少 4 倍,3 减少 8 倍,4 减少 16 倍,10 参数减少 1024 倍。
现在,我们可以通过完美转发和 SFINAE 解决这个问题,允许您编写一个带有 10 个参数的构造函数或函数模板,执行 SFINAE 以确保参数是适当的类型,然后将它们移动或复制到当地状态根据需要。虽然这可以防止程序大小增加千倍的问题,但仍然可以从这个模板生成一大堆函数。(模板函数实例化生成函数)
并且大量生成的函数意味着更大的可执行代码大小,这本身会降低性能。
只需几秒的成本move
,我们就可以得到更短的代码和几乎相同的性能,而且通常更容易理解代码。
现在,这只是因为我们知道,当调用函数(在本例中为构造函数)时,我们将需要该参数的本地副本。这个想法是,如果我们知道我们将要制作一个副本,我们应该通过将它放在我们的参数列表中来让调用者知道我们正在制作一个副本。然后,他们可以围绕他们将给我们一份副本这一事实进行优化(例如,通过进入我们的论点)。
“按值取值”技术的另一个优点是移动构造函数通常是 noexcept。这意味着按值取值并移出其参数的函数通常可以是 noexcept,将任何throw
s 移出它们的主体并进入调用范围(谁可以通过直接构造来避免它,或者将项目构造move
到参数中,以控制抛出发生的位置)。使方法 nothrow 通常是值得的。
这可能是故意的,类似于复制和交换习语。基本上,由于字符串是在构造函数之前复制的,因此构造函数本身是异常安全的,因为它只交换(移动)临时字符串 str。
您不想通过为移动编写一个构造函数和为副本编写一个构造函数来重复自己:
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)) {}
模板魔术将根据您传入的参数选择移动或复制。它基本上扩展到第一个版本,其中两个构造函数都是手工编写的。有关背景信息,请参阅 Scott Meyer 关于通用参考的帖子。
从性能方面来看,完美的转发版本优于您的版本,因为它避免了不必要的移动。但是,有人可能会争辩说您的版本更易于阅读和编写。无论如何,在大多数情况下,可能的性能影响应该无关紧要,所以这最终似乎是一个风格问题。