左值-右值-将亡值

最初概念

如何确定一个值是左值还是右值?
通常有一个比较简单的判断方案:有地址的值被称为左值,没有地址的值称为右值

但是事实好像并非如此,特别是写了一些相关代码的时候,比如下面的这段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int f(int &a) {
return 1;
}

int f(int &&a) {
return 2;
}

void solve() {
int a = 1;
int &b = a;
int &&c = 1;

cout << f(1) << endl; // 2
cout << f(a) << endl; // 1
cout << f(b) << endl; // 1
cout << f(c) << endl; // 1
}

对应的输出结果也写在每一行的后面了,这似乎有一些意料之外的情况

  • 第一行,一个单独的数字 1,很明显的确实是一个右值,符合预期
  • 第二行,变量 a 明显也是一个合情合理的左值,那么也是符合预期的
  • 接下来第三行,变量 b 作为 a 的一个引用,那毫无意义也是一个左值(b 只是引用了 a 的值,实际上仍然是 a 本身),符合预期
  • 但是第四行,却让人摸不着头脑,明明 c 是一个明确的右值引用,为什么也是一个 1

这似乎表明了,c 是一个合法的左值,而非右值

尝试做一些看起来非法的操作

1
2
3
int &&c = 1;
c += 10;
cout << c << endl; // 11

看起来非常的合法合理,就像是一个活灵活现的左值,而并非它类型那样描述的右值。即然是左值,那么必然有地址,输出看看

1
2
3
cout << &a << endl; // 0x7fff1ba1f724
cout << &b << endl; // 0x7fff1ba1f724
cout << &c << endl; // 0x7fff1ba1f72c

从上面的数字可以看出来,c 确实是在栈上,即拥有一个合理合法的地址,这是发生了什么?

调查

如果把上述的代码改成汇编语言后,再看看结果

  • 汇编前
1
2
3
4
5
6
int main() {
int a = 1;
int &b = a;
int &&c = 1;
c += 10;
}
  • 汇编结果(仅摘录核心段)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp ; 以上均为函数定义需要的一些基本操作,例如记录栈位置等,忽略
movq %fs:40, %rax ; 设置 canary 值,用于检测 stack overflow 现象
movq %rax, -8(%rbp) ; 将 canary 值保存到栈的前 8 个字节中
xorl %eax, %eax ; 任何值 xor 自己必定为 0,此处相当于清理 eax 寄存器
movl $1, -32(%rbp) ; 将 1 存储到 28-32 这几个字节中(int 占用 4 个字节)【变量 a】
leaq -32(%rbp), %rax ; 将【a】的地址拷贝到 rax
movq %rax, -24(%rbp) ; 将【a】的地址保存到 16-24 这几个字节中(64bit 上占用 8 个字节)【变量 b】
movl $1, %eax ; 将值 1 写入 eax
movl %eax, -28(%rbp) ; 将 eax 的值写入 24-28 这几个字节中【未知变量】
leaq -28(%rbp), %rax ; 将【未知变量】的地址拷贝到 rax
movq %rax, -16(%rbp) ; 将【未知变量】的地址写入到 8-16 这几个字节中【变量 c】
movq -16(%rbp), %rax ; 再读取【变量 c】的到 rax
movl (%rax), %eax ; 将【变量 c】认为是一个地址,取出此地址中的值并写入到 eax 中
leal 10(%rax), %edx ; edx = rax + 10
movq -16(%rbp), %rax ; 将【变量 c】的值拷贝到 rax 中
movl %edx, (%rax) ; 将 edx 的结果保存到 rax 对应的值的地址中(即写入【变量 c】作为地址所在的位置)
movl $0, %eax ; 清空 eax
movq -8(%rbp), %rax ; 取出 canary 值
xorq %fs:40, %rax
je .L3
call __stack_chk_fail@PLT

可以注意到,对于引用而言,汇编仍然使用的是指针来解决,所以可以看到变量 b 记录下的是 a 的指针,而非真正的给 a 做了一个别名。而 c 也是一个指针,指向了一个未知的变量。这似乎就是我们寻找的答案

从内存本身而言,任何值都可以认为是左值,因为一个值存在,则必定存在具体的地址,即使它是作为常量的方式写在代码中,那起码它也应该存在于代码段,“存在即有地址”

但是对于这种在代码段“有地址”的值,又违背了代码段不可修改的原则,而具体操作的时候又未免会使用到这些值,这个时候,编译器会将代码段的这个值拷贝到栈空间,然后将其再赋给具体的对象,这个拷贝过来的值,像是一个右值,同时又具有着左值的特点,更确切的说,它属于“将亡值(xvalue)”。

将亡值

value-type

其中,lvalue 和 rvalue 就是我们一般认为上的左值和右值,而 glvalue 则是包含了将亡值的泛左值,而 prvalue 则是指那些纯右值,也就是那些在代码段里的值

将亡值则表示一种中间变量,例如使用了纯右值的时候,或者隐式类型转化,或者函数的返回值,这些都是将亡值充当的角色。实际上他们都有确切的栈上地址。

但是将亡值本身的含义是一个临时存在的变量,终是不可久留,这也就意味着编译器通常会限制对将亡值进行左值引用的方式。例如

1
double &x = (double)1;

此时编译器的报错是:Non-const lvalue reference to type 'double' cannot bind to a temporary of type 'double',即无法通过一个非常量的左值引用指向一个将亡值。而当你改成 const double &x = (double)1; 后,程序又可以通过编译了。这也说明了编译器实际上只是在做一些安全性的检查,并没有真正限制修改将亡值,甚至可以将将亡值变成长期存在的栈上的值(例如一开始的程序)


左值-右值-将亡值
https://blog.mauve.icu/2023/09/03/cpp/lvalue-xvalue-prvalue/
作者
Shiroha
发布于
2023年9月3日
许可协议