返回

C++ 中移动语义的用法和实现原理

对 C++ 中左、右值,引用以及移动语义相关概念进行整理。

简介

在学习 C++ 时尝尝会接触到左/右值,左/右值引用以及移动等概念。直接根据标准理解稍微有点抽象。这篇博客尝试从实际使用的场景下出发来引入左值和右值引用的概念,以及标准中配套的一些操作(移动语义、转发等)。并且结合编译后的汇编代码来看编译器是如何对这些概念进行实现。注:不同编译器的实现原理可能不同,使用编译器只是为了理解这些语法概念在编译器层面上是如何实现而已。以下代码测试环境为:

  • gcc 9.3.0
  • Ubuntu 20.04
  • GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
  • Compile Explorer: https://godbolt.org/

左值和右值

首先来看一下左值和右值的区别。首先,我们先粗略地将左值和右值的区别看成是它们的生存周期长短,在操作完变量后还能稳定存在的变量看作是左值,否则为右值(不严谨),接下来结合汇编代码来对其声明周期进行分析。

我们根据颜色来对每一条语句中涉及的表达式进行分析:

int add(int a, int b)
{
    return a + b;
}

int main()
{
    int a = 10;
    int b = a;
    int c = b + 1;
    int sum = add(10, 20);

    int *p = &a;

    return 0;
}
  • int a = 10;

对应汇编为:

mov     DWORD PTR [rbp-28], 10

10 作为一个立即数,直接包含在指令中,连变量都是,因此是右值。a 存在在地址为 rbp-28 对应的栈空间上,初始化后仍然存在,因此是左值。

  • int b = a;

对应汇编为:

    mov     eax, DWORD PTR [rbp-28]
    mov     DWORD PTR [rbp-4], eax

如上所述:a 存在在 [rbp-28] 对应的栈空间上,因此是左值。b 存在地址为 rbp-4 对应的栈空间上,初始化后仍然存在,因此也是左值。

  • int c = b + 1;

对应汇编为:

        mov     eax, DWORD PTR [rbp-4]
        add     eax, 1
        mov     DWORD PTR [rbp-8], eax

可以看到,b + 1 的结果只存在第二步之后的 eax 寄存器上,在语句执行完后不稳定存在,所以是右值。而 c 存在地址为 rbp-8 对应的栈空间上,可以稳定存在,因此是左值。

int a = 10;
  • int sum = add(10, 20);

汇编代码为:

