繁体   English   中英

什么是移动语义?

[英]What is move semantics?

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

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

#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之类的表达式为“左值”。

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

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

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

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

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

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

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

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

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

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 中讨论移动语义的一个很好的起点。 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();

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

价值类别

显然,表示auto_ptr变量的表达式a和表示调用按值返回auto_ptr的函数的表达式make_triangle()之间肯定存在一些深刻的区别,因此每次调用时都会创建一个新的临时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++ 中不再适用,因为有些左值不能出现在赋值的左侧(如数组或没有赋值运算符的用户定义类型),而有些右值可以(类类型的所有右值)带有赋值运算符)。

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

右值引用

我们现在明白,从左值移动是有潜在危险的,但从右值移动是无害的。 如果 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的所有值类别,前提是存在从YX的隐式转换。 在这种情况下,会创建一个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绑定到该临时变量。 这是右值(表达式)和临时值(对象)之间的区别有点模糊的情况之一。

移动构造函数

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

请注意移动赋值运算符的这种实现如何复制析构函数和移动构造函数的逻辑。 你熟悉复制和交换的习语吗? 它也可以作为 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 在标头<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) ,我们明确了我们的意图:“亲爱的构造函数,为初始化c做任何你想做a事情;我不再关心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
    {}
};

您可以争辩在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

右值引用经历了几个版本。 从 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类型的右值,则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。 现在您知道 xvalues 的来源;)

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

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

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

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

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

当你写这样的代码时:

Matrix r = multiply(a, b);

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

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

移动语义是关于转移资源而不是在不再需要源值时复制它们

在 C++03 中,对象经常被复制,只是在任何代码再次使用该值之前被销毁或分配。 例如,当你从一个函数中按值返回时——除非 RVO 启动——你返回的值被复制到调用者的栈帧中,然后它超出范围并被销毁。 这只是众多示例之一:当源对象是临时对象时,请参见按值传递,仅重新排列项目的sort算法,超过capacity()时在vector中重新分配等算法。

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

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

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

用简单(实用)的术语:

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

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

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

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

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

std::vector<foo> get_foos();

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

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

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

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

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

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

为了说明移动语义的必要性,让我们考虑这个没有移动语义的例子:

这是一个函数,它接受一个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&&)。

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

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM