我刚听完Scott Meyers关于C++0x的软件工程广播播客采访。大多数新特性对我来说都是有意义的,我现在对C++0x非常兴奋,只有一个例外。我仍然不懂移动语义。。。到底是什么?


当前回答

简单(实用)地说:

复制对象意味着复制其“静态”成员,并为其动态对象调用新运算符。正确的

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:
   // 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类型的对象时,编译器会根据传递的对象是否命名来创建左值引用或右值引用。如果没有,将调用移动构造函数,并且您知道该对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。

务必记住,“静态”对象总是被复制的。没有办法“移动”静态对象(堆栈中的对象,而不是堆中的对象)。因此,当对象没有动态成员(直接或间接)时,区分“移动”/“复制”是不相关的。

如果您的对象很复杂,并且析构函数具有其他次要影响,例如调用库的函数、调用其他全局函数或其他任何函数,那么最好使用标志来表示移动:

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

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

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));
}

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

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

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

其他回答

我写这个是为了确保我能正确理解。

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

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

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

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

简单(实用)地说:

复制对象意味着复制其“静态”成员,并为其动态对象调用新运算符。正确的

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:
   // 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类型的对象时,编译器会根据传递的对象是否命名来创建左值引用或右值引用。如果没有,将调用移动构造函数,并且您知道该对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。

务必记住,“静态”对象总是被复制的。没有办法“移动”静态对象(堆栈中的对象,而不是堆中的对象)。因此,当对象没有动态成员(直接或间接)时,区分“移动”/“复制”是不相关的。

如果您的对象很复杂,并且析构函数具有其他次要影响,例如调用库的函数、调用其他全局函数或其他任何函数,那么最好使用标志来表示移动:

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

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

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));
}

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

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

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

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

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

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

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

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

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

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


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

它必须首先初始化对象,然后将所有相关数据从旧对象复制到新对象。根据类的不同,它可能是一个包含大量数据的容器,那么这可能会占用大量的时间和内存

// 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);
}

移动数据涉及将数据与新对象重新关联。而且根本不会复制。

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

来自cpreference.com:

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

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

#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&绑定到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行中的参数不是lvalues,而是rvalues,因为基础字符串对象没有名称,因此客户端无法在稍后的时间点再次检查它们。右值表示在下一个分号处销毁的临时对象(更准确地说:在词汇上包含右值的完整表达式的末尾)。这一点很重要,因为在b和c的初始化过程中,我们可以对源字符串做任何我们想做的事情,而客户端无法分辨出区别!

C++0x引入了一种称为“右值引用”的新机制,允许我们通过函数重载检测右值参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在该构造函数中,我们可以对源代码做任何我们想做的事情,只要我们让它处于某种有效状态:

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

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

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

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

嗯,就这样?“右值参考值在哪里?”你可能会问。“我们这里不需要它!”我的回答是:)

请注意,我们通过值传递参数,因此它必须像其他字符串对象一样进行初始化。具体如何初始化?在C++98的早期,答案应该是“复制构造函数”。在C++0x中,编译器根据赋值运算符的参数是左值还是右值,在复制构造函数和移动构造函数之间进行选择。

因此,如果你说a=b,复制构造函数将初始化它(因为表达式b是一个左值),赋值运算符将用新创建的深度副本交换内容。这就是复制和交换习惯用法的定义——制作一个副本,用副本交换内容,然后通过离开范围来删除副本。这里没有什么新鲜事。

但是如果你说a=x+y,move构造函数将初始化它(因为表达式x+y是一个右值),所以不涉及深度复制,只涉及有效的移动。这仍然是一个独立于论点的对象,但它的构造微不足道,因为堆数据不必复制,只需移动即可。不需要复制它,因为x+y是一个右值,同样,可以从右值表示的字符串对象中移动。

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

我希望这个例子能传达出要点。为了保持简单,我特意省略了很多关于重新赋值引用和移动语义的内容。如果你想了解更多详情,请参阅我的补充答案。

移动语义是指在没有人需要源值时转移资源,而不是复制资源。

在C++03中,对象经常被复制,只有在任何代码再次使用该值之前才会被销毁或赋值。例如,当您从函数中按值返回时,除非RVO踢入您返回的值,否则该值将被复制到调用方的堆栈帧,然后超出范围并被销毁。这只是众多示例中的一个:当源对象是临时对象时,请参阅passbyvalue;当超过其capacity()时,可以使用排序等算法重新排列项目。

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