什么是移动语义学?

我刚听完软件工程广播播客采访Scott Meyers关于C++0x。大多数新功能对我来说都是有意义的,我现在对C++0x感到兴奋,除了一个。我还是不明白移动语义学…它到底是什么?

551874 次浏览

假设你有一个函数返回一个实质性对象:

Matrix multiply(const Matrix &a, const Matrix &b);

当你这样写代码时:

Matrix r = multiply(a, b);

然后一个普通的C++编译器将为multiply()的结果创建一个临时对象,调用复制构造函数初始化r,然后销毁临时返回值。C++0x中的移动语义学允许调用“移动构造函数”通过复制其内容初始化r,然后丢弃临时值而无需销毁它。

如果(可能就像上面的Matrix示例),被复制的对象在堆上分配额外的内存来存储其内部表示,这一点尤其重要。复制构造函数必须要么制作内部表示的完整副本,要么在内部使用引用计数和写时复制语义学。移动构造函数将单独离开堆内存,只是将指针复制到Matrix对象内部。

这就像复制语义学,但不是必须复制所有数据,而是从被“移动”的对象中窃取数据。

你知道复制语义学是什么意思吗?它意味着你有可复制的类型,对于用户定义的类型,你定义它要么显式编写复制构造函数和赋值操作符,要么编译器隐式生成它们。这将做一个副本。

移动语义学基本上是一种带有构造函数的用户定义类型,它接受一个非const的r值引用(使用&&(是的,两个&)的新类型引用),这被称为移动构造函数,赋值操作符也是如此。那么移动构造函数做了什么,它不是从源参数复制内存,而是从源参数将内存“移动”到目标。

那么std::向量就是一个例子,假设你创建了一个临时的std::向量,你从一个函数返回它,比如:

std::vector<foo> get_foos();

当函数返回时,你将有复制构造函数的开销,如果(它将在C++0x)std::向量有一个移动构造函数,而不是复制它,可以设置它的指针并将动态分配的内存“移动”到新实例。这有点像std::auto_ptr的所有权转移语义学。

如果你真的对动作语义学的深入解释感兴趣,我强烈建议你阅读关于它们的原始论文,“向C++语言添加移动语义支持的建议。

它非常容易理解和阅读,它为他们提供的好处提供了一个很好的案例。在WG21网站上还有其他关于移动语义学的最新论文,但这篇论文可能是最直接的,因为它从顶层视图处理事情,没有太多关于语言细节的坚韧不拔的细节。

我发现使用示例代码最容易理解移动语义学。让我们从一个非常简单的字符串类开始,它只保存一个指向堆分配内存块的指针:

#include <cstring>#include <algorithm>
class string{char* data;
public:
string(const char* p){size_t size = std::strlen(p) + 1;data = new char[size];std::memcpy(data, p, size);}

由于我们选择自己管理内存,我们需要遵循三原则。我将推迟编写赋值操作符,现在只实现析构函数和复制构造函数:

    ~string(){delete[] data;}
string(const string& that){size_t size = std::strlen(that.data) + 1;data = new char[size];std::memcpy(data, that.data, size);}

复制构造函数定义了复制字符串对象的含义。参数const string& that绑定到字符串类型的所有表达式,这允许您在以下示例中进行复制:

string a(x);                                    // Line 1string b(x + y);                                // Line 2string c(some_function_returning_a_string());   // Line 3

现在是对移动语义学的关键洞察。请注意,只有在我们复制x的第一行,这个深度复制才是真正必要的,因为我们可能想稍后检查x,如果x以某种方式发生了变化,我们会非常惊讶。你注意到我刚刚说x三次(如果你包括这句话,四次),每次都意味着完全相同的物体吗?我们把x这样的表达式称为“左值”。

第2行和第3行中的参数不是左值,而是右值,因为底层字符串对象没有名称,因此客户端无法在稍后的时间点再次检查它们。右值表示在下一个分号处被销毁的临时对象(更准确地说:在词汇上包含右值的全表达式的末尾)。这很重要,因为在bc的初始化期间,我们可以对源字符串做任何我们想做的事情,还有客户看不出有什么区别

C++0x引入了一种称为“右值引用”的新机制,允许我们通过函数重载检测右值参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在该构造函数中,只要我们将其保持在一些有效状态,我们就可以对源执行任何我们想要的操作:

