注意:这个问题不是关于比较值语义和移动语义,因为这两个概念显然是不可比较的。这个问题是关于它们如何连接的,具体地说(就像@StoryTeller所说的),讨论如何:
移动语义有助于更多地使用值类型。
从原始移动提案中:
复制和移动
C和C++基于复制语义。这是一件好事。移动语义并不试图取代复制语义,也不以任何方式削弱它。相反,该提案旨在增强复制语义。一般的用户定义类可以是可复制和可移动的,其中一个,或两者都不是。
复制和移动的区别在于复制保留源对象的状态不变。而移动则使源对象处于状态——对于每种类型都定义不同。源对象的状态可能未改变,也可能彻底不同。唯一的要求是对象保持自我一致的状态(所有内部不变量仍然完整)。从客户端代码的角度来看,选择移动而不是复制意味着您不关心源对象的状态会发生什么。
对于POD(Plain Old Data)类型,移动和复制是相同的操作(甚至机器指令级别都相同)。
我猜可以补充说:
移动语义允许我们保持值语义,但在那些原始对象的值对程序逻辑不重要的情况下,同时获得引用语义的性能。
vector
更快的事实。凭借着在C++中分配和释放内存是最昂贵的操作之一的知识,将它们最小化似乎是易如反掌的事情。 - Howard Hinnant受Howard的答案启发,我写了一篇文章关于这个主题,希望能帮助到也在想这个问题的人。我在这里复制/粘贴了这篇文章。
当我学习移动语义时,我总是有一种感觉,即使我很了解这个概念,我也无法将其融入C++的大局中。移动语义不像某些语法糖那样仅存在于方便之中,它深刻地影响着人们思考和编写C++的方式,并成为最重要的C++习惯用语之一。但是,嘿,当你把移动语义扔进去时,C++的池塘已经充满了其他习惯用语,互相挤压就随之而来。移动语义是破坏、增强还是取代其他习惯用语?我不知道,但我想找出答案。
值语义是让我开始思考这个问题的原因。由于在C++中没有太多名为“语义”的东西,我自然地想到,“也许值语义和移动语义有一些联系?”结果,这不仅仅是联系,而是起源:
移动语义并非试图取代复制语义,也不会以任何方式削弱它。相反,该提案旨在增强复制语义。 - 移动语义提案,2002年9月10日T b = a;
。这是定义,但通常情况下,值语义只是指创建、使用、存储对象本身,按值传递、返回,而不是指针或引用。T& b = a;
,我们必须记住 b
是 a
的别名,而不是其他任何东西。但在值语义中,我们根本不关心标识,只关心对象1所持有的值。这是由于复制的性质带来的,因为复制确保给我们两个独立的对象,它们持有相同的值,你无法告诉哪一个是源对象,也不会影响使用。移动语义并非试图取代复制语义,它们完全兼容。我想出了一个比喻,我觉得很好地描述了它们之间的关系。
想象一下,你有一辆汽车,它内置的发动机运转顺畅。有一天,你在这辆车上安装了一个额外的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());
Handler
有一个move构造函数。通过移动(而不是复制)构造一个map值,可以节省大量时间。std::vector
。如何实现?
std::vector<T>
对象基本上是指向堆上内部数据缓冲区的一组指针,例如begin()
和end()
。由于为数据缓冲区分配新内存,复制向量是昂贵的。当使用移动而不是复制时,只有指针被复制并指向旧缓冲区。insert
操作的效率。这在提案的vector Example部分中有解释。假设我们有一个std::vector<string>
,其中包含两个元素"AAAAA"
和"BBBBB"
,现在我们想在索引1处插入"CCCCC"
。假设向量有足够的容量,以下图表演示了使用复制和移动进行插入的过程。图中展示的所有内容都在堆上,包括向量的数据缓冲区和每个元素字符串的数据缓冲区。使用复制时,必须复制str_b
的数据缓冲区,这涉及到缓冲区分配和释放。使用移动时,新地址上的新str_b
重用旧str_b
的数据缓冲区,不需要缓冲区分配或释放(正如Howard指出的那样,旧str_b
现在所指向的"data"是未指定的)。这带来了巨大的性能提升,但这意味着更多,因为现在可以将昂贵的对象存储到向量中,而不会牺牲性能,而以前必须存储指针。这也有助于扩展值语义的使用。
在著名文章零规则中,作者写道:
使用值语义对RAII至关重要,因为引用不影响其参照物的生命周期。
我发现这是讨论移动语义和资源管理相关性的好起点。
作为您可能已经知道或不知道的,RAII有另一个名字叫做“Scope-Bound Resource Management”(SBRM),这是基本用例,在该用例中,由于范围退出,RAII对象的生命周期结束。还记得使用值语义的优点吗?安全性。我们仅通过查看其存储期限就可以确定对象的生命周期何时开始和结束,99%的时间我们会在块作用域中找到它,这使得它非常简单。对于指针和引用,情况变得更加复杂,现在我们必须担心所引用或指向的对象是否已被释放。这很难,更糟糕的是,这些对象通常存在于与其指针和引用不同的范围内。第三点,如果您阅读过零规则,您就知道我在说什么。永远不要使用原始指针来管理资源,只需直接使用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::string
和std::vector
也是RAII包装器,它们管理的资源是堆内存。对于这些类,返回值仍然是首选。其他类似std::thread
或std::lock_guard
的东西,我不太确定,因为我还没有机会使用它们。
总之,通过利用智能指针,值语义现在真正地获得了与RAII的兼容性。在其核心,这是由移动语义驱动的。
到目前为止,我们已经讨论了很多概念,你可能感到不知所措,但我想传达的要点很简单:
我自己也是这个主题的学习者,所以请随时指出您认为错误的任何内容,我非常感激。
[1]: 这里的对象指的是“具有地址、类型并能够存储值的一块内存”,来自Andrzej's C++ blog。