C++ 中的值语义和移动语义之间有什么联系?

What's the connection between value semantics and move semantics in C++?

有很多文章讨论值语义与引用语义,也许更多的文章试图解释移动语义。然而,从来没有人谈论过值语义和移动语义之间的联系。它们是正交概念吗?

注意:这个问题不是关于比较值语义和移动语义,因为很明显这两个概念不是 "comparable"。这个问题是关于它们是如何连接的,具体来说(就像@StoryTeller 所说),关于讨论(如何):

Move semantics help facilitate more use of value types.

来自original move proposal

Copy vs Move

C and C++ are built on copy semantics. This is a Good Thing. Move semantics is not an attempt to supplant copy semantics, nor undermine it in any way. Rather this proposal seeks to augment copy semantics. A general user defined class might be both copyable and movable, one or the other, or neither.

The difference between a copy and a move is that a copy leaves the source unchanged. A move on the other hand leaves the source in a state defined differently for each type. The state of the source may be unchanged, or it may be radically different. The only requirement is that the object remain in a self consistent state (all internal invariants are still intact). From a client code point of view, choosing move instead of copy means that you don't care what happens to the state of the source.

For PODs, move and copy are identical operations (right down to the machine instruction level).

我想有人可以补充说:

移动语义允许我们保留值语义,但同时在原始(复制自)对象的值对程序逻辑不重要的情况下获得引用语义的性能。

受到霍华德回答的启发,我写了an article关于这个话题,希望它能帮助同样想知道它的人。我copy/paste文章在这里

当我学习移动语义时,我总有一种感觉,即使我对这个概念非常了解,但我无法将它融入 C++ 的大局中。 Move 语义不像一些单纯为了方便而存在的语法糖,它深深地影响了人们思考和编写 C++ 的方式,并成为最重要的 C++ 习语之一。但是,嘿,C++ 的池塘里已经充满了其他的习语,当你把移动语义扔进去的时候,相互挤压也随之而来。移动语义是否打破、增强或取代了其他习语?我不知道,但我想知道。

值语义

值语义让我开始思考这个问题。由于C++中名字"semantics"的东西不多,我自然而然地想到,"maybe value and move semantics have some connections?"。事实证明,这不仅仅是联系,而是起源:

Move semantics is not an attempt to supplant copy semantics, nor undermine it in any way. Rather this proposal seeks to augment copy semantics.

- Move Semantics Proposal, September 10, 2002

也许你注意到它使用了"copy semantics"这样的措辞,实际上,"value semantics"和"copy semantics"是一回事,我将它们互换使用。

好的,什么是值语义? isocpp 有一个 whole page 谈论它,但基本上,值语义意味着 赋值复制值 ,就像 T b = a;。这是定义,但是 通常值语义只是意味着创建、使用、存储对象本身、传递,return 按值,而不是指针或引用.

相反的概念是引用语义,其中赋值复制指针。在引用语义中,重要的是身份,例如 T& b = a; ,我们必须记住 ba 的别名,而不是其他任何东西。但是在值语义中,我们根本不关心身份,我们只关心一个对象1持有的值。这是copy的本质带来的,因为一个copy保证给我们两个独立的对象,它们拥有相同的值,你分不清哪个是源,也不影响使用。

与其他语言(Java、C#、JavaScript)不同,C++ 建立在值语义之上。默认情况下,赋值执行按位复制(如果不涉及用户定义的复制构造函数),参数和 return 值是复制构造的(是的,我知道有 RVO)。在 C++ 中,保持值语义被认为是一件好事。一方面,它更安全,因为您不必担心悬挂指针和所有令人毛骨悚然的东西;另一方面,它更快,因为你的间接性更少,官方解释见here

移动语义:价值语义汽车上的 V8 引擎

移动语义并不是要取代复制语义。它们彼此完全兼容。我想出了这个比喻,我觉得它很好地描述了他们的关系。

想象一下你有一辆汽车,它运行 与内置引擎一起平稳运行。有一天,您在这辆车上安装了一个额外的 V8 发动机。只要你有足够的燃料,V8 发动机就能让你的车加速,这让你很开心。

所以,汽车是值语义,V8引擎是移动语义。在你的车上安装引擎不需要一辆新车,它仍然是同一辆车,就像使用移动语义不会让你放弃值语义一样,因为你仍然在对象本身而不是它的引用或指针上操作。此外,move if you can, else copy 策略,由 binding preferences 实现,与选择引擎的方式完全相同,那就是如果可以(燃料足够)就使用V8,否则就退回到原来的发动机。

现在我们对 Howard Hinnant(搬迁提案的主要作者)关于 SO 的 有了很好的理解:

Move semantics allows us to keep value semantics, but at the same time gain the performance of reference semantics in those cases where the value of the original (copied-from) object is unimportant to program logic.

编辑:霍华德添加了一些真正值得一提的评论。根据定义,移动语义更像引用语义,因为移动对象和移动对象不是独立的,当修改(通过移动构造或移动赋值)移动对象时,移动对象是也修改了。然而,这并不重要——当移动语义发生时,你不关心被移动的对象,它要么是一个纯右值(所以没有其他人有引用到原来的),或者当程序员特别说 "I don't care about the value of the original after the copy" (通过使用 std::move 而不是复制)。由于对原始对象的修改对程序没有影响,您可以将移动到的对象当作一个独立的副本来使用,保留值语义的外观运行。

移动语义和性能优化

Move semantics is mostly about performance optimization: the ability to move an expensive object from one address in memory to another, while pilfering resources of the source in order to construct the target with minimum expense.

- Move Semantics Proposal

如提案中所述,人们从移动语义中获得的主要好处是性能提升。我这里举两个例子。

看得见的优化

假设我们有一个构造起来很昂贵的处理程序(无论是什么),我们想将它存储到地图中以备将来使用。

std::unordered_map<string, Handler> handlers;
void RegisterHandler(const string& name, Handler handler) {
  handlers[name] = std::move(handler);
}
RegisterHandler("handler-A", build_handler());

这是move的典型用法,当然它假设Handler有一个move ctor。通过移动(而不是复制)-构建地图值,可以节省大量时间。

你看不到的优化

Howard Hinnant 曾在他的 talk 中提到,移动语义的想法来自优化 std::vector。怎么样?

一个std::vector<T>对象基本上是一组指向堆上内部数据缓冲区的指针,如begin()end()。由于要为数据缓冲区分配新内存,因此复制向量的成本很高。当使用移动而不是复制时,只有指针被复制并指向旧缓冲区。

更重要的是,移动还提升了矢量insert操作。这在提案的 vector Example 部分进行了解释。假设我们有一个包含两个元素 "AAAAA""BBBBB"std::vector<string>,现在我们想在索引 1 处插入 "CCCCC"。假设向量有足够的容量,下图演示了复制与移动插入的过程。


(来源:qnssl.com

图中显示的所有内容都在堆上,包括向量的数据缓冲区和每个元素字符串的数据缓冲区。使用复制,必须复制 str_b 的数据缓冲区,这涉及缓冲区分配然后释放。通过移动,旧 str_b 的数据缓冲区被新地址中的新 str_b 重用,不需要缓冲区分配或释放(正如霍华德指出的那样,"data" 旧 str_b 现在指向未指定)。这带来了巨大的性能提升,但它的意义不止于此,因为现在您可以在不牺牲性能的情况下将昂贵的对象存储到向量中,而以前必须存储指针。这也有助于扩展值语义的使用。

移动语义和资源管理

在名篇Rule of Zero中,作者写道:

Using value semantics is essential for RAII, because references don’t affect the lifetime of their referrents.

我发现这是讨论移动语义和资源管理之间相关性的一个很好的起点。

您可能知道也可能不知道,在 RAII 对象生命周期结束的基本用例之后,RAII 有另一个名称,叫做 范围绑定资源管理 (SBRM)由于范围退出。还记得使用值语义的一个优势吗?安全。我们确切地知道对象的生命周期何时开始和结束,只需查看它的 storage duration,并且 99% 的时间我们会在块范围内找到它,这使得它非常简单。对于指针和引用,事情变得更加复杂,现在我们不得不担心被引用或指向的对象是否已被释放。这很难,更糟糕的是这些对象通常存在于与其指针和引用不同的范围内。

很明显为什么值语义与 RAII 相处得很好 —— RAII 将资源的生命周期绑定到对象的生命周期,并且通过值语义,您可以清楚地了解对象的生命周期。

但是,资源是关于身份的……

尽管值语义和 RAII 似乎是完美的匹配,但实际上并非如此。为什么?从根本上说,因为资源是关于身份的,而价值语义只关心价值。你有一个开放的套接字,你使用那个套接字;你有一个打开的文件,你使用这个文件。在资源管理的上下文中,不存在具有相同值的事物。资源代表自己,具有唯一标识。

看到这里的矛盾了吗?在 C++11 之前,如果我们坚持使用值语义,就很难使用资源,因为它们无法复制,因此程序员想出了一些解决方法:

  • 使用原始指针;
  • 写自己的可移动但不可复制的class(经常涉及私有复制构造函数和swapsplice等操作);
  • 使用auto_ptr.

这些解决方案旨在解决唯一所有权和所有权t运行转让的问题,但它们都有一些缺点。我不会在这里谈论它,因为它在 Internet 上随处可见。我想说明的是,即使没有移动语义,也可以进行资源所有权管理,只是它需要更多的代码并且经常容易出错。

What is lacking is uniform syntax and semantics to enable generic code to move arbitrary objects (just as generic code today can copy arbitrary objects).

- Move Semantics Proposal

比起上面提案的说法,我更喜欢这个answer

In addition to the obvious efficiency benefit, this also affords a programmer a standards-compliant way to have objects that are movable but not copyable. Objects that are movable and not copyable convey a very clear boundary of resource ownership via standard language semantics …my point is that move semantics is now a standard way to concisely express (among other things) movable-but-not-copyable objects.

上面的引述很好地解释了移动语义对 C++ 中资源所有权管理的意义。资源自然应该是可移动的("movable" 我的意思是 t运行sferrable)但不可复制,现在借助移动语义(实际上在语言级别进行了大量更改以支持它),有正确有效地执行此操作的标准方法。

值语义的重生

最后,我们可以谈谈增强的另一个方面(性能除外),即移动语义带来价值语义。

通过上面的讨论,我们已经了解了为什么值语义适合 RAII 模型,但同时与资源管理不兼容。随着移动语义的出现,填补这一空白的必要材料终于准备好了。所以我们有了智能指针!

std::unique_ptrstd::shared_ptr的重要性就不用多说了,这里我要强调三点:

  • 他们遵循 RAII;
  • 他们充分利用了移动语义(尤其是 unique_ptr);
  • 它们有助于保持价值语义。

第三点,如果你读过Rule of Zero,你就知道我在说什么了。无需使用原始指针来管理资源,EVER,只需直接使用 unique_ptr 或存储为成员变量,即可完成。当 t运行 转让资源所有权时,隐式构造的 move ctor 能够很好地完成这项工作。更好的是,当前规范确保在最坏情况下(即没有省略)return 语句中的命名值被视为右值。这意味着,returning by value 应该是 unique_ptr.

的默认选择
std::unique_ptr<ExpensiveResource> foo() {
  auto data = std::make_unique<ExpensiveResource>();
  return data;
}
std::unique_ptr<ExpensiveResource> p = foo();  // a move at worst

有关更详细的说明,请参阅 here。事实上,在使用unique_ptr作为函数参数时,值传递仍然是最好的选择。如果有时间,我可能会写一篇关于它的文章。

除了智能指针,std::stringstd::vector也是RAII包装器,它们管理的资源是堆内存。对于这些 classes,按值 return 仍然是首选。我不太确定 std::threadstd::lock_guard 等其他东西,因为我没有机会使用它们。

总而言之,通过使用智能指针,值语义现在真正获得了与 RAII 的兼容性。从本质上讲,这是由移动语义提供支持的。

总结

到目前为止,我们已经介绍了很多概念,您可能会感到不知所措,但我想表达的观点很简单:

  1. 移动语义在保持值语义的同时提升性能;
  2. 移动语义有助于将资源管理的每一部分整合在一起,成为今天的样子。特别是,它是使值语义和 RAII 真正协同工作的关键,因为它早就应该如此。

我自己也是这个主题的学习者,所以请随时指出任何您认为不对的地方,我非常感谢。

[1]:这里object的意思是“一块内存,有地址,有类型,可以存储值”,来自Andrzej's C++ blog