为什么移动物体比复制更快?

64
我听过Scott Meyers说过"std::move()不会移动任何东西" ... 但我不明白它的意思。
所以为了明确我的问题,请考虑以下内容:
class Box { /* things... */ };

Box box1 = some_value;
Box box2 = box1;    // value of box1 is copied to box2 ... ok

怎么样:
Box box3 = std::move(box1);

我确实理解lvalue和rvalue的规则,但我不明白的是在内存中实际发生了什么?它只是以某种不同的方式复制值,共享地址还是其他什么?更具体地说,是什么使移动比复制更快?
我只是觉得理解这一点会让我一切都清楚明了。提前感谢!
编辑:请注意,我不是在询问std::move()的实现或任何语法相关的东西。

3
请阅读https://dev59.com/emEi5IYBdhLWcg3wWLPc。 - Richard Critten
11
移动语义更快,因为它允许源对象保持无效状态,使您可以窃取其资源。例如,如果一个对象持有指向大块分配内存的指针,移动可以简单地窃取该指针,而复制必须分配自己的内存并复制整个内存块。 - Unimportant
3
复制一个对象意味着你需要复制它在内存中的内容。假设你有一个包含2Gb数据的向量。如果你复制这个向量,这2Gb的数据必须在内存中被复制,这需要时间。移动对象意味着内存中的数据保持不变,只有指向这些数据的引用从旧对象移动到你要移动到的对象中。 - bweber
@ user1488118 我确实在某个地方读到过这样的说法,一开始对我来说很有道理,直到我读到当向量已满时会重新分配新的内存,并将旧向量中的对象移动到新分配的内存中... 这让我对此产生了困惑。 - Laith
1
关于 "std::move 不移动任何东西" 的问题 - 他的意思是 std::move(box1); 并没有移动任何东西;然而 Box b = std::move(box1); 确实 移动了一些东西。区别在于移动是由 b 的初始化引起的,而不是由 std::move 的调用引起的。 - M.M
3个回答

96
作为 @gudok 之前回答过的, 一切都在实现中... 然后一点是在用户代码中。

实现

假设我们正在谈论复制构造函数,将一个值分配给当前类。
您提供的实现将考虑以下两种情况:
  1. 参数是左值,因此根据定义,不能更改它
  2. 参数是右值,因此临时值在您使用它后不会再存在太长时间,因此您可以窃取其内容而不是复制其内容
这两种情况都使用重载来实现:
Box::Box(const Box & other)
{
   // copy the contents of other
}

Box::Box(Box && other)
{
   // steal the contents of other
}

轻量级类的实现

假设你的类包含两个整数: 你不能窃取它们,因为它们是原始的值。唯一看起来像窃取的事情就是复制这些值,然后将原始值设置为零,或者类似于这样的操作... 对于简单的整数来说这没有任何意义。为什么要做额外的工作呢?

因此,对于轻量级值类,实际上提供两个特定的实现,一个用于左值,一个用于右值,是没有意义的。

只提供l-value实现将足以满足需求。

重量级类的实现

但是在某些重量级类(即std::string,std::map等)的情况下,复制可能会带来成本,通常是分配方面的成本。因此,理想情况下,尽可能避免复制。这就是从临时变量中窃取数据变得有趣的地方。

假设你的Box包含一个指向昂贵的HeavyResource的原始指针。代码如下:

Box::Box(const Box & other)
{
   this->p = new HeavyResource(*(other.p)) ; // costly copying
}

Box::Box(Box && other)
{
   this->p = other.p ; // trivial stealing, part 1
   other.p = nullptr ; // trivial stealing, part 2
}

这是因为一个构造函数(需要分配内存的复制构造函数)比另一个构造函数(只需要指针赋值的移动构造函数)慢得多。

何时“窃取”才是安全的?

问题在于:默认情况下,编译器只有在参数是临时对象时才会调用“快速代码”(稍微有点微妙,但请耐心等待……)。

为什么?

因为编译器只能保证您可以从某个对象中“窃取”,而不会出现任何问题仅当该对象是临时对象(或者很快就会被销毁)。对于其他对象,窃取意味着您突然拥有了一个有效但未指定状态的对象,可能会在代码后面继续使用。这可能导致崩溃或错误:

Box box3 = static_cast<Box &&>(box1); // calls the "stealing" constructor
box1.doSomething();         // Oops! You are using an "empty" object!

有时候,您需要更好的性能。那么,该怎么做呢?
用户代码
就像您所编写的:
Box box1 = some_value;
Box box2 = box1;            // value of box1 is copied to box2 ... ok
Box box3 = std::move(box1); // ???

对于box2来说,由于box1是一个左值,因此会调用第一个“慢速”复制构造函数。这是正常的C++98代码。
现在,对于box3,有趣的事情发生了:std::move确实返回相同的box1,但作为右值引用而不是左值。因此,该行代码为:
Box box3 = ...

...不会调用box1的复制构造函数。

它将在box1上调用窃取构造函数(正式称为移动构造函数)。

由于Box的移动构造函数“窃取”了box1的内容,因此在表达式结束时,box1处于有效但未指定的状态(通常为空),而box3包含box1的(先前的)内容。

移出类的有效但未指定状态怎么样?

当然,对l-value使用std::move意味着您承诺不再使用该l-value。或者您将非常、非常小心地使用它。

引用C++17标准草案(C++11为:17.6.5.15):

20.5.5.15 库类型的移出状态 [lib.types.movedfrom]

