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

4
有很多文章讨论值语义和引用语义,也有更多的文章试图解释移动语义。然而,没有人谈论过值语义和移动语义之间的联系。它们是正交概念吗?
注意:这个问题不是关于比较值语义和移动语义,因为这两个概念显然是不可比较的。这个问题是关于它们如何连接的,具体地说(就像@StoryTeller所说的),讨论如何:
移动语义有助于更多地使用值类型。

8
错了轴。一个轴是值/引用;另一个轴是复制/移动。 - Pete Becker
@PeteBecker 我认为那可能成为一个答案。 - nbro
2
@nbro -- 如果你想在回答中详细阐述,请尽管去做! - Pete Becker
@PeteBecker 看来你有一个追随者,他会点赞与你相关的所有内容。我不认为我足够称职,但我相信你一定是。 - nbro
2
@BaummitAugen - 我认为“完全”这个词有点不公平。移动语义绝对有助于更多地使用值类型。 - StoryTeller - Unslander Monica
显示剩余6条评论
2个回答

10

原始移动提案中:

复制和移动

C和C++基于复制语义。这是一件好事。移动语义并不试图取代复制语义,也不以任何方式削弱它。相反,该提案旨在增强复制语义。一般的用户定义类可以是可复制和可移动的,其中一个,或两者都不是。

复制和移动的区别在于复制保留源对象的状态不变。而移动则使源对象处于状态——对于每种类型都定义不同。源对象的状态可能未改变,也可能彻底不同。唯一的要求是对象保持自我一致的状态(所有内部不变量仍然完整)。从客户端代码的角度来看,选择移动而不是复制意味着您不关心源对象的状态会发生什么。

对于POD(Plain Old Data)类型,移动和复制是相同的操作(甚至机器指令级别都相同)。

我猜可以补充说:

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


感谢您提供的精彩答案!这可能是描述值/移动语义之间连接最准确的描述。您能否详细说明“唯一要求是对象保持自我一致状态(所有内部不变量仍然完整)”? - laike9m
1
@laike9m:当然。这里是那个概念的更详细描述和视频链接 - Howard Hinnant
1
@alfC:它并不是来自其他语言,至少我是这样认识的。它来自于comp.lang.c++.moderated上的一系列新闻组线程,以及我作为std::lib实现者想要让vector更快的事实。凭借着在C++中分配和释放内存是最昂贵的操作之一的知识,将它们最小化似乎是易如反掌的事情。 - Howard Hinnant
嗨,Howard,根据我从你的回答(以及其他资源)中学到的知识,我写了一篇文章:https://laike9m.com/blog/revisiting-move-semanticsand-all-the-related-idioms,114/ 如果您能够看一下,我将不胜感激。 - laike9m
@laike9m:非常好的文章!我留了一些评论,希望对你有所帮助。 - Howard Hinnant
显示剩余4条评论

1

受Howard的答案启发,我写了一篇文章关于这个主题,希望能帮助到也在想这个问题的人。我在这里复制/粘贴了这篇文章。

当我学习移动语义时,我总是有一种感觉,即使我很了解这个概念,我也无法将其融入C++的大局中。移动语义不像某些语法糖那样仅存在于方便之中,它深刻地影响着人们思考和编写C++的方式,并成为最重要的C++习惯用语之一。但是,嘿,当你把移动语义扔进去时,C++的池塘已经充满了其他习惯用语,互相挤压就随之而来。移动语义是破坏、增强还是取代其他习惯用语?我不知道,但我想找出答案。

值语义

值语义是让我开始思考这个问题的原因。由于在C++中没有太多名为“语义”的东西,我自然地想到,“也许值语义和移动语义有一些联系?”结果,这不仅仅是联系,而是起源:

