1938

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

4

11 回答 11

2757

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

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

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

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

于 2010-06-24T12:40:47.393 回答
1173

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

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. 实现安全的“只移动”类型;也就是说,复制没有意义但移动有意义的类型。示例包括具有唯一所有权语义的锁、文件句柄和智能指针。注意:这个答案讨论了一个已弃用的 C++98 标准库模板,在 C++11std::auto_ptr中被替换为。std::unique_ptr中级 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 | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

注意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
}

危险和无害的动作

危险的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_ptramake_triangle()

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

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

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

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

右值引用

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

C++11 对这个问题的回答是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&&是一种仅绑定到右值的新型引用。

隐式转换

右值引用经历了几个版本。从 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::stringr

移动构造函数

带有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 习惯用法简化了实现。

从左值移动

有时,我们想从左值转移。也就是说,有时我们希望编译器将左值视为右值,因此它可以调用移动构造函数,即使它可能是不安全的。为此,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)将左值转换为右值,从而启用后续移动。

X值

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

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

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

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

C++98 右值在 C++11 中称为右值。将前面段落中所有出现的“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
    {}
};

您可能会争辩说,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>::typeXX& &&X&ttt

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

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

于 2012-07-18T11:24:27.390 回答
63

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

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

当你写这样的代码时:

Matrix r = multiply(a, b);

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

如果(可能像Matrix上面的示例),被复制的对象在堆上分配额外的内存来存储其内部表示,这一点尤其重要。复制构造函数必须要么制作内部表示的完整副本,要么使用引用计数和写时复制语义。移动构造函数将单独保留堆内存并仅将指针复制到Matrix对象内。

于 2010-06-23T22:53:31.390 回答
30

Move semantics is about transferring resources rather than copying them when nobody needs the source value anymore.

In C++03, objects are often copied, only to be destroyed or assigned-over before any code uses the value again. For example, when you return by value from a function—unless RVO kicks in—the value you're returning is copied to the caller's stack frame, and then it goes out of scope and is destroyed. This is just one of many examples: see pass-by-value when the source object is a temporary, algorithms like sort that just rearrange items, reallocation in vector when its capacity() is exceeded, etc.

When such copy/destroy pairs are expensive, it's typically because the object owns some heavyweight resource. For example, vector<string> may own a dynamically-allocated memory block containing an array of string objects, each with its own dynamic memory. Copying such an object is costly: you have to allocate new memory for each dynamically-allocated blocks in the source, and copy all the values across. Then you need deallocate all that memory you just copied. However, moving a large vector<string> means just copying a few pointers (that refer to the dynamic memory block) to the destination and zeroing them out in the source.

于 2012-04-08T19:47:56.793 回答
30

如果您真的对移动语义的良好、深入的解释感兴趣,我强烈建议您阅读关于它们的原始论文“A Proposal to Add Move Semantics Support to the C++ Language”。

它非常易于阅读且易于阅读,并且很好地说明了它们提供的好处。在 WG21 网站上还有其他关于移动语义的最新论文,但这一篇可能是最直接的,因为它从顶层视图处理事情,并没有深入了解坚韧不拔的语言细节。

于 2010-06-23T23:32:29.323 回答
28

用简单(实用)的术语:

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

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

这导致了“右值”引用的概念。它们存在于 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应该被“复制”时,编译器会根据传递的对象是否被命名来创建一个左值引用或一个右值引用。如果没有,您的移动构造函数被调用并且您知道对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。

重要的是要记住“静态”对象总是被复制的。没有办法“移动”静态对象(堆栈中的对象而不是堆中的对象)。因此,当对象没有动态成员(直接或间接)时,“移动”/“复制”的区别是无关紧要的。

如果您的对象很复杂并且析构函数具有其他次要效果,例如调用库的函数、调用其他全局函数或其他任何函数,那么使用标志来表示运动可能会更好:

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::movea转换为右值并将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( T = A&) 的左值引用,a也是 ( A& && => A&)。如果T是对 的右值引用Aa也是 (A&& && => A&&)。在这两种情况下,a都是实际作用域中的命名对象,但从T调用者作用域的角度来看,它包含其“引用类型”的信息。此信息 ( T) 作为模板参数传递给forward'a' 根据 的类型移动或不移动T

于 2013-08-18T15:57:19.767 回答
24

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

于 2010-06-23T22:56:46.173 回答
14

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

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

你什么时候想这样做?好吧 std::vector 就是一个例子,假设你创建了一个临时的 std::vector 并且你从一个函数返回它说:

std::vector<foo> get_foos();

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

于 2010-06-23T22:58:10.103 回答
9

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

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

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

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

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

于 2016-11-18T23:12:12.993 回答
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重要区别:右值引用可以移动,而左值不能。

来自cppreference.com

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

于 2016-02-25T00:00:04.973 回答
-2

这是Bjarne Stroustrup 的《C++ 编程语言》一书中的答案。如果不想看视频,可以看下面的文字:

考虑这个片段。从 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 而不是复制它。我们可以如下定义移动构造函数:

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

&& 表示“右值引用”,是我们可以绑定右值的引用。“右值”旨在补充“左值”,“左值”大致意思是“可以出现在赋值左侧的东西”。所以右值大致意思是“一个你不能赋值的值”,比如函数调用返回的整数,以及resvectors的operator+()中的局部变量。

现在,该语句return res;不会复制!

于 2020-04-25T05:19:17.363 回答