繁体   English   中英

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

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

有很多文章讨论了值语义与引用语义,还有更多的文章试图解释移动语义。 但是,没有人谈论过价值语义学和移动语义学之间联系 它们是正交的概念吗?

注意:这个问题不是关于比较值语义和移动语义的,因为很显然这两个概念不是“可比较的”。 这个问题是关于它们如何连接的,特别是(如@StoryTeller所说的)关于讨论(如何)的问题:

移动语义有助于促进更多使用值类型。

最初的搬迁建议中

复制与移动

C和C ++建立在复制语义上。 这是一件好事。 移动语义并不是试图取代复制语义,也不是以任何方式破坏它。 相反,该提议旨在增强复制语义。 普通的用户定义类可能是可复制且可移动的,一个或另一个,或两者都不是。

副本与移动之间的区别在于副本使源保持不变。 另一方面,移动会使源处于针对每种类型定义的不同状态。 源的状态可以不变,也可以根本不同。 唯一的要求是对象保持自一致状态(所有内部不变式仍完好无损)。 从客户端代码的角度来看,选择“移动”而不是“复制”意味着您不在乎源状态如何。

对于POD,移动和复制是相同的操作(直到机器指令级别)。

我想可能会增加一点,然后说:

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

受到霍华德答案的启发,我写了一篇有关该主题的文章 ,希望它能对也对此感到疑惑的人有所帮助。 我在这里复制/粘贴文章。

当我学习移动语义时,我总是有一种感觉,即使我非常了解这个概念,也无法将其适应C ++的全局。 移动语义并不像只是为了方便起见而存在的某种语法糖,它深深地影响了人们思考和编写C ++的方式,并已成为最重要的C ++习惯用法之一。 但是,嘿,C ++的池塘里已经充满了其他习语,当您引入移动语义时,它就会相互挤压。 移动语义是否破坏,增强或替代了其他习语? 我不知道,但我想找出答案。

价值语义学

值语义使我开始考虑这个问题。 由于C ++中名称为“语义学”的东西不多,我自然想到“也许值和移动语义学之间有联系吗?”。 事实证明,这不仅仅是联系,而是起源:

移动语义并不是试图取代复制语义,也不是以任何方式破坏它。 相反,该提议旨在增强复制语义。

- 移动语义学提案 ,2002年9月10日

也许您已经注意到它使用了“复制语义”一词,实际上,“值语义”和“复制语义”是同一件事,我将互换使用它们。

好,那么值语义是什么? isocpp讨论了整个页面 ,但基本上,值语义意味着赋值复制值 ,例如T b = a; 这就是定义,但是通常值语义只是意味着创建,使用,存储对象本身,传递,按值返回,而不是指针或引用

相反的概念是引用语义,其中赋值复制指针。 在参考语义中,重要的是身份,例如T& b = a; 我们必须记住, b是一个别名a ,而不是别的。 但是在值语义上,我们根本不关心身份,只关心对象1持有的值。 这是由于副本的性质所致,因为确保了副本为我们提供了两个具有相同值的独立对象,所以您无法确定哪个是源,也不会影响使用。