add(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, DWORD PTR [rbp-8]
        add     eax, edx
        pop     rbp
        ret

mov     esi, 20
mov     edi, 10
call    add(int, int)
mov     DWORD PTR [rbp-12], eax

从这段代码里面,可以看到,调用参数时的 1020 为立即数,是右值。而在函数内通过这两个参数构造了两个局部变量,分别在 rbp-4rbp-8 对应的栈空间上,初始化后能够保持稳定,因此函数内的 a, b 为左值。而函数值返回的结果我们可以看到是存放在 eax 寄存器中,不能稳定保存,因此是右值。而 sum 则是存在 rbp-12 对应的栈空间上,能够稳定保存。因此是左值。

  • int *p = &a;

对应的汇编代码为:

        lea     rax, [rbp-28]
        mov     QWORD PTR [rbp-24], rax

显然,&a 结果存放在 rax 寄存器中,不能稳定保存,是右值。p 存放在 rbp-24 对应的栈空间上,能够稳定保存。因此是左值。

这些都是比较浅显的例子。通过这些例子可以对左右值有一个大概的了解。在 C++11 之后,由于移动语义的引入,还出现了将亡值,这些会在后面介绍。注:从例子来看似乎右值都是在寄存器上,但实际上并不一定,例如在函数返回一个类(非引用)时,该类作为临时变量也是一个右值,但有可能保存在栈空间上。

左值引用和右值引用

接下来我们结合汇编代码来看左值引用和右值引用的用法和区别。从语法概念来看的话,左值引用为可以理解为已经存在的变量的别名(alias),而右值引用可以理解为延长临时变量的生命周期。注意,左值引用和右值引用只是 C++ 的语法概念,在编译层面上不是什么特殊操作。下面结合几个例子来观察,编译器对左/右值引用的一种实现方式。

以下是几个例子,分别代表了指针、左值引用和右值引用的初始化和操作。

先看指针部分,cpp 以及汇编代码如下:

    int *p_a = &a;

    *p_a = 20;
    lea     rax, [rbp-44]
    mov     QWORD PTR [rbp-8], rax

    mov     rax, QWORD PTR [rbp-8]
    mov     DWORD PTR [rax], 20

可以看到,指针变量初始化是通过 lea 指令获取 [rbp-44]a 的地址,将其复制到 rbp-8 对应的栈空间即 p_a 上。而在赋值操作时,同样也是先从 rbp-8 对应的栈空间获取地址放在 rax 寄存器中,再对该地址指向的内存空间进行赋值。

接下来看左值引用:

    int& left_ref_a = a;

    left_ref_a = 20;
    lea     rax, [rbp-44]
    mov     QWORD PTR [rbp-16], rax

    mov     rax, QWORD PTR [rbp-16]
    mov     DWORD PTR [rax], 20

通过和指针的比较,不难看出从汇编层面上看,左值引用和指针的原理完全一样。相当于左值引用是指针的一个语法糖而已。本质上引用变量的实现方式还是指针,只是在编译器帮我们完成了取地址和解引用的步骤而已。

接下来看右值引用:

    int&& right_ref_b = 10;

    right_ref_b = 20;
        mov     eax, 10
        mov     DWORD PTR [rbp-40], eax
        lea     rax, [rbp-40]
        mov     QWORD PTR [rbp-24], rax

        mov     rax, QWORD PTR [rbp-24]
        mov     DWORD PTR [rax], 20

int a = 10 的汇编代码 mov DWORD PTR [rbp-44], 10 比较,可以看出:右值引用的初始化过程中编译器做了这么几件事情:

  • rbp-40 对应的栈空间上以立即数 10 初始化了一个临时(局部)变量,这个变量由于没有名字我们没有办法直接对其进行操作
  • 像初始化右值引用(指针)一样, 将 rbp-24 对应的栈空间存放该临时变量的地址
  • 右值引用在操作时,和指针或者左值引用的过程一样

因此从这段代码来看,左值引用和右值引用的区别是:(左值)引用变量绑定的是一个已经初始化的有名字的变量(左值),左值引用绑定的是一个用右值初始化的没有名字的局部变量。一般情况下,左值引用不能绑定右值,但在常引用的时候可以,如下所示:

const int& a = 10; // compiles!

汇编代码为:

        mov     eax, 10
        mov     DWORD PTR [rbp-12], eax
        lea     rax, [rbp-12]
        mov     QWORD PTR [rbp-8], rax

可以看到实现和右值引用一样完全一样,由于这里初始化的局部变量是没有名字的,因此编译器通过语法检查保证我们不会通过左值引用来改变该匿名局部变量。

移动语义 move semantics

通常情况下,左值引用可以作为函数入参避免使用指针可能产生的误操作,大部分情况是作为指针的语法糖使用。那么 C++ 为什么要引入右值引用呢,即:在什么情况下我们会想要延长一个临时变量的生命长度呢?事实上右值引用的场合主要是为了在变量传递和拷贝时提高效率。以下面例子为例:(摘抄自博客 C++ rvalue, && and Move)

#include <stdio.h>

class Resource {
public:
    Resource(int a_) : a(a_) {}

public:
    int a;
};

class ResourceOwner {
public:
    ResourceOwner(int a) { the_resource = new Resource(a); }

    ResourceOwner(const ResourceOwner& other) {
        printf("copy %d\n", other.the_resource->a);
        the_resource = new Resource(other.the_resource->a);
    }

    ResourceOwner& operator=(const ResourceOwner& other) {
        printf("assign %d\n", other.the_resource->a);

        printf("clean %d\n", the_resource->a);
        if (the_resource) {
            delete the_resource;
        }
        the_resource = new Resource(other.the_resource->a);

        return *this;
    }

    ~ResourceOwner() {
        if (the_resource) {
            printf("destructor %d\n", the_resource->a);
            delete the_resource;
        }
    }

private:
    Resource* the_resource = nullptr;
};

void testCopy() {  // case 1
    printf("=====start testCopy()=====\n");

    ResourceOwner res1(1);
    ResourceOwner res2 = res1;  // copy res1

    printf("=====destructors for stack vars, ignore=====\n");
}

void testAssign() {  // case 2
    printf("=====start testAssign()=====\n");

    ResourceOwner res1(1);
    ResourceOwner res2(2);
    res2 = res1;  // copy res1, assign res1, destrctor res2

    printf("=====destructors for stack vars, ignore=====\n");
}

void testRValue() {  // case 3
    printf("=====start testRValue()=====\n");

    ResourceOwner res2(2);
    res2 = ResourceOwner(1);  // copy res1, assign res1, destructor res2, destructor res1

    printf("=====destructors for stack vars, ignore=====\n");
}

int main() {
    testCopy();
    testAssign();
    testRValue();
}

编译运行结果如下所示:

$ /home/xt/code_collections/cpp/build/move_forward/move_forward_example3
=====start testCopy()=====
copy 1
=====destructors for stack vars, ignore=====
destructor 1
destructor 1
=====start testAssign()=====
clean 2
assign 1
=====destructors for stack vars, ignore=====
destructor 1
destructor 1
=====start testRValue()=====
clean 2
assign 1
destructor 1
=====destructors for stack vars, ignore=====
destructor 1

在上面例子,ResourceOwner 类中包含一个 Resource 指针指向一个内存中的变量作为资源。三个函数分别测试了该类在以下场景的行为:

  • 利用一个对象拷贝构造另一个对象:构造过程中只发生了一次拷贝构造,没有多余操作
  • 将一个对象赋值到另一个已经经过初始化的对象:清理原有资源,然后赋值新的资源,没有多余操作
  • 将一个临时变量(右值)赋值到另一个已经经过初始化的对象:清理原有资源,初始化一个临时变量,将该临时变量赋值到对象,清理该临时变量

上述前两个场景中没有多余操作,而在第三步中,我们发现由于使用右值来进行赋值,中间产生了一个临时变量作为中间变量。这里的资源其实生成了两次。(一次在临时变量的构造时生成,一次在赋值操作时生成)这一步实际上是可以优化的,由于该临时变量本身是右值,即即将要被清理的,我们能不能使得要赋值的对象直接获取该右值的资源呢从而节省一次资源拷贝呢?此时通过使用右值引用就可以解决这个问题,我们加入一个右值赋值操作符重载,当输入参数为右值时,我们知道该值的生命周期即将结束,因此可以直接把它的资源拿过来而不是拷贝一份,移动过来之后将它的资源指针清空避免该资源被清除,如下所示:

    ResourceOwner& operator=(ResourceOwner&& other) {
        printf("clean %d\n", the_resource->a);
        if (the_resource) {
            delete the_resource;
        }

        printf("move %d\n", other.the_resource->a);
        the_resource = other.the_resource;
        other.the_resource = nullptr;

        return *this;
    }

运行结果如下:

$ /home/xt/code_collections/cpp/build/move_forward/move_forward_example4
...
=====start testRValue()=====
clean 2
move 1
=====destructors for stack vars, ignore=====
...

可以发现,此时没有多余的资源拷贝动作(在关闭返回值优化的情况下)。注:在移动构造函数和移动赋值里面,虽然我们将传入其中的参数作为右值看到,但是在函数体内的形参 (在这个例子里面的 other) 本身是一个类型为右值引用的左值。它符合左值的一些性质,如:可以取地址、可以放在等式左侧等。

移动语义

引入

如果编译器能够在拷贝构造或者赋值时准确识别左右值,涉及到左右值以及左右值引用的内容已经结束了。然而编译器并没有那么智能,而且在某些情况下我们想让一个左值作为右值使用,这个时候编译器就无能为力了。

先看一下以下例子,

#include <stdio.h>

class Resource {
public:
    Resource(int a_) : a(a_) {}

public:
    int a;
};

class ResourceOwner {
public:
    ResourceOwner() {}

    ResourceOwner(int a) {
        printf("ResourceOwner: base constructor %d\n", a);
        the_resource = new Resource(a);
    }

    ResourceOwner(const ResourceOwner& other) {
        printf("ResourceOwner: lvalue copy %d\n", other.the_resource->a);
        the_resource = new Resource(other.the_resource->a);
    }

    ResourceOwner(ResourceOwner&& other) {
        printf("ResourceOwner: rvalue copy %d\n", other.the_resource->a);
        the_resource = other.the_resource;
        other.the_resource = nullptr;
    }

    ResourceOwner& operator=(const ResourceOwner& other) {
        if (the_resource) {
            delete the_resource;
        }

        printf("ResourceOwner: lvalue assign %d\n", other.the_resource->a);
        the_resource = new Resource(other.the_resource->a);

        return *this;
    }

    ResourceOwner& operator=(ResourceOwner&& other) {
        if (the_resource) {
            delete the_resource;
        }

        printf("ResourceOwner: rvlue move %d\n", other.the_resource->a);
        the_resource = other.the_resource;
        other.the_resource = nullptr;

        return *this;
    }

    ~ResourceOwner() {
        if (the_resource) {
            printf("ResourceOwner: destructor %d\n", the_resource->a);
            delete the_resource;
        }
    }

private:
    Resource* the_resource = nullptr;
};

class ResourceHolder {
public:
    ResourceHolder(int a) : the_resource_owner(a) {}

    ResourceHolder(ResourceHolder&& other) {
        printf("ResourceHolder: rvalue copy\n");
        the_resource_owner = other.the_resource_owner;
    }

private:
    ResourceOwner the_resource_owner;
};

int main() {
    ResourceHolder holder = ResourceHolder(10);
    return 0;
}

在上一部分的例子上,我们新增了一个类 ResourceHolder,其中包含一个 ResourceHolder 成员,同时我们为其增加了一个移动构造来节省资源拷贝。编译结果如下:

base constructor 10
ResourceHolder: rvalue copy
lvalue assign 10
destructor 10
destructor 10

可以发现,虽然编译器准确将 ResourceHolder holder = ResourceHolder(10); 中的 ResourceHolder(10) 返回结果作为右值,但是在移动构造函数中,the_resource_owner = other.the_resource_owner; 这是因为 other 本身为形参是一个有名字的变量,因此是一个左值。如果 other 为右值引用,我们会希望它的所有成员变量都是右值。(并不一定适用于所有场景下,在涉及到指针变量时常常指针本身可能是右值,但其指向的对象不一定是右值。)但是编译器不知道我们的意图,因此我们要显式地让编译器知道我们想要将其作为右值对待。因此引入了 std::move。这个函数用于将一个对象转换成将亡值(声明周期接近结束)使得可以对其调用移动语义提高资源传递效率。

在上面的例子中,我们想要让编译器知道 other.the_resource_owner 是一个右值,因此可以使用 std::move,如下所示:

ResourceHolder(ResourceHolder&& other) {
    printf("ResourceHolder: rvalue copy\n");
    the_resource_owner = std::move(other.the_resource_owner);
}

运行结果如下,可以看到此时编译器选择了我们希望的的赋值操作。

ResourceOwner: base constructor 10
ResourceHolder: rvalue copy
ResourceOwner: rvlue move 10
ResourceOwner: destructor 10

std::move 的注意事项

上面这个例子展示了大部分情况下我们使用 std::move 的目的,将其标记为右值。从而编译器在某些情况下能够选择右值相关操作(赋值、构造)。根据标注里所说,它和一个静态类型转换到右值引用类型的操作完全一致。它本身不对变量进行任何操作,即:std::move 不移动任何东西。在上面的例子而言:the_resource_owner = std::move(other.the_resource_owner); 虽然标记了 other.the_resource_owner 为右值,但是如果 ResourceOwner 没有实现移动赋值操作符或者移动赋值操作符里的实现为拷贝该资源。那么即便是标记 other.the_resource_owner 为右值,在赋值操作之后,other.the_resource_owner 不会发生任何变化。此外,std::move 不会改变变量的常量性( constness ),而大部分移动语义函数接收的入参为非常量右值引用 type&&,因此如果对常量使用 std::move 然后调用构造或者赋值操作不会调用其移动操作版本,因此对常量调用 std::move 甚至不能保证其资源能够被移动。

移动语义和返回值优化

考虑这样一个函数:

ResourceOwner factory(int a) { return ResourceOwner(a); }

int main() {
    ResourceOwner owner = factory(10);
    return 0;
}

在编译器打开 -fno-elide-constructors 情况运行,如果在 ResourceOwner 不提供移动构造函数的情况下,运行结果如下:

$ /home/xt/code_collections/cpp/build/move_semantics/move_semantics_example7
ResourceOwner: base constructor 10
ResourceOwner: lvalue copy constructor 10
ResourceOwner: destructor 10
ResourceOwner: lvalue copy constructor 10
ResourceOwner: destructor 10
ResourceOwner: destructor 10

可以看到,总共拷贝构造了 2 次,分别发生在什么时候呢?我们可以结合汇编代码看一下:

可以看到,factory 首先使用构造函数构造了一个局部变量,然后通过一次拷贝构造使用该局部变量构造了一个返回值。然后回到 main 函数,使用该函数返回的临时变量又使用了一次拷贝构造。那么如果我们提供了移动构造函数呢?程序运行结果如下:

ResourceOwner: base constructor 10
ResourceOwner: rvalue copy constructor 10
ResourceOwner: rvalue copy constructor 10
ResourceOwner: destructor 10

可以发现,函数同样在中间生成了两个临时变量,但由于有移动构造函数编译器不会对其调用拷贝构造函数而是调用移动构造函数来进行构造。(在 C++17 以后,函数返回值如果是纯右值将不再有额外的临时变量产生,会将该右值直接作为返回值。)

上面提到只有当 -fno-elide-constructors 打开时才有这样的效果,那么如果不打开呢?程序运行结果如下:

$ /home/xt/code_collections/cpp/build/move_semantics/move_semantics_example7
ResourceOwner: base constructor 10
ResourceOwner: destructor 10

整个对象只被构造了一次!这是因为 C++ 标准中指明了以下情况可以触发复制省略,即忽略对象的拷贝/移动构造,而是直接将对象构造在他们要拷贝/移动构造的对象上。

  • return 语句中,如果操作数为和返回类型同类型的纯右值时
  • 在对象初始化,且初始化列表将同类型的纯右值作为参数时

因此在这种情况下,复制省略被触发,因此整型 10 被直接用于 main 函数中的 owner 的构造。

值模型

在了解了移动语义之后,我们重新来看一下左/右值的分类,事实上在 C++11 之后,标准中总共规定了 3 种主要值模型,每个表达式肯定属于这三种中的其中一种,它们分别是:

  • 左值(lvalue),通常包含以下性质:
    • 可以通过取地址符取地址
    • 可修改的左值可以放在等号左侧
    • 可以用于初始化左值引用
  • 纯右值(rvalue),通常包含以下性质:
    • 不能是多态的
    • 不能包含常量性的
    • 不能包含不完整类型(除了 void
    • 不能包含抽象类或者数组
  • 将亡值(xvalue),通常包含以下性质:
    • 广义左值的性质
    • 右值的性质

除了三种主要值分类以外,还有两个混合的值分类:

  • 广义左值(gvalue),gvalue 可以是 lvalue 或 xvalue 中的一种,通常包含以下性质:
    • 可以通过左值->右值,数组->指针,函数->指针的隐式类型转换转换成纯右值
    • 可以是多态的
    • 可以包含不完整类型
  • 右值(rvalue),rvalue 可以是 xvalue 或 prvalue 中的一种,通常包含以下性质:
    • 不能通过取值符取地址
    • 不能用于等式左侧
    • 可以用于常左值引用的初始化,这种情况下右值对象的声明周期会延长至改引用类型的周期结束
    • C++11 之后,右值可以用于右值引用的初始化,这种情况下右值对象的声明周期会延长至改引用类型的周期结束
    • C++11 之后,当类提供了拷贝构造和移动构造函数时,(非常量)右值入参会调用移动构造函数。

参考资料

Built with Hugo
Theme Stack designed by Jimmy