【问题标题】:Why do we copy then move?为什么我们复制然后移动?
【发布时间】:2013-05-19 10:42:39
【问题描述】:

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

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

这是我的问题:

  • 我们为什么不对str 进行右值引用?
  • 副本不会很贵,尤其是考虑到std::string 之类的东西?
  • 作者决定复制然后搬家的原因是什么?
  • 我应该什么时候自己做?

【问题讨论】:

标签: c++ c++11 move-semantics


【解决方案1】:

在我回答您的问题之前,您似乎弄错了一件事:在 C++11 中按价值取值并不总是意味着复制。如果传递了一个右值,它将被移动(如果存在可行的移动构造函数)而不是被复制。而std::string 确实有一个移动构造函数。

与 C++03 不同,在 C++11 中,按值获取参数通常是惯用的,原因我将在下面解释。另请参阅this Q&A on 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 一样好,而在提供右值时更好。

P.S.:为了提供一些上下文,我相信 OP 指的是this is the Q&A

【讨论】:

  • 值得一提的是,它是一个 C++11 模式,它取代了 const T& 参数传递:在最坏的情况下(左值)这是相同的,但在临时情况下,您只需移动暂时的。双赢。
  • @user2030677:除非您存储参考,否则无法绕过该副本。
  • @user2030677:只要您需要,谁在乎副本有多贵(如果您想在您的data 成员中持有副本) ?即使您通过左值引用const,您也会有一份副本
  • @BenjaminLindley:作为初步,我写道:“假设移动很便宜,在考虑这种设计的整体效率时,它们实际上可以忽略不计。”。所以是的,移动会产生开销,但这应该被认为可以忽略不计,除非有证据证明这是一个真正的问题,证明将简单的设计更改为更有效的东西是合理的。
  • @user2030677:但这是一个完全不同的例子。在您问题的示例中,您最终总是在 data! 中保留一份副本!
【解决方案2】:

要了解为什么这是一个好的模式,我们应该检查 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 不能绑定到非常量引用)。但是,只完成了一次分配。 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 版本,支持复制和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 以确保参数是适当的类型,然后移动或复制他们根据需要进入当地状态。虽然这可以防止程序大小增加千倍的问题,但仍然可以从这个模板生成一大堆函数。 (模板函数实例化生成函数)

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

以几个moves 为代价,我们获得了更短的代码和几乎相同的性能,而且通常更容易理解代码。

现在,这只是因为我们知道,当调用函数(在本例中为构造函数)时,我们将需要该参数的本地副本。这个想法是,如果我们知道我们将要制作一个副本,我们应该通过将它放在我们的参数列表中来让调用者知道我们正在制作一个副本。然后他们可以围绕他们将给我们一份副本这一事实进行优化(例如,通过进入我们的论点)。

“按值取值”技术的另一个优点是移动构造函数通常是 noexcept。这意味着按值取值并移出其参数的函数通常可以是 noexcept,将任何 throws 移出其参数body 并进入调用范围(有时可以通过直接构造来避免它,或者将项目和move 构造到参数中,以控制发生抛出的位置)。使方法 nothrow 通常是值得的。

【讨论】:

  • 我还要补充一点,如果我们知道我们会复制,我们应该让编译器来做,因为编译器总是知道得更好。
  • 自从我写了这个,另一个好处是向我指出:复制构造函数通常可以抛出,而移动构造函数通常是noexcept。通过逐个复制获取数据,您可以使您的函数noexcept,并让任何复制构造导致潜在的抛出(如内存不足)发生在您的函数调用外部
  • 为什么需要3重载技术中的“lvalue non-const, copy”版本? “lvalue const, copy”不也处理非 const 的情况吗?
  • @BrunoMartinez 我们没有!
【解决方案3】:

这可能是故意的,类似于copy and swap idiom。基本上,由于字符串是在构造函数之前复制的,因此构造函数本身是异常安全的,因为它只交换(移动)临时字符串 str。

【讨论】:

  • +1 表示复制和交换并行。确实有很多相似之处。
【解决方案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)) {}

模板魔术将根据您传入的参数选择移动或复制。它基本上扩展到第一个版本,其中两个构造函数都是手工编写的。有关背景信息,请参阅 Scott Meyer 在universal references 上的帖子。

从性能方面来看,完美的转发版本优于您的版本,因为它避免了不必要的移动。但是,有人可能会争辩说您的版本更易于阅读和编写。无论如何,在大多数情况下,可能的性能影响应该无关紧要,所以这最终似乎是一个风格问题。

【讨论】:

    猜你喜欢
    • 2017-01-06
    • 2020-01-29
    • 2018-03-27
    • 1970-01-01
    • 1970-01-01
    • 2021-10-14
    • 2017-12-06
    • 2020-09-26
    相关资源
    最近更新 更多