我们为什么要复制然后移动?


98

我在某处看到的代码中有人决定复制一个对象,然后将其移动到类的数据成员中。这让我感到困惑,因为我认为移动的全部目的是避免复制。这是示例:

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

这是我的问题:

  • 为什么我们不采用右值引用str
  • 一份副本会不会很贵,特别是给类似的东西std::string吗?
  • 作者决定复制然后搬家的原因是什么?
  • 我什么时候应该自己做?

对我来说,这似乎是一个愚蠢的错误,但是我很想知道是否有人对此事有更多的了解。
戴夫


我最初忘记链接的此问答可能也与该主题有关。
安迪·普罗

Answers:


97

在我回答您的问题之前,您似乎错了一件事情:在C ++ 11中按价值取值并不总是意味着复制。如果传递了一个右值,它将被移动(假设存在可行的move构造函数),而不是被复制。并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),在考虑此设计的整体效率时,实际上可以忽略它们。如果这样做,我们将为左值创建一个副本(就像我们接受对的左值引用时那样const),而没有右值的副本(而如果接受对的左值引用,我们仍然会有一个副本const)。

这意味着const在提供左值时,按值取值与通过左值进行取值一样好,而在提供右值时则更好。

PS:为提供一些背景信息,我相信这是 OP所指的问答环节


2
值得一提的是,它是一种C ++ 11模式,它取代了const T&参数传递:在最坏的情况下(左值),这是相同的,但是在临时情况下,您只需要移动临时情况即可。双赢。
syam

3
@ user2030677:除非您要存储引用,否则无法解决该副本。
本杰明·林德利

5
@ user2030677:谁会在乎您需要副本的价格(如果您想将副本保存在data会员中,那么会花多长时间)?即使您按左值引用const
Andy Prowl

3
@BenjaminLindley:初步来说,我写道:“ 在假设移动便宜的前提下,考虑到这种设计的整体效率,实际上可以忽略它们。 ” 因此,是的,这会有举动的开销,但是除非有证据表明这是真正的关注点,可以证明将简单的设计更改为更有效的解决方案,否则这应该被认为是微不足道的。
Andy Prowl

1
@ user2030677:但这是一个完全不同的例子。在您的问题示例中,您总是最终持有副本data
Andy Prowl

51

为了理解为什么这是一个好的模式,我们应该研究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通常可以进行优化以使其非常廉价(非常类似于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版本,同时支持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

显然,这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倍。

如果您要说2到10个参数,则代码的减少是指数的-1个参数减少2倍,2减少4倍,3减少8倍,4个1024减少10个参数。

现在,我们可以通过完善的转发和SFINAE来解决此问题,允许您编写一个带有10个参数的构造函数或函数模板,执行SFINAE以确保参数为适当的类型,然后将它们移动或复制到所需的本地状态。虽然这可以防止程序大小增加一千倍,但是仍然可以从该模板生成大量函数。(模板函数实例化生成函数)

而且,大量生成的函数意味着更大的可执行代码大小,这本身可能会降低性能。

花费几move秒钟的时间,我们就能获得较短的代码和几乎相同的性能,并且通常更易于理解。

现在,这仅起作用,因为我们知道在调用函数(在这种情况下为构造函数)时,我们将需要该参数的本地副本。这样的想法是,如果我们知道要复制,则应将其放入参数列表中,从而使调用者知道我们正在复制。然后,他们可以围绕将要给我们副本的事实进行优化(例如,进入我们的论点)。

“按价值获取”技术的另一个优点是,通常将构造函数移动到其他对象,这意味着按值获取并移出其参数的函数通常也可以是其他对象,将任何throws移出其主体并移入调用范围(谁有时可以通过直接构造来避免这种情况,或者可以构造项目并将其move放入参数中以控制发生抛出的位置。)使方法不抛出异常通常是值得的。


我还要补充一点,如果我们知道我们将制作一个副本,则应该让编译器来做,因为编译器总是会更好。
Rayniery 2013年

6
自从我写了这篇文章以来,我就向我指出了另一个优势:复制构造函数经常可以抛出异常,而移动构造函数经常可以抛出异常noexcept。通过按拷贝获取数据,您可以使您的函数生效noexcept,并使任何导致潜在抛出(例如内存不足)的副本构造都发生函数调用之外
Yakk-Adam Nevraumont 2014年

为什么在3重载技术中需要“左值非常量复制”版本?“左值const,复制”是否也可以处理非const情况?
布鲁诺·马丁内斯

@BrunoMartinez我们不!
Yakk-Adam Nevraumont 2014年

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的通用参考文章

从性能方面来说,完美的转发版本要比您的版本优越,因为它避免了不必要的移动。但是,有人会说您的版本更容易读写。无论如何,可能的性能影响在大多数情况下都无关紧要,因此最终似乎与样式有关。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.