题 什么是移动语义?


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


1376
2018-06-23 22:46


起源


我找到了[Eli Bendersky的博客文章](eli.thegreenplace.net/2011/12/15/...关于C和C ++中的左值和左值非常有用。他还提到了C ++ 11中的右值引用,并用小例子介绍了它们。 - Nils
亚历克斯·阿兰对此话题的阐述 写得很好。 - Patrick Sanan
每年左右我都会想知道C ++中的“新”移动语义是什么,我谷歌并进入这个页面。我看了回答,我的大脑关闭了。我回到C,忘了一切!我陷入僵局。 - sky


答案:


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

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

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

    ~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 “左值”。

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

C ++ 0x引入了一种名为“rvalue reference”的新机制,除其他外, 允许我们通过函数重载检测rvalue参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在我的构造函数中,我们可以做到 什么我们想要的 与来源,只要我们留下它 一些 有效状态:

    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 是一个右值,再次,可以从rvalues表示的字符串对象移动。

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

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


2038
2018-06-24 12:40



优秀的答案,让它变得非常清晰。你遗漏的东西的任何链接? - Phil H
@但如果我的ctor得到一个rvalue,以后永远不能使用,为什么我甚至需要打扰它保持一致/安全状态?而不是设置that.data = 0,为什么不留下它? - einpoklum
@einpoklum因为没有 that.data = 0,人物会被太早破坏(当临时死亡时),也会两次。你想窃取数据,而不是分享它! - fredoverflow
@einpoklum定期调度的析构函数仍然运行,因此您必须确保源对象的移动后状态不会导致崩溃。更好的是,您应该确保源对象也可以是赋值或其他写入的接收者。 - CTMacUser
@pranitkothari是的,所有对象都必须被破坏,甚至是从对象移动。由于我们不希望在发生这种情况时删除char数组,因此我们必须将指针设置为null。 - fredoverflow


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

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中讨论移动语义的一个很好的起点。因人而异。

什么是举动?

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

注意如何初始化 b 同 a 不  复制三角形,而是转移三角形的所有权 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,调用未定义的行为。另一方面,从诸如的rvalues移动 make_triangle() 是非常安全的,因为在复制构造函数完成其工作后,我们再也无法使用临时工具。没有表示所述临时表达的表达;如果我们简单地写 make_triangle()再一次,我们得到一个 不同 临时。事实上,移动临时已经在下一行中消失了:

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

请注意这些字母 l 和 r 在作业的左侧和右侧有一个历史起源。这在C ++中已不再适用,因为有左值不能出现在赋值的左侧(如数组或没有赋值运算符的用户定义类型),并且有rvalues可以(类的所有rvalues)有一个赋值运算符)。

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

右值参考

我们现在明白,从左撇子移动是有潜在危险的,但从右撇子移动是无害的。如果C ++有语言支持来区分左值参数和右值参数,我们可以完全禁止从左值移动,或者至少从左值移动 明确的 在通话现场,让我们不再意外移动。

C ++ 11对这个问题的回答是 右值参考。右值引用是一种新的引用,它只绑定到rvalues,语法是 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&&。被限制从rvalues读取并不是很有用。

右值参考 X&& 是一种只与rvalues绑定的新参考。

隐含的转换

Rvalue引用经历了几个版本。从版本2.1开始,是一个右值引用 X&& 也绑定到不同类型的所有值类别 Y,如果存在隐式转换 Y 至 X。在这种情况下,临时的类型 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 这是暂时的。这是rvalues(表达式)和temporaries(对象)之间的区别有点模糊的情况之一。

移动构造函数

一个有用的函数的有用示例 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 复制构造函数,但它只能提供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() 是一个左值。移动构造函数将所有权从临时转移到 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,它将由移动构造函数初始化;也就是说,参数将被移动到参数中。该参数仍然需要是一个rvalue,因为移动构造函数本身有一个rvalue引用参数。当控制流量到达关闭支撑时 operator=source 超出范围,自动释放旧资源。

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

从左值移动

有时,我们想要从左手边移动。也就是说,有时我们希望编译器将左值视为rvalue,因此它可以调用移动构造函数,即使它可能是不安全的。 为此,C ++ 11提供了一个名为的标准库函数模板 std::move 在标题内 <utility>。 这个名字有点不幸,因为 std::move 简单地将左值投射到右值;它确实  独自移动任何东西。它只是 使 移动。也许它应该被命名 std::cast_to_rvalue 要么 std::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) 将左值投射到右值,从而实现后续移动。

Xvalues

请注意,即使 std::move(a) 是一个右值,它的评价确实如此  创建一个临时对象。这个难题迫使委员会引入第三个价值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也称为 x值 (eXpiring值)。传统的rvalues被重命名为 prvalues (纯净的左撇子)。

prvalues和xvalues都是rvalues。 Xvalues和左值都是 glvalues (广义左值)。使用图表更容易掌握关系:

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

请注意,只有xvalues才是新的;剩下的就是重命名和分组。

C ++ 98 rvalues在C ++ 11中称为prvalues。用“prvalue”将前面几段中出现的所有“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 将自动对象移出功能。

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

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 是一个左值。如果你看它的类型,你会看到一个右值引用,但是右值引用只是意味着“一个绑定到右值的引用”;它确实  意味着引用本身就是一个rvalue!确实, 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 keyword表示自动对象。

你也可以通过 parameter 按价值。对于仅限移动的类型 unique_ptr,似乎还没有确定的成语。就个人而言,我更喜欢按值传递,因为它会减少界面中的混乱。

特别会员功能

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

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

Rvalue引用经历了几个版本。从3.0版开始,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>&

如果参数是类型的右值 XT 被推断为 X因此 T&& 手段 X&&。这是任何人都期望的。 但如果参数是类型的左值 X,由于特殊规则, T 被推断为 X&因此 T&& 会有点像 X& &&。但是由于C ++仍然没有引用引用的概念,所以类型 X& && 是 倒塌 成 X&。这可能听起来有点令人困惑和无用,但参考折叠是必不可少的 完美转发 (这里不讨论)。

T &&不是右值引用,而是转发引用。它也与左值结合,在这种情况下 T 和 T&& 都是左值参考。

如果要将函数模板约束为rvalues,可以组合使用 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 到正确的返回类型。 返回rvalue引用的函数调用本身就是一个xvalue。现在你知道xvalues的来源了;)

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

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


893
2017-07-18 11:24



“我的显示器上只有10页” - Mooing Duck
移动语义很重要的第三个原因是:异常安全。通常,复制操作可能抛出(因为它需要分配资源并且分配可能失败),移动操作可能是无抛出的(因为它可以转移现有资源的所有权而不是分配新资源)。拥有无法失败的操作总是很好,在编写提供异常保证的代码时,这一点至关重要。 - Brangdon
我和你在一起直到“普遍引用”,但随后它太抽象了。参考崩溃?完美转发?如果类型是模板化的,你是说rvalue引用成为通用引用吗?我希望有一种解释方法,以便我知道我是否需要理解它! :) - Kylotan
请现在写一本书...这个答案让我有理由相信,如果你以清醒的方式涵盖C ++的其他角落,会有成千上万的人理解它。 - halivingston
@halivingston非常感谢您的反馈,我真的很感激。写一本书的问题是:它比你想象的要多得多。如果你想深入研究C ++ 11及更高版本,我建议你购买Scott Meyers的“Effective Modern C ++”。 - fredoverflow


移动语义基于 右值参考
rvalue是一个临时对象,它将在表达式的末尾被销毁。在当前的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 有一个move-constructor,然后调用它。自从它 rhs 论证是一个 右值,我们知道它不再需要,我们可以偷取它的价值。
所以价值是 移动 从未命名的临时返回 f() 至 x (而数据) x,初始化为空 X,被移入临时,这将在任务后被销毁)。


67
2018-06-23 23:12



请注意它应该是 this->swap(std::move(rhs)); 因为命名的右值引用是左值 - wmamrak
根据@Tacyt的评论,这有点不对劲: rhs 是一个 左值 在上下文中 X::X(X&& rhs)。你需要打电话 std::move(rhs) 获得一个rvalue,但这有点让答案没有实际意义。 - Ashe


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

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

当你编写这样的代码时:

Matrix r = multiply(a, b);

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

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


46
2018-06-23 22:53



移动构造函数和复制构造函数有何不同? - dicroce
@dicroce:它们的语法不同,一个看起来像Matrix(const Matrix&src)(复制构造函数)而另一个看起来像Matrix(Matrix && src)(移动构造函数),请查看我的主要答案以获得更好的示例。 - snk_kid
@dicroce:一个制作一个空白对象,一个制作副本。如果存储在对象中的数据很大,则副本可能很昂贵。例如,std :: vector。 - Billy ONeal
@ kunj2aan:我怀疑这取决于你的编译器。编译器可以在函数内部创建一个临时对象,然后将其移动到调用者的返回值中。或者,它可以在返回值中直接构造对象,而无需使用移动构造函数。 - Greg Hewgill
@Jichao:这是一个名为RVO的优化,有关差异的更多信息,请参阅此问题: stackoverflow.com/questions/5031778/... - Greg Hewgill


如果你真的对移动语义的一个好的,深入的解释感兴趣,我强烈建议你阅读原始论文, “一项为C ++语言添加移动语义支持的提案。” 

它非常易于阅读,并且可以为它们提供的好处提供优质服务。还有其他关于移动语义的最新和最新论文 WG21网站,但这个可能是最直接的,因为它从顶层视图处理事物并且没有深入到粗略的语言细节。


27
2018-06-23 23:32





移动语义 是关于 转移资源而不是复制它们 当没人需要源值时。

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

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


21
2018-04-08 19:47





简单(实用)术语:

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

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

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

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


19
2017-08-18 15:57





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


17
2018-06-23 22:56