仓库源文站点原文


title: 左值-右值-将亡值 date: 2023-09-03 21:49:07 categories:


最初概念

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

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

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
}

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

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

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

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

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

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

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

调查

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

int main() {
    int a = 1;
    int &b = a;
    int &&c = 1;
    c += 10;
}
.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 则是指那些纯右值,也就是那些在代码段里的值

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

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

double &x = (double)1;

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