一尘不染

什么是移动语义?

c++

我刚刚听完Scott Meyers关于C0x的软件工程广播播客采访。大多数新特性对我来说都是有意义的,我现在对 C0x 感到非常兴奋,除了一个。我仍然没有得到移动语义......它到底是什么?


阅读 128

收藏
2022-06-09

共2个答案

一尘不染

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

#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 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 行中的参数不是左值,而是右值,因为底层的字符串对象没有名称,因此客户端无法在以后再次检查它们。右值表示在下一个分号处销毁的临时对象(更准确地说:在词法上包含右值的完整表达式的末尾)。b这很重要,因为在and的初始化过程中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;
    }
};

咦,就这样?“右值引用在哪里?” 你可能会问。“我们这里不需要!” 是我的答案:)

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

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

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

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

我希望这个例子能理解重点。右值引用和移动语义还有很多,我故意省略了这些以保持简单。

2022-06-09
一尘不染

我的第一个答案是对移动语义进行了极其简化的介绍,并且为了保持简单而故意省略了许多细节。然而,还有很多东西需要移动语义,我认为是时候提供第二个答案来填补空白了。第一个答案已经很老了,简单地用完全不同的文本替换它感觉不对。我认为它仍然可以作为第一次介绍。但是,如果您想更深入地挖掘,请继续阅读:)

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

介绍

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

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

```cpp
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

   // ...

};
```

  1. 实现安全的“只移动”类型;也就是说,复制没有意义但移动有意义的类型。示例包括具有唯一所有权语义的锁、文件句柄和智能指针。注意:这个答案讨论了一个已弃用的 C98 标准库模板,在 C11std::auto_ptr中被替换为。std::unique_ptr中级 C++ 程序员可能至少对std::auto_ptr. YMMV。

什么是move?

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

注意bwith的初始化a不会复制三角形,而是将三角形的所有权从转移aba我们也说“移入 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
}

Dangerous and harmless moves

危险的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

请注意,字母lr在分配的左侧和右侧具有历史渊源。这在 C++ 中不再适用,因为有些左值不能出现在赋值的左侧(如数组或没有赋值运算符的用户定义类型),而有些右值可以(类类型的所有右值)带有赋值运算符)。

类类型的右值是一个表达式,其求值会创建一个临时对象。在正常情况下,同一作用域内没有其他表达式表示同一个临时对象。

Rvalue references

我们现在明白,从左值移动是有潜在危险的,但从右值移动是无害的。如果 C++ 有语言支持来区分左值参数和右值参数,我们可以完全禁止从左值移动,或者至少在调用站点明确地从左值移动,这样我们就不会再意外移动了。

C11 对这个问题的回答是rvalue references。右值引用是一种只绑定到右值的新引用,语法是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&&是一种仅绑定到右值的新型引用。

Implicit conversions

右值引用经历了几个版本。从 2.1 版开始,右值引用X&&还绑定到不同类型的所有值类别Y,前提是存在从Yto的隐式转换X。在这种情况下,会创建一个临时类型X,并且右值引用绑定到该临时:

void some_function(std::string&& r);

some_function("hello world");

在上面的例子中,"hello world"是一个类型的左值const char[12]。由于存在从 to 的隐式转换const char[12]const char*因此创建std::string了一个临时类型,并绑定到该临时。这是右值(表达式)和临时值(对象)之间的区别有点模糊的情况之一。std::string``r

移动构造函数

带有X&&参数的函数的一个有用示例是移动构造函数 X::X(X&& source)。其目的是将托管资源的所有权从源转移到当前对象。

在 C++11 中,std::auto_ptr<T>已被替换为std::unique_ptr<T>利用右值引用的。我将开发和讨论unique_ptr. 首先,我们封装一个原始指针并重载运算符->and *,所以我们的类感觉就像一个指针:

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

请注意移动赋值运算符的这种实现如何复制析构函数和移动构造函数的逻辑。你熟悉复制和交换的习语吗?它也可以作为 move-and-swap 习语应用于移动语义:

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

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

移动赋值运算符将托管资源的所有权转移到当前对象,释放旧资源。move-and-swap 习惯用法简化了实现。

Moving from lvalues

有时,我们想从左值转移。也就是说,有时我们希望编译器将左值视为右值,因此它可以调用移动构造函数,即使它可能是不安全的。为此,C++11 提供了一个std::move在 header 内部调用的标准库函数模板<utility>。这个名字有点不幸,因为std::move只是将左值转换为右值;它本身不会移动任何东西。它仅允许移动。也许它应该被命名为std::cast_to_rvalueor 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)是右值,它的评估也不会创建临时对象。这个难题迫使委员会引入第三个价值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也称为xvalue(eXpiring 值)。传统的右值被重命名为纯右值(Pure rvalues)

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

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

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

C98 右值在 C11 中称为右值。将前面段落中所有出现的“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,并且不仅仅是将右值绑定到右值引用。

Moving into members

迟早,您将编写如下代码:

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

您可能会争辩说,parametermember. 为什么没有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 按需声明了两个额外的特殊成员函数:移动构造函数和移动赋值运算符。请注意,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&&。这是任何人所期望的。但是,如果参数是 type 的左值X,由于特殊规则,T推导出为X&,因此T&&意味着类似X& &&。但是由于 C++ 仍然没有引用引用的概念,所以类型X& &&折叠X&. 起初这可能听起来令人困惑和无用,但引用折叠对于完美转发是必不可少的(这里将不讨论)。

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

如果要将函数模板约束为右值,可以将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接受任何类型的参数,并返回一个右值引用。元函数调用是必要的,因为否则,对于 type 的左值T&&,返回类型将是,它会折叠成。由于始终是左值(请记住,命名的右值引用是左值),但我们想要绑定到右值引用,我们必须显式转换为正确的返回类型。返回右值引用的函数调用本身就是一个 xvalue。现在您知道 xvalues 的来源;)std::remove_reference<T>::type``X``X& &&``X&``t``t``t

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

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

2022-06-09