在C++标准库中定义的类型的对象可以被移出(15.8)。移动操作可以明确指定或隐式生成。除非另有规定,否则这些移出对象应放置在有效但未指定的状态。

这是关于标准库中的类型,但这是您应该遵循自己代码的规则。

意思是移动值现在可以持有任何值,可以为空、为零或某些随机值。例如,你的字符串“Hello”可能变成空字符串“”,或变成“Hell”,甚至变成“Goodbye”,如果实现者认为这是正确的解决方案。但它仍然必须是一个有效的字符串,并且保留所有不变量。
因此,除非类型的实现者明确承诺在移动后采取特定行为,否则您应该将移动出的值(该类型的值)视为不知道任何内容。
结论:
如上所述,std::move什么也不做。它只告诉编译器:“你看到那个左值吗?请考虑它是一个右值,只是一瞬间而已。”
所以,在:
Box box3 = std::move(box1); // ???

...用户代码(即std::move)告诉编译器该参数可以被视为此表达式的r-value,因此将调用移动构造函数。

对于代码作者(和代码审查者),代码实际上告诉它可以安全地窃取box1的内容,将其移动到box3中。代码作者随后必须确保不再使用box1(或非常小心地使用)。这是他们的责任。

但最终,移动构造函数的实现将产生巨大的性能差异:如果移动构造函数实际上窃取了r-value的内容,则会看到差异。如果它执行其他任何操作,则作者就在撒谎,但这是另一个问题...


讲解得很好,但说实话,我已经理解了你所解释的大部分内容...但是困惑我的是,POD类型不能移动而只能复制(不确定是否100%正确)。正如我在上面评论中提到的,我也听过Scott Meyers谈论当vector重新分配空间时如何移动(而不是复制)元素,所以我就想,我们如何移动元素呢?我可以理解移动整个向量只涉及交换指针,但是移动元素对我来说就没有意义...无论如何,还是点赞。 - Laith
2
@Leo:我已经完成了答案,解释了为什么对于某些类型(主要是只包含原始整数的简单类)移动没有意义。对于你的向量问题,当向量尝试移动每个单独的对象时,如果该对象没有可用的移动实现,则移动尝试将导致简单的复制。 - paercebal
@hyde:我已经修正了那些拼写错误(在原始的“operator =”代码中,在它被改为构造函数之前)。谢谢!:-) - paercebal
访问已移出变量的内容是否属于未定义行为? - Destructor
@Destructor:不是。变量的内容是有效的(即,您可以使用它),但未指定(即,您对其内容一无所知,因此在使用之前应查看它)。这有点像具有字符串参数的函数:在函数体内,您对字符串的内容一无所知。它可能是XML字符串、JSON字符串、简单消息或您的GOG帐户密码,也可能为空。在该函数内,您可以使用该字符串。您只是不知道里面有什么,除非先查看它。 - paercebal
显示剩余3条评论

22
一切都在于实施。考虑一个简单的字符串类:
class my_string {
  char* ptr;
  size_t capacity;
  size_t length;
};
copy的语义要求我们对字符串进行完全复制,涉及到在动态内存中分配另一个数组,并将*ptr的内容复制到那里,这是一项昂贵的操作。 move的语义要求我们只需将*ptr的值传递给新对象,而无需复制字符串的内容。
当然,如果类不使用动态内存或系统资源,那么在性能方面,移动和复制之间没有区别。

1
这是有道理的,但我读到当向量已满时,它会重新分配新的内存,并且旧向量中的对象将被移动(而不是复制)到新分配的内存中...这让我感到困惑... - Laith
@WLION:这有什么不清楚的?我无法想象有任何情况下复制比移动更便宜。 - GingerPlusPlus
@WLION 向量本身在某种意义上是指向对象的指针集合,虽然存储指针的向量可能需要复制到新区域,但这些指针本身不必改变。它们仍然指向存储在原始空间中的相同对象。 - Havenard
@Havenard 我在Visual Studio中进行了测试,例如一个大小为2且容量为2的向量,然后我使用std :: move()进行push_back以使向量重新分配...当我访问向量元素时,我不必对它们进行取消引用以获取对象的值。 - Laith
1
@Havenard,向量不是指针的集合。C++标准将其定义为一个连续的内存块,包含对象,就像数组一样。移动对象本身是为了消除支持移动语义的对象的深度复制的性能惩罚。如果没有移动可能,则仅复制对象。 - Elkvis

1
< p > std::move()函数应被理解为对应右值类型的转换,它使得对象移动而非复制。


这可能根本没有任何区别:

std::cout << std::move(std::string("Hello, world!")) << std::endl;

在这里,字符串已经是一个rvalue,所以std::move()没有改变任何内容。


这可能会使移动操作生效,但仍可能导致复制:

auto a = 42;
auto b = std::move(a);

没有比直接复制更高效的创建整数的方法。


当参数

  1. 是一个左值或左值引用,
  2. 具有移动构造函数移动赋值运算符,并且
  3. (隐式或显式地)是构造或赋值的源。

即使在这种情况下,实际上进行移动的不是move()本身,而是构造或赋值。 std:move()只是允许这种操作发生的转换,即使您最初使用的是左值。如果您从一个右值开始,移动也可以在没有std::move的情况下发生。我认为这就是Meyers声明背后的含义。


复制是不好的,因为它会占用内存。移动也不好,因为删除不必要的对象需要时间。我说的对吗? - Cătălina Sîrbu

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