什么是移动语义?

我刚刚结束了对Scott Meyers 进行的有关C ++ 0x的 Software Engineering 广播播客采访 。大多数新功能对我来说都是有意义的,除了一个功能,我现在对 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);
    }

复制构造函数定义复制字符串对象的含义。绑定到 string 类型的所有表达式的参数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 行中的参数不是左值,而是右值,因为基础字符串对象没有名称,因此客户端无法在以后的时间再次检查它们。 rvalues 表示在下一个分号处销毁的临时对象(更精确地说:在词法上包含 rvalue 的完整表达式的末尾)。这很重要,因为在bc的初始化过程中,我们可以对源字符串做任何想做的事情,而客户端无法分辨

C ++ 0x 引入了一种称为 “rvalue 引用” 的新机制,该机制除其他外,使我们能够通过函数重载来检测 rvalue 参数。我们要做的就是编写一个带有右值引用参数的构造函数。在该构造函数中,我们可以对源执行任何操作 ,只要将其保持在某个有效状态即可:

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 按值 ,因此that必须要像任何其他字符串对象初始化。究竟如何that要被初始化?在过去的C ++ 98 中 ,答案应该是 “通过复制构造函数”。在 C ++ 0x 中,编译器根据赋值运算符的参数是左值还是右值,在复制构造函数和 move 构造函数之间进行选择。

所以,如果你说a = b拷贝构造函数将初始化that (因为表达b是一个左值),并赋值运算符互换与刚创建,深拷贝的内容。这就是复制和交换惯用法的确切定义 - 制作一个副本,将内容与该副本交换,然后通过保留范围来摆脱该副本。这里没有新内容。

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

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

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

我的第一个答案是对移动语义的极其简化的介绍,故意保留许多细节以使其保持简单。但是,还有更多的语义可移动性,我认为是时候第二个答案来填补空白了。第一个答案已经很老了,用简单的完全不同的文本替换它是不合适的。我认为它仍然可以很好地作为第一个介绍。但是,如果您想深入了解,请继续阅读:)

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于其 “复制” 行为:

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()什么make_triangle() ?他们不是同一个类型吗?确实是,但是它们具有不同的价值类别

值类别

显然,表达式a (表示auto_ptr变量)与表达式make_triangle() (表示按值返回auto_ptr的函数的调用make_triangle()之间必须存在深远的区别,因此每次调用时都会创建一个新的临时auto_ptr对象。 a是一个左值的示例,而make_triangle()是一个右值的示例。

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

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

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

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

右值参考

我们现在知道,从左值移出有潜在的危险,但是从右值移出是无害的。如果 C ++ 具有支持将左值参数与右值参数区分开的语言,则我们可以完全禁止从左值移动,或者至少在调用站点上明确禁止从左值移动,这样我们就不会再偶然移动了。

C ++ 11 对这个问题的答案是右值引用 。右值引用是仅绑定到右值的一种新型引用,语法为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 开始,假设存在从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的临时std::string ,并将r绑定到该临时变量。这是右值(表达式)和临时对象(对象)之间的区别有点模糊的情况之一。

移动构造函数

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

在 C ++ 11 中, std::auto_ptr<T>已被std::unique_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;
    }

这个 move 构造函数的功能与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()是一个右值。 move 构造函数会将所有权从临时所有权转让给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 构造函数的逻辑。您熟悉复制和交换习惯吗?它也可以作为移动和交换的惯用法应用于移动语义:

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

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

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

从左值转变

有时,我们想从左值转移。也就是说,有时我们希望编译器将左值当作右值对待,因此它可以调用 move 构造函数,即使它可能是不安全的。为此,C ++ 11 在标头<utility>提供了一个称为std::move的标准库函数模板。这个名字有点不幸,因为std::move只是将一个左值转换为一个右值。它本身不会移动任何东西。它仅允许移动。也许它应该被命名为std::cast_to_rvaluestd::enable_move ,但是std::cast_to_rvalue ,我们仍然使用这个名称。

这是您从左值显式移动的方式:

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 值)。传统的右值被重命名为prvalue (纯右值)。

prvalue 和 xvalue 均为 rvalue。 X 值和左值都是glvalues (广义左值 )。使用图更容易理解这些关系:

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

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

C ++ 98 右值在 C ++ 11 中称为 prvalue。用 “prvalue” 替换前面段落中所有出现的 “rvalue”。

移出功能

