什么是移动语义?

时间:2010-06-23 22:46:47

标签: c++ c++-faq c++11 move-semantics

我刚刚收听了有关podcast interview with Scott Meyers的软件工程广播C++0x。大多数新功能对我来说都很有意义,我现在对C ++ 0x感到兴奋,除了一个。我仍然没有得到移动语义 ......究竟是什么?

11 个答案:

答案 0 :(得分:2289)

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

#include <cstring>
#include <algorithm>

class string
{
    char* data;

public:

    string(const char* p)
    {
        size_t size = strlen(p) + 1;
        data = new char[size];
        memcpy(data, p, size);
    }

由于我们自己选择管理内存,因此我们需要遵循rule of three。我将推迟编写赋值运算符,现在只实现析构函数和复制构造函数:

    ~string()
    {
        delete[] data;
    }

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

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

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

现在是移动语义的关键洞察力。请注意,仅在我们复制x的第一行中,此深层副本才真正必要,因为我们可能希望稍后检查x,如果x以某种方式发生更改,将会非常惊讶。您是否注意到我刚刚说过x三次(如果包含此句子,则为四次)并且每次都表示完全相同的对象?我们称之为x“lvalues”等表达式。

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

C ++ 0x引入了一种名为“rvalue reference”的新机制,其中包括: 允许我们通过函数重载检测rvalue参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在构造函数中,只要我们将它留在某些有效状态中,我们就可以我们想要的任何内容

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

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

恭喜,您现在了解移动语义的基础知识!让我们继续实现赋值运算符。如果您不熟悉copy and swap idiom,请学习它并返回,因为它是一个与异常安全相关的令人敬畏的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是一个右值,再次,可以从rvalues表示的字符串对象移动。

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

我希望这个例子得到了重点。 rvalue引用和移动语义还有很多,我故意省略它以保持简单。如果您想了解更多详情,请参阅my supplementary answer

答案 1 :(得分:74)

移动语义基于 右值引用
右值是一个临时对象,它将在表达式的末尾被销毁。在当前的C ++中,rvalues仅绑定到const引用。 C ++ 1x将允许非const右值引用,拼写为T&&,它们是对右值对象的引用。
由于rvalue将在表达式结尾处死亡,因此您可以窃取其数据。您可以其数据移动到其他对象中,而不是复制到另一个对象中。

class X {
public: 
  X(X&& rhs) // ctor taking an rvalue reference, so-called move-ctor
    : data_()
  {
     // since 'x' is an rvalue object, we can steal its data
     this->swap(std::move(rhs));
     // this will leave rhs with the empty data
  }
  void swap(X&& rhs);
  // ... 
};

// ...

X f();

X x = f(); // f() returns result as rvalue, so this calls move-ctor

在上面的代码中,对于旧编译器,使用f()副本将x 复制 的结果复制到X构造函数。如果你的编译器支持移动语义而X有一个移动构造函数,则会调用它。由于它的rhs参数是 rvalue ,我们知道它不再需要它,我们可以窃取它的价值。
因此,从f()返回到x的未命名临时值 已移动 x的数据初始化为空X,被移入临时,在分配后将被销毁)。

答案 2 :(得分:58)

假设您有一个返回实体对象的函数:

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

当你编写这样的代码时:

Matrix r = multiply(a, b);

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

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

答案 3 :(得分:30)

如果您真的对移动语义的深入解释感兴趣,我强烈建议您阅读原始论文,"A Proposal to Add Move Semantics Support to the C++ Language."

它非常易于阅读,并且可以很好地证明它们提供的好处。还有其他一些关于移动语义的最新和最新的论文可以在the WG21 website上找到,但是这个可能是最直接的,因为它从顶层视图接近事物并且没有深入到坚韧不拔的语言中的信息。

答案 4 :(得分:26)

当没有人需要源值时,

移动语义是关于传输资源而不是复制资源

在C ++ 03中,对象经常被复制,只有在任何代码再次使用该值之前才被销毁或分配。例如,当您从函数返回值时 - 除非RVO启动 - 您返回的值将被复制到调用者的堆栈帧,然后它将超出范围并被销毁。这只是众多示例中的一个:请参阅源对象是临时的传递值,sort等只重新排列项目的算法,当vector超出capacity()时重新分配等等。

当这样的复制/破坏对很昂贵时,通常是因为该对象拥有一些重量级资源。例如,vector<string>可能拥有一个动态分配的内存块,其中包含string个对象的数组,每个对象都有自己的动态内存。复制此类对象的代价很​​高:您必须为源中的每个动态分配的块分配新内存,并复制所有值。 然后你需要释放你刚才复制的所有内存。但是,移动vector<string>意味着只需将几个指针(指向动态内存块)复制到目标,并将其归零。

答案 5 :(得分:23)

简单(实用)术语:

复制对象意味着复制其“静态”成员并为其动态对象调用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. 
};

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

void heavyFunction(HeavyType());

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

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

class A
{
   int i, *p;

public:
   // Copy
   A(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的对象应该被“复制”时,编译器会根据传入的对象是否被命名来创建左值引用或右值引用。如果没有,你的move-constructor就被调用,你知道该对象是暂时的,你可以移动它的动态对象而不是复制它们,节省空间和内存。

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

如果你的对象很复杂并且析构函数有其他的辅助效果,比如调用库的函数,调用其他全局函数或其它函数,最好用一个标志来表示一个运动:

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&&之间有什么区别?当然,在第一种情况下,你可以修改对象而在第二种情况下,但是,实际意义?在第二种情况下,您无法修改它,因此您无法使对象无效(除了带有可变标志或类似的东西),并且复制构造函数没有实际区别。

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

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

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

other_function(std::move(a));

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

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

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

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

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

因此,如果TA的左值引用( T = A&amp;),a也是{ A&amp; &amp;&amp; =&gt; A&amp;)。如果T是对A的右值引用,a也是(A&amp;&amp;&amp;&amp; =&amp; A&amp;&amp;)。在这两种情况下,a是实际范围中的命名对象,但T包含来自调用者范围的“引用类型”的信息。此信息(T)作为模板参数传递给forward,并根据T的类型移动“a”。

答案 6 :(得分:19)

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

答案 7 :(得分:13)

你知道复制语义是什么意思吗?它意味着你有可复制的类型,对于你定义的用户定义类型,要么明确地写一个拷贝构造函数&amp;赋值运算符或编译器隐式生成它们。这将进行复制。

移动语义基本上是一个用户定义的类型,其构造函数采用r值引用(使用&amp;&amp;(是两个&符)的新类型引用)非const,这称为移动构造函数,赋值运算符也一样。那么移动构造函数是做什么的,而不是从它的源参数中复制内存,而是“将”内存从源移动到目标。

你想什么时候做? well std :: vector就是一个例子,假设您创建了一个临时的std :: vector,并从函数中返回它:

std::vector<foo> get_foos();

当函数返回时,你将从复制构造函数中获得开销,如果(并且它将在C ++ 0x中)std :: vector有一个移动构造函数而不是复制它可以只设置它的指针和'移动'动态分配内存到新实例。这有点像使用std :: auto_ptr转移所有权语义。

答案 8 :(得分:7)

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

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

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

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

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

构建了两个新对象,其中一个是临时对象,仅用于函数的持续时间。

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

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

首先必须初始化对象,然后将旧对象中的所有相关数据复制到新对象中 根据类,可能是一个包含大量数据的容器,那么这可能代表 time 内存使用

// Copy constructor
T::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 constructor
T::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

  

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

答案 9 :(得分:5)

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

创建了移动语义以避免不必要的大型对象复制。 Bjarne Stroustrup在他的书“The C ++ Programming Language”中使用了两个例子,默认情况下会发生不必要的复制:一个是交换两个大对象,另外两个是从一个方法返回一个大对象。

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

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

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

答案 10 :(得分:-2)

这里是{@ 3}},来自Bjarne Stroustrup撰写的“ The C ++ Programming Language”一书。如果您不想看视频,可以看下面的文字:

考虑此代码段。从运算符+返回包括将结果复制到局部变量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而不是复制它。我们可以如下定义move构造函数:

class Vector {
    // ...
    Vector(const Vector& a); // copy constructor
    Vector& operator=(const Vector& a); // copy assignment
    Vector(Vector&& a); // move constructor
    Vector& operator=(Vector&& a); // move assignment
};

Vector::Vector(Vector&& a)
    :elem{a.elem}, // "grab the elements" from a
    sz{a.sz}
{
    a.elem = nullptr; // now a has no elements
    a.sz = 0;
}

&&表示“右值引用”,是可以绑定右值的引用。 “ rvalue”是对“ lvalue”的补充,“ lvalue”的大致含义是“可能出现在作业左侧的内容”。因此,右值大致表示“您无法分配的值”,例如函数调用返回的整数,以及Vectors的operator +()中的res局部变量。

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