什么是移动语义?


1700

我刚刚结束了对Scott Meyers进行的有关C ++ 0x的Software Engineering广播播客采访。大多数新功能对我来说都是有意义的,除了一个功能,我现在对C ++ 0x感到非常兴奋。我仍然没有移动语义 ……这到底是什么?


20
我发现[Eli Bendersky的博客文章](eli.thegreenplace.net/2011/12/15/…)关于C和C ++中的左值和右值非常有用。他还提到了C ++ 11中的右值引用,并通过一些小示例对其进行了介绍。
尼尔斯


19
大约每年左右,我都想知道C ++中的“新”移动语义是什么,我用Google搜索它并转到此页面。我读了回复,我的大脑关闭了。我回到C,忘记了一切!我陷入僵局。
天空

7
@sky考虑std :: vector <> ...在某个地方有一个指向堆上数组的指针。如果复制此对象,则必须分配新的缓冲区,并且需要将缓冲区中的数据复制到新的缓冲区中。在任何情况下都可以简单地窃取指针吗?当编译器知道该对象是临时对象时,答案为是。移动语义允许您定义当编译器知道要从中移动的对象即将消失时,如何将类的胆量移出或放入另一个对象中。
dicroce

我能理解的唯一参考文献:learncpp.com/cpp-tutorial/…,即,移动语义的原始推理来自智能指针。
jw_

Answers:


2478

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

#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类型的表达式,使您可以在以下示例中进行复制:

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“左值”之类的表达式。

第2行和第3行中的参数不是左值,而是右值,因为基础字符串对象没有名称,因此客户端无法在以后的时间再次检查它们。rvalues表示在下一个分号处销毁的临时对象(更精确地说:在词法上包含rvalue的完整表达式的末尾)。这一点很重要,因为在band 初始化期间c,我们可以对源字符串做任何想做的事情,而客户端却无法分辨

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

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

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

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

因此,如果您说a = b,则复制构造函数将初始化that(因为表达式b是一个左值),并且赋值运算符将内容与新创建的深层副本交换。这就是复制和交换惯用法的确切定义-制作一个副本,将内容与该副本交换,然后通过保留范围来摆脱该副本。这里没有新东西。

但是,如果您说a = x + ymove构造函数将初始化that(因为该表达式x + y是一个右值),因此不涉及深层复制,而仅涉及有效的移动。 that仍然是该参数中的一个独立对象,但是它的构造很简单,因为不必复制堆数据,只需移动它即可。不必复制它,因为它x + y是一个右值,并且再次可以从右值表示的字符串对象中移出。

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

我希望这个例子能说明重点。右值引用和移动语义还有很多,我有意省略以保持简单。如果您需要更多详细信息,请参阅我的补充答案


40
@但是,如果我的ctor得到一个右值,以后再也不能使用,为什么我还要费心将其保持在一致/安全的状态?而不是设置that.data = 0,为什么不保留它呢?
einpoklum

70
@einpoklum因为没有that.data = 0,这些字符会被过早地销毁(当临时死亡时),而且还会被销毁两次。您想窃取数据,而不是共享!
fredoverflow

19
@einpoklum仍会运行定期安排的析构函数,因此您必须确保源对象的移动后状态不会导致崩溃。更好的是,您应确保源对象也可以是分配或其他写入的接收者。
CTMacUser 2013年

12
@pranitkothari是的,所有对象都必须被销毁,甚至必须移离对象。并且由于我们不希望在发生这种情况时删除char数组,因此必须将指针设置为null。
fredoverflow

7
delete[]C ++标准将nullptr上的@ Virus721 定义为无操作。
fredoverflow

1057

我的第一个答案是对移动语义的极为简化的介绍,故意保留了许多细节以使其保持简单。但是,还有更多的语义要移动,我认为现在是第二次填补空白的时候了。第一个答案已经很老了,用简单的完全不同的文本替换它是不合适的。我认为它仍然可以很好地作为第一个介绍。但是,如果您想深入了解,请继续阅读:)

Stephan T. Lavavej花时间提供了宝贵的反馈意见。非常感谢,斯蒂芬!