与其他语言(Java,C#,JavaScript)不同,C ++建立在值语义上。 默认情况下,赋值会逐位复制(如果不涉及用户定义的复制ctor),参数和返回值都是复制构造的(是的,我知道有RVO)。 在C ++中,保持值语义是一件好事。 一方面,它更安全,因为您无需担心悬空的指针和所有令人毛骨悚然的东西。 另一方面,它的速度更快,因为您的间接访问较少,请参阅此处获取官方说明。

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

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

想象一下,您有一辆汽车,它的内置发动机运行平稳。 一天,您在这辆车上安装了额外的V8发动机。 只要您有足够的燃油,V8发动机就能使您的汽车加速,这会让您感到高兴。

因此,汽车是价值语义,而V8引擎是移动语义。 在汽车上安装引擎不需要新汽车,它仍然是同一辆汽车,就像使用移动语义不会使您放弃值语义一样,因为您仍在操作对象本身而不是其引用或指针。 此外,由绑定首选项实现的如果可以的话,否则采取复制策略,这与选择引擎的方式完全相同,即如果可以(足够加油),则使用V8,否则退回到原始引擎。

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

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

编辑 :霍华德添加了一些值得一提的评论。 按照定义,移动语义的行为更像参考语义,因为移入和移出的对象不是独立的,因此在修改(通过移置构造或移置分配)移入的对象时,移出的对象是也修改了。 但是, 这并不重要-发生移动语义时,您无需关心move-from对象 ,它要么是纯右值(因此没有人引用原始对象),或者当程序员专门说“我不在乎复制后的原始值”(通过使用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 ctor。 通过移动(而非复制)构造地图值,可以节省大量时间。

您看不到的优化

霍华德·辛南特(Howard Hinnant)在演讲中曾提到,移动语义的思想来自优化std::vector 怎么样?

std::vector<T>对象基本上是指向堆上内部数据缓冲区的一组指针,例如begin()end() 由于为数据缓冲区分配了新的内存,因此复制向量非常昂贵。 当使用move而不是copy时,仅指针被复制并指向旧缓冲区。

此外,移动还可以增强矢量insert操作。 提案的矢量示例部分对此进行了说明。 假设我们有一个带有两个元素"AAAAA""BBBBB"std::vector<string> ,现在我们想在索引1处插入"CCCCC" 。假设该向量具有足够的容量,下图说明了使用复制与移动。


(来源: qnssl.com

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

移动语义和资源管理

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

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

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

您可能知道也可能不知道,在基本用例(RAII对象的生存期由于范围退出而终止)的基本用例之后,RAII的另一个名称称为范围绑定资源管理 (SBRM)。 还记得使用值语义的一项优势吗? 安全。 我们知道什么时候一个对象的生命周期的开始和结束,只要看一眼它的存储时间 ,和99%的时间,我们将在块范围,这使得它非常简单的找到它。 对于指针和引用而言,事情变得更加复杂,现在我们必须担心所引用或指向的对象是否已释放。 这很难,更糟糕的是,这些对象通常存在于与其指针和引用不同的范围内。

显而易见,为什么值语义与RAII融为一体-RAII将资源的生命周期绑定到对象的生命周期,并且通过值语义,您可以清楚地了解对象的生命周期。

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

尽管价值语义学和RAII似乎是完美的匹配,但实际上并非如此。 为什么? 从根本上说,因为资源与身份有关,而价值语义只关心价值。 您有一个开放的套接字,使用了非常套接字; 您有一个打开的文件,就使用该文件。 在资源管理的上下文中,没有东西具有相同的价值。 资源代表自己,具有独特的身份。

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

  • 使用原始指针;
  • 编写自己的可移动但不可复制的类(通常涉及私有复制ctor和诸如swapsplice类的操作);
  • 使用auto_ptr

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

缺乏统一的语法和语义来使通用代码能够移动任意对象(就像今天的通用代码可以复制任意对象一样)。

- 移动语义学提案

与提案中的上述陈述相比,我更喜欢这个答案

除了明显的效率优势外,这还为程序员提供了一种符合标准的方式,使对象可以移动但不能复制 可移动且不可复制的对象通过标准语言语义传达了资源所有权的明确界限……我的意思是, 移动语义现在是简洁地(其中包括)可移动但不可复制对象的一种标准方式。

上面的引用在解释移动语义对C ++中的资源所有权管理的意义方面做得很好。 资源自然应该是可移动的(“移动”是指可转移的),但不可复制,现在借助移动语义(实际上在语言级别上有很多更改可以支持它),有一种标准的方法可以做到这一点,并且有效率的。

价值语义学的重生

最后,我们能够讨论将语义带入价值语义的扩充的其他方面(性能之外)。

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

不用说std::unique_ptrstd::shared_ptr的重要性,在这里我想强调三点:

  • 他们遵循RAII;
  • 它们利用了移动语义的巨大优势(尤其是对于unique_ptr);
  • 它们有助于保持价值语义。

第三点,如果您已经读过零规则 ,那么您知道我在说什么。 EVER,不需要使用原始指针来管理资源,只需直接使用unique_ptr或将其存储为成员变量,就可以了。 转移资源所有权时,隐式构造的移动控制器可以很好地完成工作。 更好的是,当前规范确保将在最坏情况下(即不带省略号)的return语句中的命名值视为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的C ++博客

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM