C++指针,左值引用,右值引用详解2 - 弦外之音

/ 0评 / 0

C++ 里面有一个非常独特的功能,叫 "右值引用",刚学 C++ 的人都会觉得这个东西非常反人类,我也一样的。

我个人认为,要理解 右值引用 这玩意,需要先学习汇编,如果没学 汇编,直接学 C++ 的右值引用就会觉得它反人类,不易理解。

C++ 里面的 左值,右值,对应的英文缩写是 lvaluervalue,但是目前,市面上,这两种概念有不同的翻译。

例如有些人说 lvalue 的全称是 loactor value,可定位的值,这个实际上是指你在 C/C++ 代码里面能用标识符 定位到的值,例如 int a = 1,这里的 a 就是一个标识符,a 就是 loactor value

rvalue 的全称是 read value,只可读取的值。我个人觉得这个翻译解释也不太对,并不是只读的值,而是 rvalue 是指一个临时变量,或者说是匿名变量,这个变量是没有标识符的。


所以我个人倾向于,rvalue 的正确翻译是 右值 (临时变量), lvalue 的正确翻译是 loactor value (可定位值),而不应该翻译成左值。

左值跟右值,不是说变量在左边他就是左值,在右边他就是右值,不是这个意思,不要被这个术语误导。例如下面的代码:

int&& b = 10;

上面的 b 就是对右值的引用。但是他在左边。

上面这种代码写法是没有意义的,跟直接 int b=10 几乎一样,而且上面这样写 && 还会损失性能,多执行两条汇编,有兴趣可以自行反汇编。

我个人特别不喜欢用 int&& b = 10; 来讲解右值,因为现实工程代码根本不会这样写,能这么写代码,只是恰好编译器能让你这么写。但是这样写代码又有什么意义,我喜欢举实际有用的例子来讲解右值。


讲到这里,可能读者会有疑问,所有变量都应该有标识符的,没有标识符怎么能叫做变量呢?

我通过一个简单的代码,演示一下,大家就明白了。

代码如下:

#include <iostream>
​
int main() {
    int a = 2;
    int b = a * 5;
    return 0;
}

上面的代码,翻译成汇编,如下:

1-1

我上图圈出来的 eax他就是右值

为什么会产生右值,是因为 汇编,CPU 指令无法 直接把结果直接存进去另一块内存,结果需要存在 eax 里面,然后再复制到另一块内存。变量都是放在内存里面的。

x86 指令的内存寻址比较强大,所以产生的右值比较少,而 ARM 指令没那么强大,会产生更多的右值。

CPU 指令,需要一个缓存,一个中间的处理。因此就诞生了右值这个概念,右值这个概念,他不是 C++ 独有的,而是在 C 语言里面编程就会遇到 右值的。

为什么说 右值 是一个临时值,是因为 eax 寄存器就是一个临时值。

上面的 a * 5 执行完之后,右值 eax 是 10。我再加一些代码在后面,如下:

1-2

可以看到,后面的代码,会覆盖 eax 的值。只要后面的代码执行,你就无法找到那个曾经 等于 10 的右值变量。而 a ,b 这些是 左值变量,也叫 可定位变量,无论代码怎么跑,你都能通过 a 这个标识符,找到那个等于 2 的变量。

做个小总结,右值是临时的,而 左值是永久的。


下面我们来讲一个 函数调用,值拷贝的例子,代码如下:

#include <iostream>
using namespace std;
​
struct Box {
    int length;   // 盒子的长度
    int breadth;  // 盒子的宽度
    int height;
};
​
struct Box getBox() {
    struct Box b1;
    b1.length = 1;
    b1.breadth = 2;
    b1.height = 3;
    return b1;
}
​
int main() {
    struct Box my_box = getBox();
    return 0;
}
1-3

从上图可以看出, 局部变量 b1 的内存起始地址是 ebp-10h ,也就是 0x00FAFAE0,一直到 ebp-8h,也就是 b1 变量的数据在 0x00FAFAE0 ~ 0x00FAFAEC

由于 b1 是局部变量,所以它的内存是在 栈上面的,0x00FAFAE0 是属于栈内存的地址,当函数返回的时候,ebp 寄存器就会恢复。后面的代码极有可能会再次压栈,所以函数返回之后 0x004FFC18 地址的内存数据后面极有可能会被修改。

这就是 局部变量为什么在函数返回之后,就有可能失效的原因。所以变量都是在内存里面的,只是有些变量在栈里,有些在堆里面。堆变量不会被隐性修改,所以需要手动释放内存。而栈变量的内存会随着各个函数调用被修改,不需要你手动释放内存。

而我们上面的代码,return 返回的不是 b1 的指针,而是 b1 的值。所以 b1 的值会被拷贝到一个临时的存储空间,这个临时的存储空间就是右值,如下:

1-4

可以看到,return b1 被翻译成 8 句汇编代码。现在来仔细讲解一下 这 8 句汇编在干什么。

1,mov eax,dword ptr [ebp+8]

我们知道,函数内部创建局域变量的时候, ebp 是减的,也就是变得越来越小。而 ebp + 8 代表什么意思?往上加,代表跳回到上层调用者的栈内存里面。

所以现在 eax 存的是调用者的栈地址。

补充:也可能不是调回到 上层调用者的栈内存,而是函数本身会保留一块内存来放返回值。

后面的 7 句汇编,实际上就是把 内部的局部变量,全部拷贝到 调用者的栈里面,这样,调用者就能拿到这个返回值。下面我们来看一下调用者是如何拿这个返回值的,如下:

1-5

从上图可以看到,从 getBox 返回之后,还会进行一次拷贝,首先 eax 存储的就是 返回值的起始地址。

我们总结一下整个 拷贝过程。

1,return b1 ,需要把 b1 的值 复制到 内存 A。

2,从 b1 返回之后,eax 就会指向 内存 A,然后把 内存 A 复制到 匿名变量内存B。

3,把匿名变量 赋值到 my_box。

流程如图:

局部变量 b1 -> 内存A -> 匿名内存B -> 变量 my_box。

值传递,经历了 3 次 内存拷贝。


现在我用一些 hack 的方法来优化一下上面的代码,如下:

struct Box {
    int length;   // 盒子的长度
    int breadth;  // 盒子的宽度
    int height;
};
​
struct Box* getBox() {
    struct Box b1;
    b1.length = 1;
    b1.breadth = 2;
    b1.height = 3;
    return &b1;
}
​
int main() {
    struct Box my_box;
    my_box = *(getBox());
    return 0;
}

我直接返回局部变量的内存指针,这是一种不正规的做法,但是因为这个指针的内存返回之后就被立即取值了,所以获取到的是正确的数据。如下:

1-6

可以看到,getBox() 函数的汇编代码少了几行。在看一下 main 函数的汇编,如下:

1-7

可以看到,只进行了一次内存拷贝。直接把局部变量的内存拷贝到上层调用者的 my_box 里面,因为 getBox 函数调用之后,就立即取他的内存,所以他的内存还没有损坏。可以看到 my_box 的值如下,正常被赋值为 1,2,3。

1-8

这种 hack 的做法,减少了 两次 内存拷贝,大大提升了性能,但毕竟这不是一种正规的写法,稍不注意就会导致问题。

因为局部变量的内存也是内存,都是我们可以控制的,既然知道了数据在哪里,直接 copy 一次给变量不就行了。由此可见,值传递是比较消耗性能的。


而右值引用,也是为了减少内存拷贝,下面就来演示一下 C++ 里面的右值引用是如何减少内存拷贝的。

先来个简单的例子,如下:

int getNum() {
    int num = 5;
    return num;
}
​
int main() {
    int&& my_box = getNum();
    return 0;
}

反汇编后如下;

1-9

上图中,my_box0x010FFA64,并没有减少什么内存拷贝,反而比 不用 && 多了两句汇编,在上面的代码中,my_box 类似于一级指针,他指向的内存才是 5 。my_box 本身不是 5,这就是那两句汇编干的活。

eax 的值复制到 ebp-18h ,让右值的生命周期延长。然后把 my_box 指向 ebp-18h ,这样 my_box 就类似一级指针了。

由此可将,上面这样使用右值引用 并没有什么卵用,还会多执行两句汇编。


下面就来讲一个 右值引用真正发挥他的作用的场景,如下:

待写.....


由于笔者的水平有限,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。

发表回复

您的电子邮箱地址不会被公开。