介绍

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

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

    class cannot_benefit_from_move_semantics
    {
        int a;        // moving an int means copying an int
        float b;      // moving a float means copying a float
        double c;     // moving a double means copying a double
        char d[64];   // moving a char array means copying a char array
    
        // ...
    };
  2. 实施安全的“仅移动”类型;也就是说,复制没有意义,而移动却有意义。示例包括具有唯一所有权语义的锁,文件句柄和智能指针。注意:此答案讨论了std::auto_ptr,已弃用的C ++ 98标准库模板,std::unique_ptr在C ++ 11中已将其替换。中级C ++程序员可能至少有点熟悉std::auto_ptr,并且由于它显示的“移动语义”,这似乎是讨论C ++ 11中移动语义的一个很好的起点。YMMV。

这是什么举动?

C ++ 98标准库提供了一个智能指针,该指针具有称为的唯一所有权语义std::auto_ptr<T>。如果您不熟悉auto_ptr,它的目的是即使在遇到异常的情况下,也始终保证释放动态分配的对象:

{
    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复制三角形,而是转移三角形的从所有权ab。我们也说“ a移入 b ”或者“三角形移动a b ”。这听起来可能会造成混淆,因为三角形本身始终位于内存中的同一位置。

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

的复制构造函数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是,在语法上看起来像副本的实际上是移动。尝试在from上调用成员函数auto_ptr将调用未定义的行为,因此您必须非常小心,不要auto_ptr在将其从from位置移开后使用:

auto_ptr<Shape> a(new Triangle);   // create triangle
auto_ptr<Shape> b(a);              // move a into b
double 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 c
double area = make_triangle()->area();   // perfectly safe

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

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

但是,其中一个调用了未定义的行为,而另一个则没有。那么,表达式a和之间有什么区别make_triangle()?他们不是同一个类型吗?确实是,但是它们具有不同的价值类别

值类别

显然,a表示auto_ptr变量的表达式make_triangle()与表示返回auto_ptr按值的函数的调用的表达式之间必须有一些深远的区别,因此auto_ptr每次调用时都会创建一个新的临时对象。a左值的示例,而是右值make_triangle()的示例。

从这样的左值移动a很危险,因为稍后我们可以尝试通过调用成员函数a,从而调用未定义的行为。另一方面,从rvalue这样的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&          yes
const X&    yes      yes            yes      yes
X&&                                 yes
const X&&                           yes      yes

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

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

隐式转换

右值引用经历了多个版本。从2.1版开始,如果存在从到的隐式转换,则右值引用X&&也将绑定到不同类型的所有值类别。在这种情况下,将创建一个临时类型,并将右值引用绑定到该临时上:YYXX

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&&参数的函数的有用示例是move构造函数 X::X(X&& source)。其目的是将托管资源的所有权从源转移到当前对象。

在C ++ 11中,std::auto_ptr<T>已被std::unique_ptr<T>rvalue引用所取代。我将开发和讨论的简化版本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;
    }

这个move构造函数的功能与auto_ptr复制构造函数的功能完全相同,但是只能提供rvalues:

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

第二行由于a是左值而无法编译,但是该参数unique_ptr&& source只能绑定到右值。这正是我们想要的。危险的举动绝对不能隐含。第三行编译就好了,因为它make_triangle()是一个右值。move构造函数会将所有权从临时所有权转移到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 resource
            source.ptr = nullptr;
        }
        return *this;
    }
};

请注意,移动分配运算符的此实现如何复制析构函数和move构造函数的逻辑。您熟悉复制和交换习惯吗?它也可以作为移动和交换的惯用法应用于移动语义:

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

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

移动分配运算符将托管资源的所有权转移到当前对象中,从而释放旧资源。移动和交换习惯简化了实现。

从左值移动

有时,我们想从左值转移。也就是说,有时我们希望编译器将一个左值当作一个右值对待,因此它可以调用move构造函数,即使它可能是不安全的。为此,C ++ 11提供了std::move在header内部调用的标准库函数模板<utility>。这个名字有点不幸,因为std::move只是将一个左值转换为一个右值。它本身不会移动任何东西。它仅允许移动。也许它应该被命名为std::cast_to_rvaluestd::enable_move,但是到目前为止,我们仍然停留在该名称上。

这是您从左值显式移动的方式:

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

请注意,在第三行之后,a不再拥有三角形。没关系,因为通过显式编写std::move(a),我们明确了我们的意图:“亲爱的构造函数,请执行您想进行的任何操作a以进行初始化c;我不再关心a。请随时使用a。”

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

X值

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

prvalue和xvalue均为rvalue。X值和左值都是glvalues(广义左值)。使用图更容易理解这些关系:

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

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