    string(string&& that)   // string&& is an rvalue reference to a string{data = that.data;that.data = nullptr;}

我们在这里做了什么?我们没有深入复制堆数据,而是复制了指针,然后将原始指针设置为null(以防止源对象的析构函数的“删除[]”释放我们的“只是被盗的数据”)。实际上,我们“窃取”了最初属于源字符串的数据。同样,关键的见解是,在任何情况下客户端都无法检测到源已被修改。由于我们在这里没有真正复制,我们将此构造函数称为“移动构造函数”。它的工作是将资源从一个对象移动到另一个对象,而不是复制它们。

恭喜你,你现在了解了移动语义学的基础知识!让我们继续实现赋值操作符。如果你不熟悉复制和交换习语,学习它并回来,因为它是一个很棒的C++习语,与异常安全相关。

    string& operator=(string that){std::swap(data, that.data);return *this;}};

你可能会问:“右值引用在哪里?”我的回答是:)

请注意,我们传递了参数that按价值,因此that必须像任何其他字符串对象一样初始化。究竟如何初始化that?在C++98的旧日子里,答案可能是“由复制构造函数”。在C++0x中,编译器根据赋值操作符的参数是左值还是右值在复制构造函数和移动构造函数之间进行选择。

因此,如果你说a = b复制构造函数将初始化that(因为表达式b是左值),并且赋值操作符将内容与新创建的深度副本交换。这就是复制和交换习语的定义-制作副本,将内容与副本交换,然后通过离开范围来摆脱副本。这里没有什么新东西。

但是如果你说a = x + y移动构造函数将初始化that(因为表达式x + y是右值),所以不涉及深度复制,只有高效的移动。that仍然是一个独立于参数的对象,但它的构造很简单,因为堆数据不必复制,只需移动。没有必要复制它,因为x + y是右值,再次,可以从右值表示的字符串对象中移动。

总而言之,复制构造函数会进行深度复制,因为源必须保持不变。另一方面,移动构造函数可以复制指针,然后将源中的指针设置为null。以这种方式“取消”源对象是可以的,因为客户端无法再次检查对象。

我希望这个例子能让你明白要点。还有很多关于右值引用和移动语义学的内容,我故意省略了这些内容以保持简单。如果你想了解更多细节,请参阅我的补充答复

当没有人再需要源值时,移动语义学大约是传输资源而不是复制它们

在C++03中,对象经常被复制,只是在任何代码再次使用该值之前被销毁或赋值。例如,当你从函数按值返回时——除非RVO启动——你返回的值被复制到调用者的堆栈帧中,然后它超出作用域并被销毁。这只是许多例子中的一个:参见源对象是临时对象时的按值传递,像sort这样只是重新排列项目的算法,当capacity()被超过时在vector中重新分配,等等。

当这样的复制/销毁对是昂贵的时,这通常是因为该对象拥有一些重量级资源。例如,vector<string>可能拥有一个动态分配的内存块,其中包含string对象的数组,每个对象都有自己的动态内存。复制这样的对象是昂贵的:你必须为源中的每个动态分配的块分配新内存,并复制所有的值。然后你需要释放所有刚刚复制的内存。然而,移动一个大的vector<string>意味着只需将几个指针(引用动态内存块)复制到目标并在源中将它们归零。

我的第一个答案是一个极其简化的移动语义学介绍,为了保持简单,许多细节都被故意遗漏了。然而,语义学还有很多东西要做,我认为是时候用第二个答案来填补空白了。第一个答案已经很陈旧了,直接用完全不同的文字来代替是很不合适的。我觉得还是可以用它作为第一个引言。如果你想更深入地挖掘,请继续阅读:)

Stephan T. Lavavej花时间提供了宝贵的反馈。非常感谢Stephan!

导言