到目前为止,我们已经看到了向局部变量和函数参数的移动。但是也可以朝相反的方向移动。如果一个函数按值返回,则调用站点上的某些对象(可能是局部变量或临时对象,但可以是任何类型的对象)都用return语句后的表达式作为 move 构造函数的参数进行初始化:

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
}

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

切勿通过右值引用返回自动对象。移动仅由 move 构造函数执行,而不是由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 根据需要声明了两个附加的特殊成员函数:move 构造函数和 move 赋值运算符。请注意,VC10 和 VC11 都不符合版本 3.0,因此您必须自己实现它们。

X::X(X&&);                   // move constructor
X& X::operator=(X&&);        // move assignment operator

仅当没有手动声明任何特殊成员函数时,才隐式声明这两个新的特殊成员函数。同样,如果您声明自己的 move 构造函数或 move 赋值运算符,则不会隐式声明副本构造函数或副本赋值运算符。

这些规则在实践中意味着什么?

如果编写没有不受管资源的类,则无需自己声明五个特殊成员函数中的任何一个,您将获得正确的复制语义并免费移动语义。否则,您将必须自己实现特殊的成员函数。当然,如果您的类没有从移动语义中受益,则无需实现特殊的移动操作。

请注意,可以将复制赋值运算符和移动赋值运算符融合为单个统一赋值运算符,并按值取其参数:

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&&都是左值引用。

如果要将函数模板限制为右值,可以将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。现在您知道了 xvalue 的来源;)

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

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

移动语义基于右值引用
一个右值是一个临时对象,它将在表达式末尾销毁。在当前的 C ++ 中,右值仅绑定到const引用。 C ++ 1X 将允许非const rvalue 引用,斯佩尔特T&& ,这对于一个右值对象的引用。
由于右值将在表达式的末尾消失,因此您可以窃取其数据 。无需复制到另一个对象中,而是其数据移入其中。

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的 copy 构造函数将f()的结果复制x 。如果您的编译器支持 move 语义,并且X具有 move-constructor,则将调用它。由于它的rhs参数是一个rvalue ,我们知道它不再需要了,我们可以窃取它的值。
因此,值将从f()返回的未命名临时变量移动x (同时将初始化为空Xx数据移入临时变量,在赋值后将被销毁)。

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

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

当您编写这样的代码时:

Matrix r = multiply(a, b);

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

如果(例如上面的Matrix示例)复制的对象在堆上分配了额外的内存来存储其内部表示,则这一点尤其重要。复制构造函数必须要么完整复制内部表示形式,要么在内部使用引用计数和写时复制语义。移动构造函数将只保留堆内存,而仅将指针复制到Matrix对象中。

如果您真的真正想对动作语义学有一个很好的深入的解释,我强烈建议您阅读有关它们的原始论文, “向 C ++ 语言添加动作语义支持的建议”。

它非常易于访问且易于阅读,并且很好地说明了它们提供的好处。 WG21 网站上还有其他有关移动语义的最新文章,但这可能是最直接的,因为它是从顶级角度处理问题的,并且对粗俗的语言细节了解不多。

移动语义是关于转移资源,而不是在没有人再需要源值时复制资源

在 C ++ 03 中,通常会复制对象,仅在任何代码再次使用该值之前将其销毁或分配。例如,当您从函数中按值返回时(除非 RVO 插入),您将返回的值复制到调用方的堆栈框架中,然后超出范围并被销毁。这只是许多示例之一:当源对象是临时对象时,请参见传递值;仅对项进行重新sort算法;在超过其capacity()时,在vector进行重新分配等。

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

用简单(实用)的术语来说:

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

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

这导致了 “右值” 引用的概念。它们存在于 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将接收该对象作为未命名对象。当然,如果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都是实际作用域中的命名对象,但是从调用者作用域的角度来看, T包含其 “引用类型” 的信息。该信息( T )作为模板参数被传递到forward和 “a” 为根据的类型移动或不T

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

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

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

您什么时候要这么做?好 std :: vector 就是一个例子,假设您创建了一个临时的 std :: vector,并从一个函数返回它,例如:

std::vector<foo> get_foos();

函数返回时,如果(在 C ++ 0x 中)std :: vector 具有移动构造函数(而不是复制它),则复制构造函数将产生开销,只需设置其指针并动态分配 “移动” 即可内存到新实例。这有点类似于 std :: auto_ptr 的所有权转移语义。

我正在写这篇文章,以确保我理解正确。

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

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

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

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