为什么我们需要先复制再移动文件?

103

我在某个地方看到了一些代码,其中有人决定复制一个对象,并随后将其移动到类的数据成员中。这让我感到困惑,因为我认为移动的整个目的是避免复制。下面是示例:

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

这是我的问题:

  • 为什么不使用右值引用来获取 str
  • 复制会不会很昂贵,特别是对于像 std::string 这样的东西?
  • 作者决定先复制再移动有何原因?
  • 我应该自己在什么情况下这样做?

对我来说,这似乎是一个愚蠢的错误,但我很想看看那些在这个领域拥有更多知识的人是否有什么要说的。 - Dave
可能是重复的问题:[传递const std :: string&作为参数的日子已经过去了吗?](https://dev59.com/qWkv5IYBdhLWcg3w9lai) - Nicol Bolas
这个问题的问答我最初忘记链接了,它可能与该主题相关。 - Andy Prowl
4个回答

101
在回答你的问题之前,有一件事情似乎你理解错了:在C++11中按值传递并不总是意味着复制。如果传递的是rvalue(右值),那么会进行移动操作(前提是存在可行的移动构造函数),而不是复制。并且std::string确实有一个移动构造函数。
与C++03不同,在C++11中,通常以按值方式接受参数是惯用语法,原因将在下面解释。还可以参见StackOverflow上的这个问答,获取更一般的有关如何接受参数的指南。

为什么我们不使用rvalue引用来接受str

因为这将使传递lvalue(左值)变得不可能,例如:

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

如果S只有一个接受rvalue的构造函数,则上述代码将无法编译。
引用块:

即使像std::string这样的东西,复制操作也会很昂贵吗?

如果您传递rvalue,那么它将被移动到str中,最终将被移动到data中。不会执行任何复制操作。另一方面,如果您传递lvalue,那么该lvalue将被复制到str中,然后移动到data中。
因此,总结一下,rvalue需要两次移动,而lvalue需要一次复制和一次移动。
引用块:

作者为什么要决定先复制再移动呢?

首先,正如我上面提到的,第一个并不总是复制;其次,答案是:“因为它高效(std::string对象的移动是便宜的)和简单”。
假设移动操作很便宜(在此忽略SSO),在考虑该设计的整体效率时,可以将其实际上忽略不计。如果这样做,我们对于lvalues只有一个副本(就像接受lvalue引用到const一样),而对于rvalues则没有副本(尽管如果我们接受lvalue引用到const仍然会有一个副本)。
这意味着,当提供lvalues时,按值传递与按lvalue引用到const一样好,而当提供rvalues时更好。
附:为了提供一些背景,我认为这是Q&A中OP所指的是这个。

2
值得一提的是,这是一个C++11模式,用于替换const T&参数传递:在最坏的情况下(lvalue),这是相同的,但在临时情况下,您只需要移动临时对象。双赢。 - syam
3
除非你储存一个引用,否则无法避免拷贝这个对象。 - Benjamin Lindley
5
只要你需要它(如果你想在“数据”成员中保存一个“副本”),那么副本的价格昂贵与否并不重要。即使你使用左值引用传递给const,仍然会有一个副本。 - Andy Prowl
3
作为初步的准备工作,我写道:“在假设移动是廉价的情况下,在考虑此设计的整体效率时可以基本忽略它们”。因此,是的,会有移动的开销,但除非有证据表明这是一个真正的关注点,需要将简单的设计改变为更有效的设计,否则这种开销应被认为是可以忽略的。 - Andy Prowl
1
@user2030677:但那是完全不同的例子。在你提出问题的例子中,你最终总是持有data的一个副本! - Andy Prowl
显示剩余22条评论

52
为了理解为什么这是一个好的模式,我们应该研究一下C++03和C++11中的替代方案。
我们有C++03采用std::string const&的方法:
struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};
在这种情况下,将始终执行单个副本。如果您从原始C字符串构造,则将构造一个std :: string,然后再次复制:两个分配。
有一种C++03的方法是获取对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无法绑定到非const引用)。然而,只有一次分配。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版本,支持拷贝和移动

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个重载版本的实现为“最优化”版本。

现在,我们将检查按值传递的版本:

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到10个参数,那么代码量的减少是指数级别的——一个参数减少2倍,两个参数减少4倍,三个参数减少8倍,四个参数减少16倍,十个参数减少1024倍。
现在,我们可以通过完美转发和SFINAE来解决这个问题,允许你编写一个单一的构造函数或函数模板,接受10个参数,使用SFINAE确保参数具有适当的类型,并根据需要移动或复制它们到局部状态中。虽然这可以避免程序大小增加成千上万倍的问题,但仍可能会生成大量基于该模板的函数。(模板函数实例化生成函数)
大量生成的函数意味着更大的可执行代码大小,这本身就会降低性能。
通过牺牲几个move操作,我们可以获得更短的代码、几乎相同的性能,并且通常更容易理解的代码。
现在,这只有在我们知道函数(在本例中是构造函数)被调用时,我们将要想要一个局部副本的参数时才起作用。思路是,如果我们知道我们将要进行复制,我们应该让调用者知道我们正在进行复制,通过把它放在我们的参数列表中。它们可以优化围绕他们将为我们提供一个副本的事实(例如通过移动到我们的参数中)。
“按值接受”技术的另一个优点是,移动构造函数通常是noexcept的。这意味着取出值并将其移动到参数中的函数通常可以是nothrow的,将任何throw移出其主体并移入调用范围中(有时可以通过直接构建避免它,或构建项目并move到参数中,以控制抛出发生的位置)。使方法成为nothrow往往是值得的。

我也想补充一点,如果我们知道我们要进行拷贝操作,最好让编译器来完成,因为编译器总是更加懂得如何处理。 - Rayniery
6
自从我写了这篇文章后,有人向我指出了另一个好处:经常情况下,复制构造函数可能会抛出异常,而移动构造函数通常是“noexcept”的。通过按拷贝方式传递数据,您可以使函数成为“noexcept”,并且任何因复制构造引起的潜在异常(如内存不足)都会发生在函数调用外部 - Yakk - Adam Nevraumont
为什么在三个重载技术中需要“lvalue非const,复制”版本?“lvalue const,复制”不也处理非const情况吗? - Bruno Martinez
@BrunoMartinez 我们不这样做! - Yakk - Adam Nevraumont

13

这可能是有意为之的,类似于拷贝并交换惯用语法。基本上,由于在构造函数之前复制了字符串,因此构造函数本身是异常安全的,因为它只交换(移动)临时字符串str。


+1 对于复制并交换并行化很有用。事实上,它们有很多相似之处。 - syam

11

您不想重复编写移动构造函数和复制构造函数:

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在通用引用上发布的文章。

从性能方面来看,完美转发版本优于您的版本,因为它避免了不必要的移动。但是,人们可以认为您的版本更易于阅读和编写。在大多数情况下,可能不会有太大的性能影响,因此这似乎最终是一种风格问题。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接