移动语义学允许一个对象在某些条件下获得其他对象外部资源的所有权。这在两个方面很重要:

  1. 将昂贵的副本变成廉价的移动。参见我的第一个答案的例子。请注意,如果一个对象不管理至少一个外部资源(直接或通过其成员对象间接),移动语义学不会比复制语义学提供任何优势。在这种情况下,复制一个对象和移动一个对象意味着完全相同的事情:

    class cannot_benefit_from_move_semantics{int a;        // moving an int means copying an intfloat b;      // moving a float means copying a floatdouble c;     // moving a double means copying a doublechar d[64];   // moving a char array means copying a char array
    // ...};
  2. Implementing safe "move-only" types; that is, types for which copying does not make sense, but moving does. Examples include locks, file handles, and smart pointers with unique ownership semantics. Note: This answer discusses std::auto_ptr, a deprecated C++98 standard library template, which was replaced by std::unique_ptr in C++11. Intermediate C++ programmers are probably at least somewhat familiar with std::auto_ptr, and because of the "move semantics" it displays, it seems like a good starting point for discussing move semantics in C++11. YMMV.

What is a move?

The C++98 standard library offers a smart pointer with unique ownership semantics called std::auto_ptr<T>. In case you are unfamiliar with auto_ptr, its purpose is to guarantee that a dynamically allocated object is always released, even in the face of exceptions:

{std::auto_ptr<Shape> a(new Triangle);// ...// arbitrary code, could throw exceptions// ...}   // <--- when a goes out of scope, the triangle is deleted automatically

auto_ptr的不同寻常之处在于它的“复制”行为:

auto_ptr<Shape> a(new Triangle);
+---------------+| triangle data |+---------------+^|||+-----|---+|   +-|-+ |a | p | | | ||   +---+ |+---------+
auto_ptr<Shape> b(a);
+---------------+| triangle data |+---------------+^|+----------------------+|+---------+            +-----|---+|   +---+ |            |   +-|-+ |a | p |   | |          b | p | | | ||   +---+ |            |   +---+ |+---------+            +---------+

注意ba的初始化是如何没有复制三角形的,而是将三角形的所有权从a转移到b。我们也说“a搬进b”或“三角形是a0来自aa1b”。这听起来可能很混乱,因为三角形本身总是停留在内存中的同一个地方。

移动一个对象意味着将它管理的某些资源的所有权转移到另一个对象。

auto_ptr的复制构造函数可能看起来像这样(有点简化):

auto_ptr(auto_ptr& source)   // note the missing const{p = source.p;source.p = 0;   // now the source no longer owns the object}

危险和无害的动作

auto_ptr的危险之处在于,语法上看起来像副本的东西实际上是一个移动。试图在从auto_ptr移动的情况下调用成员函数会调用未定义的行为,所以你必须非常小心,不要在auto_ptr从以下位置移动后使用它:

auto_ptr<Shape> a(new Triangle);   // create triangleauto_ptr<Shape> b(a);              // move a into bdouble area = a->area();           // undefined behavior

但是auto_ptr并不是总是危险。工厂函数是auto_ptr的完美用例:

auto_ptr<Shape> make_triangle(){return auto_ptr<Shape>(new Triangle);}
auto_ptr<Shape> c(make_triangle());      // move temporary into cdouble area = make_triangle()->area();   // perfectly safe

注意两个例子如何遵循相同的语法模式:

auto_ptr<Shape> variable(expression);double area = expression->area();

然而,其中一个调用了未定义的行为,而另一个没有。那么表达式amake_triangle()有什么区别呢?它们不是同一类型的吗?的确如此,但它们的价值类别不同。

价值类别

显然,表达式a表示auto_ptr变量,表达式make_triangle()表示调用一个按值返回auto_ptr的函数,因此每次调用时都会创建一个新的临时auto_ptr对象,这两者之间肯定有一些深刻的区别。

从诸如a这样的左值移动是危险的,因为我们以后可能会尝试通过a调用成员函数,调用未定义的行为。另一方面,从诸如make_triangle()这样的右值移动是完全安全的,因为在复制构造函数完成其工作后,我们不能再次使用临时。没有表示所述临时的表达式;如果我们简单地再次写入make_triangle(),我们会得到一个不同临时。事实上,从下一行移动的临时已经消失了:

auto_ptr<Shape> c(make_triangle());^ the moved-from temporary dies right here

请注意,字母lr在赋值的左侧和右侧都有历史起源。这在C++中不再适用,因为有些左值不能出现在赋值的左侧(如数组或没有赋值操作符的用户定义类型),还有一些右值可以(类类型的所有右值都有赋值操作符)。

类类型的右值是一个表达式,其计算创建一个临时对象。在正常情况下,同一范围内没有其他表达式表示相同的临时对象。

右值引用

我们现在知道从左值移动是潜在危险的,但从右值移动是无害的。如果C++有语言支持来区分左值参数和右值参数,我们可以完全禁止从左值移动,或者至少在调用站点从左值明确移动,这样我们就不会再意外移动。

C++11对这个问题的回答是右值引用。右值引用是一种只绑定到右值的新引用,语法是X&&。好的旧引用X&现在被称为左值引用。(注意X&&没有对引用的引用;C++中没有这样的东西。)

如果我们将const放入混合中,我们已经有四种不同类型的引用。它们可以绑定到哪种类型的X表达式?

            lvalue   const lvalue   rvalue   const rvalue---------------------------------------------------------X&          yesconst X&    yes      yes            yes      yesX&&                                 yesconst X&&                           yes      yes

在实践中,您可以忘记const X&&。限制从右值读取不是很有用。

右值引用X&&是一种仅绑定到右值的新引用。

隐式转换

右值引用经历了几个版本。从2.1版开始,右值引用X&&也绑定到不同类型Y的所有值类别,前提是从YX的隐式转换。在这种情况下,创建了X类型的临时,右值引用绑定到该临时:

void some_function(std::string&& r);
some_function("hello world");

在上面的示例中,"hello world"是类型const char[12]的左值。由于存在从const char[12]const char*std::string的隐式转换,因此创建了类型std::string的临时,并且r绑定到该临时。这是右值(表达式)和临时值(对象)之间的区别有点模糊的情况之一。

移动构造函数

带有X&&参数的函数的一个有用示例是移动构造函数X::X(X&& source)。它的目的是将托管资源的所有权从源转移到当前对象。

在C++11中,std::auto_ptr<T>std::unique_ptr<T>替换,它利用了右值引用。我将开发并讨论unique_ptr的简化版本。首先,我们封装一个原始指针并重载运算符->*,所以我们的类感觉像一个指针:

template<typename T>class unique_ptr{T* ptr;
public:
T* operator->() const{return ptr;}
T& operator*() const{return *ptr;}

构造函数获取对象的所有权,析构函数删除它:

    explicit unique_ptr(T* p = nullptr){ptr = p;}
~unique_ptr(){delete ptr;}

现在是有趣的部分,移动构造函数:

    unique_ptr(unique_ptr&& source)   // note the rvalue reference{ptr = source.ptr;source.ptr = nullptr;}

这个移动构造函数完全做了auto_ptr复制构造函数所做的事情,但它只能提供右值:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a);                 // errorunique_ptr<Shape> c(make_triangle());   // okay

第二行编译失败,因为a是左值,但参数unique_ptr&& source只能绑定到右值。这正是我们想要的;危险的移动永远不应该是隐式的。第三行编译得很好,因为make_triangle()是右值。移动构造函数将所有权从临时转移到c。同样,这正是我们想要的。

移动构造函数将托管资源的所有权转移到当前对象中。

移动赋值操作符

最后一个缺失的部分是移动赋值操作符。它的工作是释放旧资源并从其参数中获取新资源:

    unique_ptr& operator=(unique_ptr&& source)   // note the rvalue reference{if (this != &source)    // beware of self-assignment{delete ptr;         // release the old resource
ptr = source.ptr;   // acquire the new resourcesource.ptr = nullptr;}return *this;}};

请注意移动赋值操作符的这个实现是如何复制析构函数和移动构造函数的逻辑的。你熟悉复制和交换习惯用法吗?它也可以作为移动和交换习惯用法应用于移动语义学:

    unique_ptr& operator=(unique_ptr source)   // note the missing reference{std::swap(ptr, source.ptr);return *this;}};

现在source是类型unique_ptr的变量,它将由移动构造函数初始化;也就是说,参数将被移动到参数中。参数仍然需要是右值,因为移动构造函数本身有一个右值引用参数。当控制流到达operator=的右括号时,source超出范围,自动释放旧资源。

移动赋值操作符将托管资源的所有权转移到当前对象,释放旧资源。移动和交换习惯用法简化了实现。

从左值移动

有时,我们想从左值移动。也就是说,有时我们希望编译器将左值视为右值,因此它可以调用移动构造函数,即使它可能不安全。为此,C++11在标头<utility>中提供了一个名为std::move的标准库函数模板。这个名字有点不幸,因为std::move只是将左值转换为右值;没有本身移动任何东西。它只是使移动。也许它应该被命名为std::cast_to_rvaluestd::enable_move,但我们现在被这个名字困住了。

以下是从左值显式移动的方式:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a);              // still an errorunique_ptr<Shape> c(std::move(a));   // okay

请注意,在第三行之后,a不再拥有三角形。没关系,因为通过明确编写std::move(a),我们明确了我们的意图:“亲爱的构造器,为了初始化c,你可以对a做任何你想做的事情;我不再关心a了。随意使用a。”

std::move(some_lvalue)将左值转换为右值,从而启用后续移动。

x值

请注意,即使std::move(a)是一个右值,它的求值也确实没有创建了一个临时对象。这个难题迫使委员会引入了第三个值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也被称为xvalue(可扩展值)。传统的右值被重命名为右值(纯右值)。

右值和x值都是右值。X值和左值都是价值观(广义左值)。使用图表更容易掌握关系:

        expressions/     \/       \/         \glvalues   rvalues/  \       /  \/    \     /    \/      \   /      \lvalues   xvalues   prvalues

请注意,只有x值是真正新的;其余的只是由于重命名和分组。

C++98个右值在C++11中称为右值。

移出功能

到目前为止,我们已经看到了局部变量和函数参数的移动。但是也可以朝着相反的方向移动。如果函数按值返回,调用站点的某个对象(可能是局部变量或临时对象,但可以是任何类型的对象)将使用return语句之后的表达式作为移动构造函数的参数进行初始化:

unique_ptr<Shape> make_triangle(){return unique_ptr<Shape>(new Triangle);}          \-----------------------------/|| temporary is moved into c|vunique_ptr<Shape> c(make_triangle());

也许令人惊讶的是,自动对象(未声明为static的局部变量)也可以隐含移出函数:

unique_ptr<Shape> make_square(){unique_ptr<Shape> result(new Square);return result;   // note the missing std::move}

为什么移动构造函数接受左值result作为参数?result的作用域即将结束,它将在堆栈展开时被销毁。之后没有人可能抱怨result以某种方式发生了变化;当控制流回到调用者时,result不再存在了!出于这个原因,C++11有一个特殊的规则,允许从函数返回自动对象,而无需编写std::move。事实上,你应该从未使用std::move将自动对象移出函数,因为这抑制了“命名返回值优化”(NRVO)。

永远不要使用std::move将自动对象移出函数。

请注意,在这两个工厂函数中,返回类型都是值,而不是右值引用。右值引用仍然是引用,一如既往,你不应该返回对自动对象的引用;如果你欺骗编译器接受你的代码,调用者最终会得到一个悬空引用,如下所示:

unique_ptr<Shape>&& flawed_attempt()   // DO NOT DO THIS!{unique_ptr<Shape> very_bad_idea(new Square);return std::move(very_bad_idea);   // WRONG!}

永远不要通过右值引用返回自动对象。移动仅由移动构造函数执行,而不是由std::move执行,也不是仅仅将右值绑定到右值引用。

移动到成员

迟早,你会写这样的代码:

class Foo{unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter): member(parameter)   // error{}};

基本上,编译器会抱怨parameter是左值。如果你看它的类型,你会看到一个右值引用,但右值引用只是意味着“绑定到右值的引用”;没有确实意味着引用本身是一个右值!事实上,parameter只是一个有名字的普通变量。你可以在构造函数的主体中随意使用parameter,它总是表示同一个对象。隐式从它移动会很危险,因此语言禁止它。

命名的右值引用是左值,就像任何其他变量一样。

解决方案是手动启用移动:

class Foo{unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter): member(std::move(parameter))   // note the std::move{}};

可能会说parametermember初始化之后就不再使用了。为什么没有像返回值一样静默插入std::move的特殊规则?可能是因为这会给编译器实现者带来太大负担。例如,如果构造函数主体在另一个翻译单元中怎么办?相比之下,返回值规则只需检查符号表,以确定return关键字后的标识符是否表示自动对象。

您还可以按值传递parameter。对于像unique_ptr这样的仅移动类型,似乎还没有既定的习惯用法。就个人而言,我更喜欢值传递,因为它会减少界面中的混乱。

特殊成员职能

C++98隐式声明了三个特殊的成员函数:复制构造函数、复制赋值操作符和析构函数。

X::X(const X&);              // copy constructorX& X::operator=(const X&);   // copy assignment operatorX::~X();                     // destructor

右值引用经历了几个版本。从3.0版开始,C++11按需声明了两个额外的特殊成员函数:移动构造函数和移动赋值操作符。请注意,VC10和VC11都不符合3.0版,因此您必须自己实现它们。

X::X(X&&);                   // move constructorX& X::operator=(X&&);        // move assignment operator

这两个新的特殊成员函数只有在没有手动声明特殊成员函数的情况下才会隐式声明。此外,如果您声明了自己的移动构造函数或移动赋值操作符,复制构造函数和复制赋值操作符都不会隐式声明。

这些规则在实践中意味着什么?

如果你写了一个没有非托管资源的类,没有必要自己声明五个特殊成员函数中的任何一个,你将免费获得正确的复制语义学和移动语义学。否则,你将不得不自己实现特殊成员函数。当然,如果你的类没有从移动语义学中受益,就没有必要实现特殊的移动操作。

请注意,复制赋值操作符和移动赋值操作符可以融合为一个统一的赋值操作符,按值获取其参数:

X& X::operator=(X source)    // unified assignment operator{swap(source);            // see my first answer for an explanationreturn *this;}

这样,要实现的特殊成员函数的数量从5个减少到4个。这里需要在异常安全和效率之间进行权衡,但我不是这个问题的专家。

转发引用(以前称为普遍参考

考虑以下函数模板:

template<typename T>void foo(T&&);

你可能希望T&&只绑定到右值,因为乍一看,它看起来像一个右值引用。不过,事实证明,T&&也绑定到左值:

foo(make_triangle());   // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a);                 // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

如果参数是类型X的右值,则推导出TX,因此T&&表示X&&。这是任何人都会想到的。但是如果参数是类型X的左值,由于特殊规则,T被推导出为X&,因此T&&的意思类似于X& &&。但是由于C++仍然没有引用引用的概念,类型X& &&崩溃X&。这可能听起来令人困惑和无用,但是引用折叠对于完全转发是必不可少的(这里不会讨论)。

T&&不是右值引用,而是转发引用。它还绑定到左值,在这种情况下TT&&都是左值引用。

如果您想将函数模板约束为右值,您可以将SFINAE与类型特征结合起来:

#include <type_traits>
template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

实施搬迁

现在您了解了引用折叠,以下是std::move的实现方式:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){return static_cast<typename std::remove_reference<T>::type&&>(t);}

如您所见,move接受任何类型的参数,这要归功于转发引用T&&,并且它返回一个右值引用。std::remove_reference<T>::type元函数调用是必要的,因为否则,对于类型X的左值,返回类型将是X& &&,这将折叠成X&。由于t始终是左值(请记住,命名的右值引用是左值),但我们想绑定t到右值引用,我们必须显式地将t转换为正确的返回类型。返回右值引用的函数调用本身就是一个xvalue。现在你知道xvalue来自哪里了;)

返回右值引用(如std::move)的函数调用是xvalue。

请注意,在此示例中通过右值引用返回是很好的,因为t不表示自动对象,而是调用者传入的对象。

简单(实用)术语:

复制对象意味着复制其“静态”成员并为其动态对象调用new运算符。对吧?

class A{int i, *p;
public:A(const A& a) : i(a.i), p(new int(*a.p)) {}~A() { delete p; }};

然而,对于移动对象(我重复一遍,从实际的角度来看)意味着只复制动态对象的指针,而不是创建新对象。

但是,这不是危险吗?当然,你可以两次破坏动态对象(分割错误)。因此,为了避免这种情况,你应该使源指针“无效”以避免两次破坏它们:

class A{int i, *p;
public:// Movement of an object inside a copy constructor.A(const A& a) : i(a.i), p(a.p){a.p = nullptr; // pointer invalidated.}
~A() { delete p; }// Deleting NULL, 0 or nullptr (address 0x0) is safe.};

好的,但是如果我移动一个对象,源对象就变得无用了,不是吗?当然,但在某些情况下,这非常有用。最明显的是当我用匿名对象调用一个函数时(临时的,右值的对象,…,你可以用不同的名字调用它):

void heavyFunction(HeavyType());

在这种情况下,创建一个匿名对象,然后复制到函数参数,然后删除。所以,这里最好移动对象,因为你不需要匿名对象,你可以节省时间和内存。

这就引出了“右值”引用的概念。它们存在于C++11中,只是为了检测接收到的对象是否匿名。我想你已经知道“左值”是一个可分配的实体(=运算符的左侧部分),所以你需要对对象的命名引用才能充当左值。右值正好相反,一个没有命名引用的对象。正因为如此,匿名对象和右值是同义词。所以:

class A{int i, *p;
public:// CopyA(const A& a) : i(a.i), p(new int(*a.p)) {}
// Movement (&& means "rvalue reference to")A(A&& a) : i(a.i), p(a.p){a.p = nullptr;}
~A() { delete p; }};

在这种情况下,当应该“复制”类型A的对象时,编译器会根据传递的对象是否命名而创建左值引用或右值引用。如果没有,则调用您的移动构造函数,您知道该对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。

重要的是要记住“静态”对象总是被复制的。没有办法“移动”静态对象(堆栈中的对象而不是堆上的对象)。因此,当一个对象没有动态成员(直接或间接)时,“移动”/“复制”的区别是无关紧要的。

如果你的对象很复杂,析构函数有其他次要效果,比如调用库的函数,调用其他全局函数或其他什么,也许用标志来表示运动更好:

class Heavy{bool b_moved;// staff
public:A(const A& a) { /* definition */ }A(A&& a) : // initialization list{a.b_moved = true;}
~A() { if (!b_moved) /* destruct object */ }};

因此,您的代码更短(您不需要为每个动态成员执行nullptr赋值)并且更通用。

其他典型问题:A&&const A&&有什么区别?当然,在第一种情况下,你可以修改对象,第二种情况下不能,但是,实际意义?在第二种情况下,你不能修改它,所以你没有办法使对象无效(除了使用可变标志或类似的东西),并且复制构造函数没有实际区别。

什么是完全转发?重要的是要知道“右值引用”是对“调用者作用域”中命名对象的引用。但在实际作用域中,右值引用是对象的名称,因此,它充当命名对象。如果您将右值引用传递给另一个函数,您将传递一个命名对象,因此,该对象不会像临时对象一样被接收。

void some_function(A&& a){other_function(a);}

对象a将被复制到other_function的实际参数中。如果您希望对象a继续被视为临时对象,您应该使用std::move函数:

other_function(std::move(a));

使用这一行,std::movea转换为右值,other_function将接收对象作为未命名对象。当然,如果other_function没有特定的重载来处理未命名对象,这种区别并不重要。

这是完美转发吗?不是,但我们非常接近。完美转发只对使用模板有用,目的是说:如果我需要将对象传递给另一个函数,我需要如果我收到一个命名对象,该对象将作为命名对象传递,当不是时,我想像未命名对象一样传递它:

template<typename T>void some_function(T&& a){other_function(std::forward<T>(a));}

这是一个使用完美转发的原型函数的签名,在C++11中通过std::forward实现。这个函数利用了模板实例化的一些规则:

 `A& && == A&``A&& && == A&&`

因此,如果T是对A的左值引用(A1=A&),则a也是(A2&&=>A&)。如果T是对A的右值引用,则a也是(A&&&&=>A&&)。在这两种情况下,a都是实际作用域中的命名对象,但从调用者作用域的角度来看,T包含其“引用类型”的信息。此信息(T)作为模板参数传递给forward,并且根据T的类型移动'a'。

为了说明对移动语义学的需求,让我们考虑这个没有移动语义学的例子:

这是一个函数,它接受类型T的对象并返回相同类型T的对象:

T f(T o) { return o; }//^^^ new object constructed

上述函数使用按值调用,这意味着当调用此函数时,对象必须是建造才能被函数使用。
因为函数也是按值返回,所以为返回值构造了另一个新对象:

T b = f(a);//^ new object constructed

两个已经构造了新对象,其中一个是仅在函数持续时间内使用的临时对象。

当从返回值创建新对象时,调用复制构造函数复制将临时对象的内容复制到新对象b。函数完成后,函数中使用的临时对象超出范围并被销毁。


现在,让我们考虑一下复制构造函数的作用。

它必须首先初始化对象,然后将所有相关数据从旧对象复制到新对象。
根据类的不同,也许它是一个包含非常多数据的容器,那么它可以代表很多时间内存使用

// Copy constructorT::T(T &old) {copy_data(m_a, old.m_a);copy_data(m_b, old.m_b);copy_data(m_c, old.m_c);}

使用移动语义学,现在可以通过简单地移动数据而不是复制来减少大部分工作的不愉快。

// Move constructorT::T(T &&old) noexcept {m_a = std::move(old.m_a);m_b = std::move(old.m_b);m_c = std::move(old.m_c);}

移动数据涉及将数据与新对象重新关联。和没有复制发生

这是通过rvalue引用完成的。
rvalue引用的工作原理与lvalue引用非常相似,但有一个重要区别:
右值引用可以移动左值不能。

cppreference.com

为了使强大的异常保证成为可能,用户定义的移动构造函数不应该抛出异常。事实上,当需要重新定位容器元素时,标准容器通常依赖d::move_if_noexcept在移动和复制之间做出选择。如果同时提供了复制和移动构造函数,则重载解析会在参数为右值时选择移动构造函数(例如无名临时的纯右值或例如std::移动的结果的xvalue),并在参数为左值时选择复制构造函数(命名对象或返回左值引用的函数/运算符)。如果仅提供复制构造函数,则所有参数类别都会选择它(只要它需要对const的引用,因为右值可以绑定到const引用),这使得在移动不可用时复制移动的后备。在许多情况下,移动构造函数会被优化,即使它们会产生可观察到的副作用,请参阅复制省略。当构造函数将右值引用作为参数时,它被称为“移动构造函数”。它没有义务移动任何东西,类不需要移动资源,并且“移动构造函数”可能无法移动资源,因为在允许(但可能不合理)的情况下,参数是常量右值引用(const T&&)。

我写这篇文章是为了确保我正确地理解它。

移动语义学的创建是为了避免对大对象的不必要复制。Bjarne Stroustrup在他的书《C++编程语言》中使用了两个默认发生不必要复制的例子:一个是两个大对象的交换,另一个是从方法返回一个大对象。

交换两个大对象通常涉及将第一个对象复制到临时对象,将第二个对象复制到第一个对象,并将临时对象复制到第二个对象。对于内置类型,这非常快,但对于大型对象,这三个副本可能需要大量时间。“移动赋值”允许程序员覆盖默认的复制行为,而是交换对对象的引用,这意味着根本没有复制,交换操作要快得多。可以通过调用std::移动()方法调用移动赋值。

默认情况下,从方法返回对象涉及在调用者可访问的位置复制本地对象及其关联数据(因为调用者无法访问本地对象,并且在方法完成后消失)。当返回内置类型时,此操作非常快,但如果返回大对象,这可能需要很长时间。移动构造函数允许程序员覆盖此默认行为,并通过将返回给调用者的对象指向与本地对象关联的堆数据来“重用”与本地对象关联的堆数据。因此不需要复制。

在不允许创建本地对象(即堆栈上的对象)的语言中,这些类型的问题不会发生,因为所有对象都在堆上分配,并且总是通过引用访问。

这是Bjarne Stroustrup的《C++编程语言》一书中的一个答案。如果您不想看视频,可以查看下面的文本:

考虑这个片段。从运算符+返回涉及将结果从局部变量res复制到调用者可以访问它的地方。

Vector operator+(const Vector& a, const Vector& b){if (a.size()!=b.size())throw Vector_siz e_mismatch{};Vector res(a.size());for (int i=0; i!=a.size(); ++i)res[i]=a[i]+b[i];return res;}

我们并不是真的想要复制;我们只是想从函数中获取结果。所以我们需要移动Vector而不是复制它。我们可以将移动构造函数定义如下:

class Vector {// ...Vector(const Vector& a); // copy constructorVector& operator=(const Vector& a); // copy assignmentVector(Vector&& a); // move constructorVector& operator=(Vector&& a); // move assignment};
Vector::Vector(Vector&& a):elem{a.elem}, // "grab the elements" from asz{a.sz}{a.elem = nullptr; // now a has no elementsa.sz = 0;}

&&表示“右值引用”,是我们可以绑定右值的引用。“右值”旨在补充“左值”,大致意思是“可以出现在赋值左侧的东西”。所以右值大致意思是“不能赋值的值”,比如函数调用返回的整数,以及向量运算符+()中的res局部变量。

现在,语句return res;不会复制!