协慌网

登录 贡献 社区

什么是移动语义?

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

答案

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

#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 “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,而不是深度复制堆数据。实际上,我们 “窃取” 了原来属于源字符串的数据。同样,关键的见解是,在任何情况下客户都无法检测到源已被修改。由于我们在这里没有真正复制,我们称这个构造函数为 “移动构造函数”。它的工作是将资源从一个对象移动到另一个对象而不是复制它们。

恭喜,您现在了解移动语义的基础知识!让我们继续实现赋值运算符。如果您不熟悉复制和交换习惯用法 ,请学习它并返回,因为它是一个与异常安全相关的令人敬畏的 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 引用和移动语义还有很多,我故意省略它以保持简单。如果您想了解更多详情,请参阅我的补充答案

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

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 标准库模板,它在 C ++ 11 中被std::unique_ptr取代。中级 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 | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

注意如何初始化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的危险之处在于语法上看起来像副本实际上是一个动作。尝试在移动的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();

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

价值类别

显然,必须有表达之间的一些深刻差a其中表示auto_ptr表达变量, make_triangle()其表示返回一个函数的调用auto_ptr由值,从而产生一个新的临时auto_ptr每次调用时间目标。 a左值的示例,而make_triangle()右值的示例。

从左值如移动a是危险的,因为我们以后可以尝试通过调用一个成员函数a ,调用未定义的行为。另一方面,从诸如make_triangle()类的make_triangle()移动是非常安全的,因为在复制构造函数完成其工作之后,我们不能再次使用临时工具。没有表示所述临时表达的表达; 如果我们再次编写make_triangle() ,我们会得到一个不同的临时。事实上,移动临时已经在下一行中消失了:

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

请注意,字母lr在赋值的左侧和右侧具有历史原点。这在 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 开始,如果存在从YX的隐式转换,则右值引用X&&也会绑定到不同类型Y所有值类别。在这种情况下,创建一个临时的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;
    }
};

既然sourceunique_ptr类型的变量,它将由 move 构造函数初始化; 也就是说,参数将被移动到参数中。该参数仍然需要是一个 rvalue,因为移动构造函数本身有一个 rvalue 引用参数。当控制流到达operator=的右括号时, source超出范围,自动释放旧资源。

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

从左值移动

有时,我们想要从左手边移动。也就是说,有时我们希望编译器将左值视为 rvalue,因此它可以调用移动构造函数,即使它可能是不安全的。为此,C ++ 11 在头文件<utility>提供了一个名为std::move的标准库函数模板。这个名字有点不幸,因为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)将左值转换为右值,从而启用后续移动。

Xvalues

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

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 ,而不是仅仅通过将 rvalue 绑定到右值引用。

进入成员

迟早,你要编写这样的代码:

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

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

如果参数是类型X的右值,则推导出TX ,因此T&&表示X&& 。这是任何人都期望的。但是如果参数是类型X的左值,由于特殊规则, T被推导为X& ,因此T&&意味着像X& && 。但是由于 C ++ 仍然没有引用引用的概念,因此类型X& &&折叠X& 。这可能听起来有点困惑和无用,但参考折叠对于完美转发至关重要(这里不再讨论)。

T && 不是右值引用,而是转发引用。它也绑定到左值,在这种情况下, TT&&都是左值引用。

如果要将函数模板约束为 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);
}

如您所见,由于转发引用T&&move接受任何类型的参数,并返回一个右值引用。 std::remove_reference<T>::type元函数调用是必要的,否则,对于类型X左值,返回类型将是X& && ,它将折叠为X& 。因为t总是一个左值(记住一个命名的右值引用是一个左值),但我们想将t绑定到一个右值引用,我们必须显式地将t转换为正确的返回类型。返回 rvalue 引用的函数调用本身就是一个 xvalue。现在你知道 xvalues 的来源了;)

返回 rvalue 引用的函数(如std::move的调用是 xvalue。

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

移动语义基于右值引用
rvalue 是一个临时对象,它将在表达式的末尾被销毁。在当前的 C ++ 中,rvalues 只绑定到const引用。 C ++ 1X 将允许非const rvalue 引用,斯佩尔特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

在上面的代码中,使用旧的编译器,使用X的复制构造函数将f()的结果复制x 。如果您的编译器支持移动语义而X有一个移动构造函数,则会调用它。由于它的rhs参数是一个rvalue ,我们知道它不再需要它,我们可以窃取它的价值。
所以值从无名临时从返回移动 f()x (而数据x ,初始化为空X ,移动到暂时的,这将在转让之后被摧毁)。