C ++ 98右值在C ++ 11中称为prvalue。用“ prvalue”替换前面段落中所有出现的“ rvalue”。

移出功能

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

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

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

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

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

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

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

特殊成员功能

C ++ 98根据需要隐式声明三个特殊的成员函数,即在某个地方需要它们时:拷贝构造函数,拷贝赋值运算符和析构函数。

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

右值引用经历了多个版本。从3.0版开始,C ++ 11根据需要声明了两个附加的特殊成员函数:move构造函数和move赋值运算符。请注意,VC10和VC11都不符合版本3.0,因此您必须自己实现它们。

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

仅当没有手动声明任何特殊成员函数时,才隐式声明这两个新的特殊成员函数。同样,如果您声明自己的move构造函数或move赋值运算符,则不会隐式声明副本构造函数或副本赋值运算符。

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

如果您编写的类没有非托管资源,则无需自己声明这五个特殊成员函数中的任何一个,您将获得正确的复制语义并免费移动语义。否则,您将必须自己实现特殊的成员函数。当然,如果您的类没有从移动语义中受益,则无需实现特殊的移动操作。

请注意,可以将复制赋值运算符和移动赋值运算符融合为单个统一赋值运算符,并按值取其参数:

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

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

转发参考(以前称为通用参考

考虑以下功能模板:

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>&

如果参数是类型的右值XT则推导为X,因此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>::type
foo(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它不表示自动对象,而是表示调用者传递的对象。



24
第三个原因是移动语义很重要:异常安全。通常,复制操作可能会抛出(因为它需要分配资源并且分配可能会失败)在哪里,移动操作可能是不抛出(因为它可以转移现有资源的所有权而不是分配新资源)。进行不失败的操作总是很好,并且在编写提供异常保证的代码时至关重要。
布兰登

8
我一直在和您一起讨论“通用引用”,但是那太抽象了,无法遵循。参考崩溃了?完美的转发?您是说如果类型化模板,则右值引用将成为通用引用?我希望有一种方法可以解释这一点,以便我知道是否需要了解!:)
Kylotan 2014年

8
请立即写一本书...这个答案使我有理由相信,如果您以这种清晰的方式涵盖了C ++的其他方面,将会有成千上万的人理解它。
halivingston

12
@halivingston非常感谢您的反馈,非常感谢。写书的问题是:它的工作量超出您的想象。如果您想深入研究C ++ 11及更高版本,建议您购买Scott Meyers的“ Effective Modern C ++”。
fredoverflow

77

移动语义基于右值引用
一个右值是一个临时对象,它将在表达式末尾销毁。在当前的C ++中,右值仅绑定到const引用。C ++ 1x允许使用非const右值引用(拼写为)T&&,这些引用是对右值对象的引用。
由于右值将在表达式的末尾消失,因此您可以窃取其数据。无需复制到另一个对象中,而是其数据移入其中。

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的copy构造函数。如果您的编译器支持move语义并且X具有move-constructor,则将调用它。由于它的rhs参数是一个右值,我们知道它不再需要了,我们可以窃取它的值。
因此,值从从返回到的未命名临时对象移到了(初始化为空的的数据被移入临时对象,在赋值后将被销毁)。f()xxX


1
请注意,这应该是this->swap(std::move(rhs));因为命名的右值引用是

根据@Tacyt的评论,这有点不对:在的上下文中rhs左值X::X(X&& rhs)。您需要致电std::move(rhs)以获得右值,但这有点使答案无济于事。
Ashe 2014年

没有指针的类型移动语义是什么?移动语义可以复制吗?
Gusev Slava

@Gusev:我不知道你在问什么。
sbi

60

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

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

当您编写这样的代码时:

Matrix r = multiply(a, b);

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

如果(Matrix例如上面的示例)复制的对象在堆上分配了额外的内存来存储其内部表示,则这一点尤其重要。复制构造函数必须要么完整复制内部表示形式,要么在内部使用引用计数和写时复制语义。移动构造函数将不理会堆内存,仅将指针复制到Matrix对象内部。


2
移动构造函数和副本构造函数有何不同?
dicroce 2010年

1
@dicroce:它们的语法不同,一个看起来像Matrix(const Matrix&src)(复制构造函数),另一个看起来像Matrix(Matrix && src)(移动构造函数),请查看我的主要答案以获取更好的示例。
snk_kid 2010年

3
@dicroce:一个制作空白对象,一个制作副本。如果存储在对象中的数据很大,则副本可能会很昂贵。例如,std :: vector。
Billy ONeal

1
@ kunj2aan:我怀疑这取决于您的编译器。编译器可以在函数内部创建一个临时对象,然后将其移到调用者的返回值中。或者,它可能能够直接在返回值中构造对象,而无需使用move构造函数。
格雷格·希吉尔

2
@Jichao:这就是所谓的视网膜静脉阻塞的优化,看到这个问题上的区别的详细信息:stackoverflow.com/questions/5031778/...
格雷格Hewgill


27

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

在C ++ 03中,通常会复制对象,仅在任何代码再次使用该值之前将其销毁或分配。例如,当您从函数按值返回时(除非RVO插入),将您返回的值复制到调用方的堆栈框架中,然后超出范围并被销毁。这只是许多示例之一:当源对象是临时对象时,请参见传递值;sort仅对项目进行重新排列的算法;在超出vectorcapacity()范围时进行重新分配等。

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


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

好的,但是如果我移动一个对象,则源对象将变得无用,不是吗?当然可以,但是在某些情况下非常有用。最明显的一个例子是,当我使用匿名对象(时间对象,右值对象……)调用函数时,可以使用不同的名称进行调用:

void heavyFunction(HeavyType());

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

这导致了“右值”引用的概念。它们存在于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&&?当然,在第一种情况下,您可以修改对象,而在第二种情况下,不是,但是,具有实际意义?在第二种情况下,您无法对其进行修改,因此您没有使对象无效的方法(带有可变标志或类似的东西除外),并且复制构造函数没有实际区别。

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

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在C ++ 11中实现std::forward。此函数利用模板实例化的一些规则:

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

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



13

您知道复制语义是什么意思吗?这意味着您具有可复制的类型,对于用户定义的类型,您可以定义此类型,或者购买显式编写副本构造函数和赋值运算符,或者由编译器隐式生成它们。这将做一个副本。

Move语义基本上是用户定义的类型,具有带非常量的r值引用(使用&&(是两个&符)的新引用类型)的构造函数,这称为Move构造函数,赋值运算符也是如此。因此,move构造函数会做什么,而不是从其源参数复制内存,而是将内存从源“移动”到目标。

您什么时候要这么做?好std :: vector就是一个例子,假设您创建了一个临时的std :: vector,并从一个函数返回它,例如:

std::vector<foo> get_foos();

函数返回时,如果(在C ++ 0x中)std :: vector具有移动构造函数(而不是复制它)可以设置其指针并动态分配“移动”,则复制构造函数将产生开销内存到新实例。这有点类似于std :: auto_ptr的所有权转移语义。


1
我认为这不是一个很好的示例,因为在这些函数的返回值示例中,“返回值优化”可能已经消除了复制操作。
Zan Lynx 2010年

7

为了说明对移动语义的需求,让我们考虑不带移动语义的示例:

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

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

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

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

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

这是通过rvalue参考完成的。
一个rvalue参考的工作非常像一个lvalue有一个重要区别参考:
一个右值引用可以移动左值不能。

来自cppreference.com

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


7

我正在写这篇文章,以确保我理解正确。

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

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

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

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


““移动分配”允许程序员重写默认的复制行为,而是交换对对象的引用,这意味着根本没有复制,并且交换操作要快得多。” -这些说法含糊不清且具有误导性。要交换两个对象xy,您不能仅仅“交换对对象的引用”;这些对象可能包含引用其他数据的指针,并且这些指针可以交换,但是移动运算符不需要交换任何内容。它们可能会从移出的对象中擦除数据,而不是在其中保留目标数据。
Tony Delroy,

您可以编写swap()而无需移动语义。 “可以通过调用std :: move()方法来调用移动分配。” - 有时有必要使用std::move()-尽管实际上并不会移动任何东西-只是让编译器知道该参数是可移动的,有时std::forward<>()(使用转发引用),而其他时候编译器知道可以移动一个值。
Tony Delroy,

-2

这是Bjarne Stroustrup撰写的“ The C ++ Programming Language”一书的答案。如果您不想观看视频,则可以看到以下文本:

考虑一下此片段。从operator +返回的操作涉及将结果复制到局部变量之外,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”的大致含义是“可能出现在作业左侧的内容”。因此,rvalue大致表示“您无法分配的值”,例如函数调用返回的整数,以及resVectors的operator +()中的局部变量。

现在,该声明return res;将不会复制!

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.