代码之家  ›  专栏  ›  技术社区  ›  laike9m

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

  •  3
  • laike9m  · 技术社区  · 7 年前

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

    注意:这个问题不是关于比较值语义和移动语义,因为很明显这两个概念是不“可比的”。这个问题是关于他们是如何联系的,特别是关于讨论(如何):

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

    2 回复  |  直到 6 年前
        1
  •  9
  •   Howard Hinnant    7 年前

    original move proposal :

    复制vs移动

    C和C++是基于复制语义构建的。这是一件好事。移动 语义不是试图取代复制语义,也不是破坏 不管怎么说。相反,这一建议旨在增强复制语义。A. 一般用户定义的类可以是可复制的,也可以是可移动的,一个或多个 另一个,或者两者都没有。

    复制和移动的区别在于,复制会离开 源未更改。另一方面,移动会使源处于 为每种类型定义不同的状态。源的状态可能 保持不变,否则可能会完全不同。唯一的要求 对象保持自一致状态(所有内部 不变量仍然完好无损)。从客户端代码的角度来看, 选择“移动”而不是“复制”,意味着您不在乎会发生什么 到源的状态。

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

    我想有人可以补充说:

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

        2
  •  0
  •   Glorfindel Doug L.    5 年前

    受霍华德回答的启发,我写道 an article 关于这个话题,希望它能帮助那些同样对此感到疑惑的人。我把这篇文章复制/粘贴到这里。

    当我学习移动语义学时,我总是有一种感觉,即使我对这个概念非常熟悉,但我无法将它融入C++的大局中。移动语义不像是为了方便而存在的语法糖,它深刻地影响了人们思考和编写C++的方式,并已成为最重要的C++习语之一。但是,嘿,C++的池塘里已经充满了其他的习惯用法,当你加入移动语义时,就会产生相互挤压。move语义是否破坏、增强或取代了其他习语?我不知道,但我想知道。

    值语义

    价值语义学让我开始思考这个问题。由于C++中没有太多叫做“语义”的东西,我自然会想,“也许值语义和移动语义有一些联系?”。事实证明,这不仅仅是联系,而是起源:

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

    - Move Semantics Proposal ,2002年9月10日

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

    那么什么是值语义呢?isocpp具有 whole page 从根本上讲,价值语义学意味着 赋值复制值 喜欢 T b = a; .这是定义,但是 通常情况下,值语义只是指创建、使用、存储对象本身、传递、按值返回,而不是指针或引用

    相反的概念是引用语义,其中赋值复制指针。例如,在引用语义中,重要的是标识 T& b = a; ,我们必须记住 b 是的别名 a ,而不是其他任何东西。但在值语义中,我们根本不关心身份,我们只关心对象的值 1. 保持。这是由拷贝的性质带来的,因为一个拷贝被确保为我们提供两个具有相同值的独立对象,您无法区分哪一个是源,也不会影响使用。

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

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

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

    想象一下,你有一辆车,它带着内置的发动机运转平稳。有一天,你在这辆车上额外安装了一个V8引擎。只要你有足够的燃油,V8引擎就能加速你的汽车,这让你很高兴。

    因此,汽车是值语义,V8引擎是移动语义。在车上安装引擎并不需要新车,它仍然是同一辆车,就像使用移动语义不会让你放弃值语义一样,因为你仍然在操作对象本身,而不是它的引用或指针。此外 如果可以,请移动,否则请复制 战略,由 binding preferences ,这与选择发动机的方式完全相同,即如果可以(燃油充足),请使用V8,否则请使用原发动机。

    现在我们对霍华德·希南特(搬家提案的主要作者)的 answer 在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 具有移动向量。通过移动(而不是复制)-构造贴图值,可以节省大量时间。

    你看不到的优化

    Howard Hinnant在他的 talk 移动语义的思想来自于优化 std::vector 怎样

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

    更重要的是,移动还可以提升向量 insert 活动这在 vector Example 提案中的第节。假设我们有 std::vector<string> 有两个元素 "AAAAA" "BBBBB" ,现在我们要插入 "CCCCC" 在索引1处。假设向量有足够的容量,下图演示了使用“复制”与“移动”插入的过程。


    (来源: qnssl.com )

    图上显示的所有内容都在堆上,包括向量的数据缓冲区和每个元素字符串的数据缓冲区。有副本, str_b 必须复制的数据缓冲区,这涉及缓冲区分配,然后再释放。带move,old str\U b 的数据缓冲区被新的 str\U b 在新地址中,不需要缓冲区分配或释放(正如Howard指出的,旧的“数据” str\U b 现在指向未指定)。这带来了巨大的性能提升,但其意义不止于此,因为现在您可以将昂贵的对象存储到向量中,而不牺牲性能,而之前必须存储指针。这也有助于扩展值语义的使用。

    移动语义和资源管理

    在那篇著名的文章中 Rule of Zero ,作者写道:

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

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

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

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

    但是,资源是关于身份的

    虽然值语义和RAII似乎是完美的匹配,但实际上并非如此。为什么?从根本上讲,因为资源是关于身份的,而值语义只关心值。你有一个打开的插座,你使用的就是这个插座;你有一个打开的文件,你使用的就是这个文件。在资源管理的上下文中,没有具有相同价值的东西。资源用唯一标识表示自身。

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

    • 使用原始指针;
    • 编写他们自己的可移动但不可复制的类(通常涉及私有复制器和操作,如 swap splice );
    • 使用 auto_ptr

    这些解决方案旨在解决唯一所有权和所有权转移问题,但都存在一些弊端。我不会在这里谈论它,因为它在互联网上无处不在。我想说的是,即使没有移动语义,也可以进行资源所有权管理,只是它需要更多的代码,而且往往容易出错。

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

    - Move Semantics Proposal

    与提案中的上述声明相比,我喜欢这样 answer 更多信息:

    除了明显的效率优势外,这还为程序员提供了一种符合标准的方式来拥有 可移动但不可复制 。可移动且不可复制的对象通过标准语言语义传达了非常明确的资源所有权边界。我的观点是 移动语义现在是简洁地表达(除其他外)可移动但不可复制对象的标准方法。

    上面的引用很好地解释了移动语义对C++中的资源所有权管理意味着什么。资源自然应该是可移动的(我所说的“可移动”是指可转移的),但不可复制,现在在移动语义的帮助下(实际上在语言级别上有很多变化来支持它),有一种标准方法可以正确有效地做到这一点。

    价值语义学的重生

    最后,我们可以讨论扩展的另一个方面(除了性能),即移动语义带来的价值语义。

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

    不用说 std::unique_ptr std::shared_ptr ,在此我想强调三件事:

    • 他们追随RAII;
    • 它们极大地利用了移动语义(尤其是对于unique\u ptr);
    • 它们有助于保持值语义。

    第三点,如果你读过 零法则 ,你知道我在说什么。无需使用原始指针来管理资源,只需直接使用unique\u ptr或存储为成员变量,就可以了。在转移资源所有权时,隐式构造的move-ctor能够很好地完成这项工作。更妙的是,当前规范确保在最坏的情况下(即没有省略)返回语句中的命名值被视为右值。这意味着, 按值返回应该是unique\u 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\u ptr作为函数参数时,按值传递仍然是最佳选择。 如果时间允许,我可能会写一篇关于它的文章。

    除了智能指针, std::string 标准::矢量 也是RAII包装器,它们管理的资源是堆内存。对于这些类,仍然首选按值返回。我对其他事情不太确定,比如 std::thread std::lock_guard 因为我没有机会使用它们。

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

    总结

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

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

    我自己也是这个话题的学习者,所以请随时指出你认为错误的地方,我真的很感激。

    [1] :此处对象表示“ 一种具有地址、类型并能存储值的存储器 “,发件人 Andrzej's C++ blog