移动语义并非试图取代复制语义,也不会以任何方式削弱它。相反,该提案旨在增强复制语义。 - 移动语义提案,2002年9月10日
也许您已经注意到它使用了“复制语义”一词,实际上,“值语义”和“复制语义”是相同的东西,我将交替使用它们。
好的,那么什么是值语义?isocpp有一个完整页面介绍了它,但基本上,值语义意味着赋值复制值,例如T b = a;。这是定义,但通常情况下,值语义只是指创建、使用、存储对象本身,按值传递、返回,而不是指针或引用
相反的概念是引用语义,其中赋值复制指针。在引用语义中,重要的是标识,例如 T& b = a; ,我们必须记住 ba 的别名,而不是其他任何东西。但在值语义中,我们根本不关心标识,只关心对象1所持有的值。这是由于复制的性质带来的,因为复制确保给我们两个独立的对象,它们持有相同的值,你无法告诉哪一个是源对象,也不会影响使用。
与其他语言(Java、C#、JavaScript)不同,C++ 建立在值语义的基础上。默认情况下,赋值进行位拷贝(如果没有涉及用户定义的复制构造函数),参数和返回值是复制构造的(是的,我知道有 RVO)。保持值语义在 C++ 中被认为是一件好事。一方面,这更安全,因为你不需要担心悬挂指针和所有可怕的东西;另一方面,它更快,因为你有更少的间接性,请参见此处官方解释。

移动语义:V8引擎在值语义汽车上的应用

移动语义并非试图取代复制语义,它们完全兼容。我想出了一个比喻,我觉得很好地描述了它们之间的关系。

想象一下,你有一辆汽车,它内置的发动机运转顺畅。有一天,你在这辆车上安装了一个额外的V8引擎。只要你有足够的燃料,V8引擎就能加速你的车,这会让你感到高兴。

因此,汽车是值语义,而V8引擎则是移动语义。在你的汽车上安装引擎并不需要一辆新车,它仍然是同一辆车,就像使用移动语义不会使你放弃值语义一样,因为你仍然在操作对象本身而不是它的引用或指针。此外,由绑定偏好实现的“如果可以移动则移动,否则复制”的策略,正如选择引擎的方式一样,即如果可以使用V8(有足够的燃料),否则回退到原始引擎。

现在我们对Howard Hinnant(移动提案的主要作者)在SO上的答案有了相当好的理解:

移动语义允许我们保持值语义,同时在那些原始对象的价值对程序逻辑不重要的情况下获得引用语义的性能。

编辑: Howard添加了一些非常值得一提的评论。根据定义,移动语义更像引用语义,因为移动到和移动自对象不是独立的,当修改(无论是通过移动构造还是移动赋值)移动到的对象时,移动自对象也会被修改。然而,这并不重要——当移动语义发生时,您不关心移动自对象,它要么是纯右值(因此没有其他人引用原始对象),要么是程序员明确表示“我不关心复制后原始值”(使用std::move而不是复制)。由于对原始对象的修改对程序没有影响,因此可以将移动到的对象用作独立副本,保留值语义的外观。

移动语义和性能优化

移动语义主要是关于性能优化的:能够将昂贵的对象从一个内存地址移动到另一个内存地址,同时通过窃取源的资源来以最小的代价构造目标。 - 移动语义提案 正如提案所述,移动语义的主要好处是提升性能。这里举两个例子。

你可以看到的优化

假设我们有一个处理器(不管它是什么),它的构建成本很高,我们想将其存储到映射中以备将来使用。
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构造函数。通过移动(而不是复制)构造一个map值,可以节省大量时间。
你看不见的优化
Howard Hinnant曾在他的talk中提到,move语义的想法来自于优化std::vector。如何实现? std::vector<T>对象基本上是指向堆上内部数据缓冲区的一组指针,例如begin()end()。由于为数据缓冲区分配新内存,复制向量是昂贵的。当使用移动而不是复制时,只有指针被复制并指向旧缓冲区。
What's more,移动操作还可以提高向量insert操作的效率。这在提案的vector Example部分中有解释。假设我们有一个std::vector<string>,其中包含两个元素"AAAAA""BBBBB",现在我们想在索引1处插入"CCCCC"。假设向量有足够的容量,以下图表演示了使用复制和移动进行插入的过程。
(来源:qnssl.com)

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

移动语义和资源管理

在著名文章零规则中,作者写道:

使用值语义对RAII至关重要,因为引用不影响其参照物的生命周期。

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

作为您可能已经知道或不知道的,RAII有另一个名字叫做“Scope-Bound Resource Management”(SBRM),这是基本用例,在该用例中,由于范围退出,RAII对象的生命周期结束。还记得使用值语义的优点吗?安全性。我们仅通过查看其存储期限就可以确定对象的生命周期何时开始和结束,99%的时间我们会在块作用域中找到它,这使得它非常简单。对于指针和引用,情况变得更加复杂,现在我们必须担心所引用或指向的对象是否已被释放。这很难,更糟糕的是,这些对象通常存在于与其指针和引用不同的范围内。
很明显,值语义与RAII相得益彰——RAII将资源的生命周期绑定到对象的生命周期上,并且使用值语义,您可以清楚地了解对象的生命周期。
但是,资源涉及身份...
尽管值语义和RAII似乎是完美的匹配,但实际上并非如此。为什么?从根本上讲,因为资源涉及身份,而值语义只关心值。您拥有一个打开的套接字,您使用该套接字;您拥有一个打开的文件,您使用该文件。在资源管理的背景下,没有相同值的事物。资源代表自己,具有唯一的身份。
看到矛盾了吗?在C++11之前,如果坚持值语义,很难处理资源,因为它们无法被复制,因此程序员想出了一些解决方法:
- 使用裸指针; - 编写自己的可移动但不可复制的类(通常涉及私有复制构造函数和像swap和splice这样的操作); - 使用auto_ptr。
这些解决方案旨在解决唯一所有权和所有权转移的问题,但它们都有一些缺点。我不会在这里讨论它,因为它无处不在互联网上。我想要解决的是,即使没有移动语义,也可以进行资源所有权管理,只是需要更多的代码,并且常常容易出错。
"缺乏统一的语法和语义来使通用代码移动任意对象(就像通用代码今天可以复制任意对象一样)。”-Move Semantics Proposal 与提案中上述语句相比,我更喜欢这个答案:
“除了明显的效率好处外,这还为程序员提供了一个符合标准的方式来拥有可移动但不可复制的对象。可移动但不可复制的对象通过标准语言语义传达出非常清晰的资源所有权边界...我的观点是,移动语义现在是一种标准的方式,可以简洁地表达(除其他事项外)可移动但不可复制的对象。”
上述引用已经很好地解释了移动语义在C++中的资源所有权管理方面的含义。资源应该自然而然地是可移动的(我指的是可转移的),但不可复制。现在,借助移动语义(实际上是为了支持它而进行的整个语言层面的大量更改),有一种标准的方法可以正确且高效地做到这一点。
价值语义的重生
最后,我们能够谈论移动语义为价值语义带来的另一个方面(除了性能)。
通过上面的讨论,我们已经看到了为什么价值语义符合RAII模型,但与此同时不兼容资源管理。随着移动语义的出现,填补这一差距所必需的材料终于准备好了。因此,我们拥有了智能指针!
毋庸置疑,std::unique_ptr和std::shared_ptr的重要性,在此我想强调三件事:
- 它们遵循RAII; - 它们利用移动语义获得巨大优势(特别是对于unique_ptr); - 它们有助于保持价值语义。

第三点,如果您阅读过零规则,您就知道我在说什么。永远不要使用原始指针来管理资源,只需直接使用unique_ptr或存储为成员变量即可。在传输资源所有权时,隐式构造的移动构造函数能够很好地完成工作。更好的是,当前规范确保,在最坏情况下(即没有省略的情况下),返回语句中的命名值被视为rvalue。这意味着,对于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

点击这里查看更详细的解释。实际上,当使用unique_ptr作为函数参数时,按值传递仍然是最好的选择。如果有时间,我可能会写一篇文章来介绍它。

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

总之,通过利用智能指针,值语义现在真正地获得了与RAII的兼容性。在其核心,这是由移动语义驱动的。

摘要

到目前为止,我们已经讨论了很多概念,你可能感到不知所措,但我想传达的要点很简单:

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

我自己也是这个主题的学习者,所以请随时指出您认为错误的任何内容,我非常感激。

[1]: 这里的对象指的是“具有地址、类型并能够存储值的一块内存”,来自Andrzej's C++ blog


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