首页 文章

什么是移动语义?

提问于
浏览
1456

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

11 回答

  • 2152

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

    Stephan T. Lavavej花时间提供有 Value 的反馈 . 非常感谢,斯蒂芬!

    简介

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

    • 将昂贵的副本变成便宜的动作 . 请参阅我的第一个答案 . 请注意,如果对象不管理至少一个外部资源(直接或间接通过其成员对象),则移动语义将不会提供优于复制语义的任何优势 . 在这种情况下,复制对象和移动对象意味着完全相同的事情:
    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
    
        // ...
    };
    
    • 实施安全"move-only"类型;也就是说,复制没有意义,但移动的类型 . 示例包括锁,文件句柄和具有唯一所有权语义的智能指针 . 注意:这个答案讨论了 std::auto_ptr ,一个已弃用的C98标准库模板,在C11中被 std::unique_ptr 取代 . 中级C程序员可能至少对 std::auto_ptr 有点熟悉,并且由于它显示的是"move semantics",它似乎是一个好的开始在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 的不寻常的事情是它的"copying"行为:

    auto_ptr<Shape> a(new Triangle);
    
          +---------------+
          | triangle data |
          +---------------+
            ^
            |
            |
            |
      +-----|---+
      |   +-|-+ |
    a | p | | | |
      |   +---+ |
      +---------+
    
    auto_ptr<Shape> b(a);
    
          +---------------+
          | triangle data |
          +---------------+
            ^
            |
            +----------------------+
                                   |
      +---------+            +-----|---+
      |   +---+ |            |   +-|-+ |
    a | p |   | |          b | p | | | |
      |   +---+ |            |   +---+ |
      +---------+            +---------+
    

    请注意 ba 的初始化不会复制三角形,而是将三角形的所有权从 a 转移到 b . 我们还说“ a 被移入 b " or ",三角形从 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() 有什么区别?它们不是同一类型吗?确实如此,但它们有不同的 Value 类别 .

    值类别

    显然,表达式 a 表示 auto_ptr 变量,表达式 make_triangle() 表示调用返回 auto_ptr 值的函数,因此每次调用时都会创建一个新的临时 auto_ptr 对象 . a 是左值的示例,而 make_triangle() 是右值的示例 .

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

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

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

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

    Rvalue参考

    我们现在明白从左值移动是有潜在危险的,但从右值移动是无害的 . 如果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 类型的变量,它将由移动构造函数初始化;也就是说,参数将被移动到参数中 . 该参数仍然需要是一个rvalue,因为移动构造函数本身有一个rvalue引用参数 . 当控制流到达 operator= 的右括号时, source 超出范围,自动释放旧资源 .

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

    从左值移动

    有时,我们想要从左手边移动 . 也就是说,有时我们希望编译器将左值视为rvalue,因此它可以调用移动构造函数,即使它可能是不安全的 . 为此,C 11在 Headers <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
    

    注意在第三个之后line, a 不再拥有一个三角形 . 没关系,因为通过明确写出 std::move(a) ,我们明确表达了意图:“亲爱的构造函数,用 a 做任何你想要的事情来初始化 c ;我不再关心 a 了 . 随意用 a . ”

    std :: move(some_lvalue)将左值转换为右值,从而启用后续移动 .

    Xvalues

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

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

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

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

    C 98 rvalues在C 11中称为prvalues . 用“prvalue”在前面的段落中用“prvalue”替换所有出现的“rvalue” .

    搬出功能

    到目前为止,我们已经看到了局部变量和函数参数的变化 . 但是在相反的方向上移动也是可能的 . 如果函数按值返回,则调用站点上的某个对象(可能是局部变量或临时变量,但可以是任何类型的对象)使用 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 将自动对象移出函数,因为这会禁止"named return value optimization"(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 是左值 . 如果查看其类型,则会看到右值引用,但右值引用只是表示"a reference that is bound to an rvalue";它并不意味着引用本身就是一个右值!实际上, 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 类型的右值,则 T 被推断为 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);
    }
    

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

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

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

  • 26

    移动语义基于 rvalue references .
    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
    

    在上面的代码中,对于旧的编译器,使用 X 的复制构造函数将 f() 的结果 copied 改为 x . 如果您的编译器支持移动语义并且 X 具有移动构造函数,则会调用它 . 由于它的 rhs 论证是一个rvalue,我们知道它不再需要它,我们可以窃取它的 Value .
    因此,从 f() 返回到 x 的未命名临时值为 moved (而 x 的数据,初始化为空 X ,被移入临时,在分配后将被销毁) .

  • 72

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

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

    当你编写这样的代码时:

    Matrix r = multiply(a, b);
    

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

    如果(如上面的 Matrix 示例),正在复制的对象在堆上分配额外的内存来存储其内部表示,这一点尤为重要 . 复制构造函数必须制作内部表示的完整副本,或者使用从属计算引用计数和写时复制语义 . 移动构造函数会单独留下堆内存,只需将指针复制到 Matrix 对象中 .

  • 29

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

    这是一个函数,它接受 T 类型的对象并返回相同类型的对象 T

    T f(T o) { return o; }
      //^^^ new object constructed
    

    上面的函数使用call by value,这意味着当调用此函数时,必须构造一个对象以供函数使用 .
    因为函数也按值返回,所以为返回值构造了另一个新对象:

    T b = f(a);
      //^ new object constructed
    

    Two 已经构造了新对象,其中一个是临时对象,仅用于函数的持续时间 .

    从返回值创建新对象时,将调用复制构造函数以将临时对象的内容复制到新对象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 semantics ,现在可以通过简单地移动数据而不是复制来减少大部分工作 .

    // 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来在需要重新定位容器元素时在移动和复制之间进行选择 . 如果同时提供了复制和移动构造函数,则重载解析选择移动构造函数(如果参数是rvalue(prvalue,如无名临时或xvalue,如std :: move的结果),并选择复制构造函数,如果参数是左值(命名对象或返回左值引用的函数/运算符) . 如果只提供了复制构造函数,则所有参数类别都会选择它(只要它引用const,因为rvalues可以绑定到const引用),这使得当移动不可用时复制后退以进行移动 . 在许多情况下,移动构造函数会被优化,即使它们会产生可观察到的副作用,请参阅copy elision . 当构造函数将rvalue引用作为参数时,它被称为“移动构造函数” . 它没有义务移动任何东西,类不需要移动资源,并且“移动构造函数”可能无法移动资源,如在参数为a的允许(但可能不合理)的情况下const rvalue reference(const T &&) .

  • 13

    当没有人需要源值时, Move semantics 约为 transferring resources rather than copying them .

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

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

  • 5

    简单(实用)术语:

    复制对象意味着复制其"static"成员并为其动态对象调用 new 运算符 . 对?

    class A
    {
       int i, *p;
    
    public:
       A(const A& a) : i(a.i), p(new int(*a.p)) {}
       ~A() { delete p; }
    };
    

    但是,对于 move 一个对象(我重复一遍,从实际的角度来看)意味着只复制动态对象的指针,而不是创建新的指针 .

    但是,那不危险吗?当然,您可以两次破坏动态对象(分段错误) . 因此,为避免这种情况,您应该“使源指针无效”以避免两次破坏它们:

    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());
    

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

    这导致了"rvalue"参考的概念 . 它们仅存在于C 11中以检测所接收的对象是否是匿名的 . 我想你已经知道"lvalue"是一个可赋值实体( = 运算符的左边部分),所以你需要一个对象的命名引用才能充当左值 . 右值正好相反,没有命名引用的对象 . 因此,匿名对象和右值是同义词 . 所以:

    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 类型的对象应为"copied"时,编译器将根据传入的对象是否已命名来创建左值引用或右值引用 . 如果没有,您的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&& 之间有什么区别?当然,在第一种情况下,你可以修改对象而在第二种情况下,但是,实际意义?在第二种情况下,您无法修改它,因此您无法使对象无效(除了带有可变标志或类似的东西),并且复制构造函数没有实际区别 .

    什么是 perfect forwarding ?重要的是要知道"rvalue reference"是对"caller's scope"中命名对象的引用 . 但在实际范围中,右值引用是对象的名称,因此,它充当命名对象 . 如果将rvalue引用传递给另一个函数,则传递的是命名对象,因此,不会像临时对象那样接收该对象 .

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

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

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

    因此,如果 TAT = A&)的左值引用, a 也是( A& && => A&) . 如果 TA 的右值引用, a 也是(A && && => A &&) . 在这两种情况下, a 是实际范围中的命名对象,但 T 包含来自调用者范围的"reference type"的信息 . 此信息( T )作为模板参数传递给 forward ,并根据 T 的类型移动'a' .

  • 22

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

  • 7

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

    移动语义基本上是一个用户定义的类型,其构造函数采用r值引用(使用&&(是两个&符号)的新引用类型)非const,这称为移动构造函数,同样适用于赋值运算符 . 那么移动构造函数是做什么的,而不是从它的源参数中复制内存,它将内存从源移动到目标 .

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

    std::vector<foo> get_foos();
    

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

  • 19

    如果你真的对移动语义的深入解释感兴趣,我强烈建议你阅读原始论文,"A Proposal to Add Move Semantics Support to the C++ Language."

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

  • 929

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

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

    由于我们自己选择管理内存,因此我们需要遵循rule of three . 我将推迟编写赋值运算符,现在只实现析构函数和复制构造函数:

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

    恭喜,您现在了解移动语义的基础知识!让's continue by implementing the assignment operator. If you'不熟悉copy and swap idiom,学习它并回来,因为它是一个与异常安全相关的令人敬畏的C语言 .

    string& operator=(string that)
        {
            std::swap(data, that.data);
            return *this;
        }
    };
    

    嗯,就是这样吗? “右值参考在哪里?”你可能会问 . “我们这里不需要它!”是我的答案:)

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

    因此,如果您说 a = b ,则复制构造函数将初始化 that (因为表达式 b 是左值),并且赋值运算符使用新创建的深层副本交换内容 . 这就是复制和交换习惯用语的定义 - 制作副本,将内容与副本交换,然后通过离开作用域来删除副本 . 这里没什么新鲜的 .

    但是如果你说 a = x + y ,移动构造函数将初始化 that (因为表达式 x + y 是一个右值),所以没有深度复制,只有一个有效的移动 . that 仍然是参数的一个独立对象,但它的构造是微不足道的,因为堆数据不需要复制它,因为 x + y 是一个右值,并且再次可以从rvalues表示的字符串对象移动 .

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

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

  • 52

    我写这篇文章是为了确保我理解它 .

    创建了移动语义以避免不必要的大型对象复制 . Bjarne Stroustrup在他的书“The C Programming Language”中使用了两个例子,默认情况下会发生不必要的复制:一个是交换两个大对象,另外两个是从一个方法返回一个大对象 .

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

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

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

相关问题