移动语义是什么?

2080
我刚刚听完了关于C++11的软件工程广播采访Scott Meyers。其中大部分新特性对我来说都很有意义,只有一个例外。我还是不太明白移动语义...它到底是什么?

28
我找到了Eli Bendersky的博客文章,它介绍了C和C++中lvalue和rvalue的概念,并提到了C++11中的rvalue引用,通过一些小例子对其进行了介绍。 - Nils
23
Alex Allain对这个主题的阐述非常精彩。 - Patrick Sanan
43
每年左右,我都会想知道C++中的“新”移动语义是什么,于是我谷歌了一下并找到了这个页面。我读了回答,但我的大脑变得迷茫了。然后我就回到C语言,把一切都忘了!我被卡住了。 - sky
19
考虑使用 std::vector<>... 其中有一个指针指向堆上的数组。如果您复制此对象,则必须分配一个新缓冲区,并将缓冲区中的数据复制到新缓冲区。是否有什么情况可以简单地窃取指针?答案是肯定的,当编译器知道对象是临时的时候。移动语义允许您定义如何将类的内容移出并放入不同的对象中,当编译器知道要移动的对象即将消失时。 - dicroce
2
我能理解的唯一参考资料是:https://www.learncpp.com/cpp-tutorial/15-1-intro-to-smart-pointers-move-semantics/,即移动语义的原始推理来自智能指针。 - jw_
显示剩余2条评论
11个回答

2921

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

#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之类的表达式称为"lvalue"。
第2和第3行中的参数不是lvalue,而是rvalue,因为底层字符串对象没有名称,因此客户端无法在以后的某个时间点再次检查它们。 rvalue表示临时对象,在下一个分号处被销毁(更准确地说:在包含rvalue的完整表达式的结尾处)。这很重要,因为在初始化b和c期间,我们可以对源字符串做任何我们想做的事情,而"客户端无法察觉到任何区别"!
C++0x引入了一种新机制,称为"rvalue reference",该机制允许我们通过函数重载检测rvalue参数。我们所要做的就是编写一个具有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是一个rvalue),所以没有涉及深拷贝,只有高效的移动。 that仍然是独立于参数的对象,但它的构造是微不足道的,因为堆数据不必被复制,只需移动即可。不需要复制它,因为x + y是一个rvalue,而且再次从由rvalues表示的字符串对象中移动也可以。
总之,复制构造函数进行深拷贝,因为源必须保持不变。另一方面,移动构造函数可以只复制指针,然后将源中的指针设置为null。以这种方式“使源对象无效”是可以的,因为客户端无法再次检查该对象。
我希望这个例子能够传达主要观点。关于rvalue引用和移动语义还有很多细节,我故意省略了以保持简单。如果您想获取更多详细信息,请参见我的补充答案

49
但是如果我的构造函数接收的是一个rvalue,它将永远不会被再次使用,为什么我需要费心让它保持一致和安全状态?与其将that.data设置为0,为什么不直接保持原样呢? - einpoklum
89
因为如果没有“that.data = 0”,这些角色将过早地被销毁(在临时对象死亡时),而且可能会被销毁两次。你想要窃取数据,而不是共享它! - fredoverflow
28
定期调用的析构函数仍将运行,因此您必须确保源对象的移动后状态不会导致崩溃。更好的做法是确保源对象也可以成为赋值或其他写入操作的接收者。 - CTMacUser
16
是的,所有对象都必须被销毁,即使是已移动的对象。由于我们不希望在此过程中删除字符数组,因此必须将指针设置为null。 - fredoverflow
13
根据 C++ 标准,对空指针使用 delete[] 会被定义为无操作。 - fredoverflow
显示剩余61条评论

1269
我的第一个答案是关于移动语义的极度简化介绍,故意省略了许多细节以保持简单易懂。然而,移动语义还有很多内容,我想现在是时候回答第二个问题来填补空白了。 第一个答案已经相当老了,用完全不同的文本来取代它并不合适。我认为它仍然可以作为第一次介绍的好材料。但如果你想深入了解,请继续阅读 :)
Stephan T. Lavavej花时间提供了宝贵的反馈。非常感谢,Stephan!
介绍
移动语义允许对象在某些条件下接管其他对象的外部资源所有权。这在两个方面都很重要:
  1. Turning expensive copies into cheap moves. See my first answer for an example. Note that if an object does not manage at least one external resource (either directly, or indirectly through its member objects), move semantics will not offer any advantages over copy semantics. In that case, copying an object and moving an object means the exact same thing:

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

什么是移动?

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

请注意,使用a初始化b并不会复制三角形,而是将三角形的所有权从a转移给b。我们还说“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 的危险之处在于,从语法上看起来像是复制,实际上是移动。尝试在已经移动过的 auto_ptr 上调用成员函数将会导致未定义的行为,因此您必须非常小心,不要在移动后使用 auto_ptr

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 调用成员函数,从而引发未定义行为。另一方面,从像 make_triangle() 这样的右值移动是完全安全的,因为在复制构造函数完成其工作后,我们无法再次使用临时对象。没有表达式表示该临时对象;如果我们简单地再次编写 make_triangle(),我们将获得一个 不同的 临时对象。实际上,在下一行中,已经移动了原始的临时对象:
auto_ptr<Shape> c(make_triangle());
                                  ^ the moved-from temporary dies right here

请注意,字母lr在赋值的左侧和右侧具有历史渊源。但在C++中这已不再成立,因为存在不能出现在赋值左侧的lvalue(例如数组或没有赋值运算符的用户定义类型),也存在可以出现在赋值左侧的rvalue(所有具有赋值运算符的类类型的rvalue)。

类类型的rvalue是一个表达式,其评估创建临时对象。 在正常情况下,同一作用域内没有其他表达式指代相同的临时对象。

Rvalue引用

我们现在明白了从lvalue移动可能是危险的,但从rvalue移动是无害的。如果C++支持区分lvalue参数和rvalue参数的语言支持,我们可以完全禁止从lvalue移动,或者至少使从lvalue移动在调用点上变得显式,以便我们不再意外地移动。
C++11的解决方案是rvalue引用。rvalue引用是一种新的引用类型,只绑定到rvalue,语法为X&&。传统的引用X&现在称为lvalue引用。(请注意,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&&。只能从rvalue读取的限制并不是很有用。
rvalue引用X&&是一种新的引用类型,只绑定到rvalue。
隐式转换
rvalue引用经历了几个版本。自2.1版以来,rvalue引用X&&还绑定到不同类型Y的所有值类别,前提是存在从YX的隐式转换。在这种情况下,创建一个临时的X类型,并将rvalue引用绑定到该临时对象。
void some_function(std::string&& r);

some_function("hello world");

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

移动构造函数

具有 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);                 // error
unique_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 resource
            source.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提供了一个名为std::move的标准库函数模板,位于头文件<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),我们表明了我们的意图:“亲爱的构造函数,为了初始化 c,你可以任意处理 a;我不再关心 a 了。随便你对待 a。”

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

Xvalues

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

prvalues和xvalues都是rvalues。xvalues和lvalues都是glvalues(广义左值)。通过图表更容易理解它们之间的关系:

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

请注意,只有xvalues是真正的新东西;其余部分只是由于重命名和分组而产生的。
C++98中的rvalues在C++11中被称为prvalues。在前面的段落中,将所有出现的“rvalue”都替换为“prvalue”。
从函数中移动
到目前为止,我们已经看到了移动到局部变量和函数参数中。但是,移动也可以朝相反的方向进行。如果一个函数通过值返回,那么调用点上的某个对象(可能是局部变量或临时变量,但也可能是任何类型的对象)将使用return语句后的表达式作为移动构造函数的参数进行初始化:
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
}

移动构造函数为什么能接受左值变量"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
    {}
};

你可以认为在初始化member之后,parameter不再使用。为什么没有像返回值一样自动插入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

自 C++11 版本以来,右值引用已经经历了几个版本。注意,既 VC10 也 VC11 还没有符合第 3.0 版本,因此您需要自己实现移动构造函数和移动赋值运算符。同时,这两个函数是根据需要生成的特殊成员函数。
X::X(X&&);                   // move constructor
X& X::operator=(X&&);        // move assignment operator

这两个新的特殊成员函数仅在没有手动声明任何特殊成员函数时隐式声明。此外,如果您声明自己的移动构造函数或移动赋值运算符,则不会隐式声明复制构造函数或复制赋值运算符。

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

如果您编写一个没有未管理资源的类,则无需自己声明五个特殊成员函数,您将免费得到正确的复制语义和移动语义。否则,您必须自己实现特殊成员函数。当然,如果您的类不受移动语义的影响,则没有必要实现特殊移动操作。

请注意,复制赋值运算符和移动赋值运算符可以合并为一个统一的赋值运算符,通过值来取其参数:

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

如果参数是类型为X的右值引用,T将被推导为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);
}

正如您所看到的,由于转发引用 T&&move 接受任何类型的参数,并返回一个右值引用。必须调用 std::remove_reference<T>::type 元函数,否则对于类型为 X 的左值,返回类型将是 X& &&,这将缩减为 X&。由于 t 总是一个左值(请记住,命名的右值引用是一个左值),但我们想要将 t 绑定到一个右值引用,因此必须显式地将 t 转换为正确的返回类型。
返回右值引用的函数调用本身就是一个 xvalue。现在您知道 xvalue 来自哪里了 ;)
需要注意的是,在这个例子中通过右值引用返回是可以的,因为 t 不表示自动对象,而是由调用者传入的对象。

返回右值引用的函数调用,例如 std::move,是一个 xvalue。


256
在我的显示器上只有10页。 - Mooing Duck
31
移动语义之所以重要的第三个原因是异常安全。在某些情况下,复制操作可能会抛出异常(因为它需要分配资源,而分配可能失败),而移动操作可以是无法抛出异常的(因为它可以转移现有资源的所有权而不是分配新的资源)。具有不能失败的操作总是很好的,而且在编写提供异常保证的代码时,它可能是至关重要的。 - Brangdon
10
我理解你的意思直到"通用引用",但接下来的内容过于抽象难以理解。引用折叠?完美转发?你是说如果类型被模板化,右值引用会变成通用引用吗?希望有一种方法能够解释清楚这些,让我知道是否需要理解它们! :) - Kylotan
16
现在请你写一本书……这个回答让我相信,如果你能像这样简明地解释C++的其他方面,那么会有成千上万的人能够理解它。 - halivingston
16
@halivingston 非常感谢你的反馈,我非常欣赏。写书的问题在于:它需要比你想象中更多的工作量。如果你想深入了解C++11及其以上版本,我建议你购买Scott Meyers所著的“Effective Modern C++”。 - fredoverflow
显示剩余39条评论

70

假设你有一个返回大型对象的函数:

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

当您编写以下代码时:

Matrix r = multiply(a, b);

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

如果正在复制的对象(例如上面的Matrix示例)在堆上分配了额外的内存来存储其内部表示,则这一点尤其重要。复制构造函数必须要么完全复制内部表示,要么在内部使用引用计数和写时复制语义。移动构造函数将保留堆内存,只需复制Matrix对象内部的指针即可。


3
移动构造函数和拷贝构造函数有何不同? - dicroce
2
@dicroce:它们的语法不同,一个看起来像Matrix(const Matrix& src)(复制构造函数),另一个看起来像Matrix(Matrix&& src)(移动构造函数),请查看我的主要答案以获得更好的示例。 - snk_kid
4
创建一个空对象和创建一个副本是不同的。如果对象中存储的数据很大,创建一个副本可能会很昂贵,比如std::vector。 - Billy ONeal
1
@kunj2aan:这取决于你的编译器,我猜测。编译器可能会在函数内部创建一个临时对象,然后将其移动到调用者的返回值中。或者,它可以直接在返回值中构造对象,而不需要使用移动构造函数。 - Greg Hewgill
3
@Jichao:这是一种名为RVO的优化,有关其与移动语义之间差异的更多信息,请参见此问题:https://dev59.com/-2445IYBdhLWcg3wGmk6 - Greg Hewgill
显示剩余4条评论

36

移动语义是指在没有任何代码使用源值时,转移资源而不是复制它们

在C++03中,对象经常被复制,但在任何代码再次使用该值之前就被销毁或被覆盖。例如,当您从函数按值返回时,除非RVO生效,否则将返回的值复制到调用者的堆栈帧中,然后它将超出作用域并被销毁。这只是许多例子之一:参见当源对象为临时对象时的按值传递、像sort这样只重新排列项的算法、vector中的reallocation以及其capacity()被超过的情况等。

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


31

用通俗易懂的语言来说:

复制一个对象意味着复制它的“静态”成员,并为其动态对象调用 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());

在这种情况下,会创建一个匿名对象,然后将其复制到函数参数中,最后删除。因此,在这种情况下最好移动对象,因为您不需要匿名对象,可以节省时间和内存。
这导致了“rvalue”引用的概念。它们仅存在于C++11中,以检测接收到的对象是否是匿名的。我认为您已经知道“lvalue”是可分配实体(=操作符的左侧),因此您需要一个命名引用来作为lvalue的对象。rvalue恰恰相反,是没有命名引用的对象。因此,匿名对象和rvalue是同义词。因此:
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&&const A&&之间的区别?当然,在第一种情况下,您可以修改对象,在第二种情况下则不能,但是实际意义是什么?在第二种情况下,您无法修改它,因此您没有任何方法来使对象失效(除了使用可变标志或类似内容),并且与复制构造函数没有实际区别。

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

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

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

other_function(std::move(a));

通过这行代码,std::move 将把 a 转换为 rvalue,并且 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&&`

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

30
如果你真的对移动语义有兴趣,我强烈建议阅读关于它们的原始论文《为C++语言添加移动语义支持的提案》。这篇文章非常易懂,对移动语义的优势做出了很好的解释。虽然WG21网站上也有其他更近期和更新的关于移动语义的论文,但是由于该论文从高层次的角度入手,没有涉及太多细节,因此可能是最直接的一篇。

26

它类似于复制语义,但不必复制所有数据,而是可以从“移动”对象中窃取数据。


14
你知道什么是复制语义吗?它意味着你有一些可复制的类型,对于用户定义的类型,你可以通过显式编写复制构造函数和赋值运算符或者由编译器隐式生成来定义它们。这将进行复制。
移动语义基本上是一个具有构造函数的用户定义类型,该构造函数使用非 const 的右值引用(一种使用两个“&”符号的新类型的引用)。这称为移动构造函数,赋值运算符也是如此。那么移动构造函数做什么呢?好吧,它不会从源参数复制内存,而是将内存从源移动到目标位置。
何时需要这样做呢?std::vector 就是一个例子,假设你创建了一个临时的 std::vector 并从函数中返回它:
std::vector<foo> get_foos();

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


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

9

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

移动语义是为了避免不必要的大对象复制而创建的。Bjarne Stroustrup在他的书《C++程序设计语言》中使用了两个例子,其中默认情况下会出现不必要的复制:一是交换两个大对象,二是从方法返回一个大对象。

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

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

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


1
“移动赋值”允许程序员覆盖默认的复制行为,而是交换对象之间的引用,这意味着根本没有复制,交换操作更快。但是,这些声明含糊不清且具有误导性。要交换两个对象x和y,不能仅仅“交换对象之间的引用”;可能是对象包含指向其他数据的指针,这些指针可以交换,但移动运算符并不需要交换任何内容。它们可能会清除移动源对象中的数据,而不是保留其中的目标数据。 - Tony Delroy
你可以不使用移动语义来编写 swap() 函数。有时需要使用 std::move() 方法来调用移动赋值操作符,但这并不会实际移动任何东西,只是让编译器知道该参数是可移动的。有时需要使用带转发引用的 std::forward<>(),而其他时候编译器知道一个值可以被移动。 - Tony Delroy

8
为了说明需要使用“移动语义”,让我们考虑没有使用移动语义的情况下的示例:
这是一个函数,它接受类型为T的对象,并返回相同类型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);
}

移动数据涉及将数据重新关联到新对象。而且根本不会发生复制。
这是通过使用rvalue引用来实现的。 rvalue引用的工作方式与lvalue引用基本相同,但有一个重要区别: rvalue引用可以移动,而lvalue则不能。
来自cppreference.com
为了实现强异常保证,用户定义的移动构造函数不应抛出异常。事实上,标准容器通常依赖于std::move_if_noexcept在需要重新定位容器元素时选择移动或复制。如果同时提供了复制和移动构造函数,则重载解析器会在参数是rvalue(如无名临时对象或std::move的结果等xvalue)时选择移动构造函数,并在参数是lvalue(命名对象或返回lvalue引用的函数/运算符)时选择复制构造函数。如果仅提供复制构造函数,则所有参数类别都会选择它(只要它接受对const的引用,因为rvalues可以绑定到const引用),这使得在移动不可用时,复制成为移动的后备。在许多情况下,即使移动构造函数会产生可观察的副作用,也会被优化掉,参见复制省略。当一个构造函数以rvalue引用作为参数时,称其为“移动构造函数”。它没有义务移动任何内容,类也不需要拥有要移动的资源,“移动构造函数”可能无法移动资源,例如允许(但可能不明智)的情况下参数是const rvalue引用(const T&&)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接