C++为什么会有这么多难搞的值类别?(上)-创新互联-成都快上网建站

C++为什么会有这么多难搞的值类别?(上)-创新互联

前言

相信大家在写C++的时候一定会经常讨论到「左值」「右值」「将亡值」等等的概念,在笔者的其他系列文章中也反复提及这几个概念,再加上一些「右值引用」「移动语义」等等这些概念的出现,说一点都不晕那一定是骗人的。

合山ssl适用于网站、小程序/APP、API接口等需要进行数据传输应用场景,ssl证书未来市场广阔!成为创新互联的ssl证书销售渠道,可以享受市场价格4-6折优惠!如果有意向欢迎电话联系或者加微信:13518219792(备注:SSL证书合作)期待与您的合作!

如果你对C++值类型的区分和具体概念还不了解的话,重磅推荐先来读一下我的偶像——三帅妹妹的一篇文章《c++ value categories》。

很多人都在吐槽C++,为什么要设计的这样复杂?就一个程序语言,还能搞出这么多值类别来?(话说可能自然语言都不见得有这么复杂吧……),那么这篇我们就来详细研究一下,为什么要专门定义这样的值类型,以及在这个过程中笔者自己的思考。

一些吐槽

不得不吐槽一下,笔者认为,C++之所以复杂,C语言是原罪。因为C++一开始设计的目的,就是为给C来进行语法扩充的。因此,C++的设计方式和其他语言会有一些不同。

一般设计一门程序语言,应该是先设计一套语言体系,我希望这个语言提供哪些语法、哪些功能。之后再去根据这套理论体系来设计编译器,也就是说对于每一种语法如何解析,如何进行汇编。

但C++是不同的,因为在设计C++的语言体系的时候,就已经有完整的C语言了。因此,C++的语言体系建立其实是在C的语言体系、编译器实现以及标准库等这些之上,又重新建立的。所以说C++从设计之初,就决定了它没办法甩开C的缺陷。很多问题都是为了解决一个问题又不得不引入另一个问题,不断「找补」导致的。今天要细说的C++值类别(Value Category)就是其中非常有代表性的一个。

所以要想解释清为什么会有这些概念,我们就要从C语言开始,去猜测和体会C++设计者的初衷,遇到的问题以及「找补」的手段,这样才能真正理解这些概念是如何诞生的。

正式开始解释 从C语言开始讲起

在C语言当中其实并没有什么「左右值」之类的概念,单从值的角度来说C语言仅仅在意的是「可变量」和「不可变量」。但C更关心的是,数据存在哪里,首先是内存还是寄存器?为了区分「内存变量」还是「寄存器变量」,从而诞生了registerauto关键字(用register修饰的要放在寄存器中,auto修饰的由编译器来决定放在哪里,没有修饰符的要放在内存中)。

之后,即便是内存也要再继续细致划分,C把内存划分为4大区域,分别是全局区、静态区、堆区和栈区。而「栈区」主要依赖于函数(我觉得这个地方翻译成「存储过程」可能更合适),在C语言的视角来看,每一个程序就是一个过程(主函数),而这个过程执行的途中,会有很多子过程(其他函数),一个程序就是若干过程嵌套拼接和组合的结果。这其实也就是C语言「面向过程」的原因,因为它就是这样来设计的。从C语言衍生出的C++、OC、Go等其实都没有逃过这个设计框架。以OC为例,别看OC是面向对象的,但它仍然可以过程式开发,它的程序入口也是主函数,这个切入点来看它还是面相过程的,只是在执行这个过程中,衍生出了面向对象的操作。(这里就不详细展开了。)

那么以C语言的视角来看,一个函数其实就是一个过程,所以这个过程应该就需要相对独立的数据区域,仅仅在这个过程中生效,当过程结束,那这些数据也就不需要了。这就是函数的栈区的目的,我们管在栈区中的变量称作「局部变量」。

虽然栈区把不同过程之间的数据隔离开了,但是我们在过程的执行之间肯定是要有一些数据传递的,体现在C语法上就是函数的参数和返回值。正常来说,一个函数的调用过程是:

  1. 划分一个栈区用于当前函数的执行(这里其实只要确定一个栈底就好了)
  2. 把函数需要的所有数据入栈
  3. 执行函数体(也就是指令组了)
  4. 把函数的结果返回出去
  5. 栈区作废,可以重复利用

在早期版本的C语言(C89)中,每个函数中需要的局部变量都是要在函数头定义全的,也就是说函数体中是不能再单独定义变量的,主要就是为了让编译器能够划分好内存空间给每一个局部变量。但后来在C99标准里这个要求被放开了,但本质上来说原理是没有变的,编译器会根据局部变量定义的顺序来进行空间的分配。

要理解这一点,我们直接从汇编代码上来看是最直观的。首先给出一个用于验证的C代码:

void Demo() {int a = 0;
  long b = 1;
  short c = 2;
}

将其转换为AMD64汇编是这样的:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        mov     QWORD PTR [rbp-16], 1
        mov     WORD PTR [rbp-18], 2
        nop
        pop     rbp
        ret

rbp寄存器中存放的就是栈底的地址,我们可以看到,rbp-4的位置放了变量a,因为aint类型的,所以占用4个字节,也就是从[rbp][rbp-4]的位置都是变量a(这里注意里面是减法哈,按照小端序的话低字节是高位),然后按照我们定义变量的顺序来排布的(中间预留4字节是为了字节对齐)。

那如果函数有参数呢?会放在哪里?比如:

void Demo(int in1, char in2) {int a = 0;
  long b = 1;
  short c = 2;
}

会转换为:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-36], edi
        mov     eax, esi
        mov     BYTE PTR [rbp-40], al
        mov     DWORD PTR [rbp-4], 0
        mov     QWORD PTR [rbp-16], 1
        mov     WORD PTR [rbp-18], 2
        nop
        pop     rbp
        ret

可以看出来,函数参数也是作为一种局部变量来使用的,我们可以看到这里处理参数都是直接处理内存的,也就是说在函数调用的时候,就是直接把拿着实参的值,在函数的栈区创建了一个局部变量。所以函数参数在函数内部也是作为局部变量来对待的。

那如果函数有返回值呢?请看下面实例:

int Demo() {return 5;
}

会转义为:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     eax, 5
        pop     rbp
        ret

也就是说,返回值会直接写入寄存器,这样外部如果需要使用函数返回值的话,就直接从寄存器中取就好了。

所以,上面的例子主要是想表明,C语言的设计对于编译器来说是相当友好的,从某种程度上来说,就是在给汇编语法做一个语法糖。数据的传递都是按照硬件的处理逻辑来布局的。请大家先记住这个函数之间传值的方式,参数就是普通的局部变量;返回的时候是把返回值放到寄存器,调用方会再从寄存器中拿。这个过程我们可以写一个更加直观的例子:

int Demo1(int a) {return 5;
}

void Demo2() {int a = Demo1(2);
}

汇编后是:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, 5
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     edi, 2
        call    Demo1
        mov     DWORD PTR [rbp-4], eax
        nop
        leave
        ret

这就非常说明问题了,函数传参时,因为已经构建了被调函数的栈空间,所以可以直接变量复制,但对于返回值,这是本篇的第一个重点!!「函数返回值是放在寄存器中传递出去的」。

寄存器传递数据固然方便,但寄存器长度是有上限的,如果需要传递的数据超过了寄存器的长度怎么办?

struct Test {long a, b;
};

struct Test Demo() {struct Test t = {1, 2};
  return t;
}

汇编后是:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-16], 1
        mov     QWORD PTR [rbp-8], 2
        mov     rax, QWORD PTR [rbp-16]
        mov     rdx, QWORD PTR [rbp-8]
        pop     rbp
        ret

尴尬~~编译器竟然用了2个寄存器来返回数据……这太不给面子了,那我们就再狠一点,搞再长一点:

struct Test {long a, b, c;
};

struct Test Demo() {struct Test t = {1, 2, 3};
  return t;
}

当结构体的长度再大一点的时候,情况就发生改变了:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret

我们能看到,这里做的事情很有趣,[rbp-40]~[rpb-16]这24个字节是局部变量t,函数执行后被写在了[rdi]~[rdi+24]这24个字节的空间的位置,而最后寄存器中存放的是rdi的值(汇报指令有点绕,受限于AMD64汇编语法的限制,不同种类寄存器之间不能直接赋值,所以它先搞到了[rbp-40]的内存位置,然后又写到了rcx寄存器中,所以后面的[rcx+8]其实就是[rdi+8],最后rax中其实放的也是一开始的rdi的值)。那这个rdi寄存器的值是谁给的呢?我们加上调用代码来观察:

struct Test {long a, b, c;
};

struct Test Demo1() {struct Test t = {1, 2, 3};
  return t;
}

void Demo2() {struct Test t = Demo1();
}

汇编成:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rax, [rbp-32]
        mov     rdi, rax
        mov     eax, 0
        call    Demo1
        nop
        leave
        ret

也就是说,在这种场景下,调用Demo1之前,rdi写的就已经是Demo2t的地址了。编译器其实是把「返回值」变成了「出参」,直接拿着「将要接受返回值的变量地址」进到函数里面来处理了。这是本篇的第二个重点!!「函数返回值会被转换为出参,内部直接操作外部栈空间」。

但假如,我们要的并不是「返回值的全部」,而是「返回值的一部分」呢?比如说:

struct Test {long a, b, c;
};

struct Test Demo1() {struct Test t = {1, 2, 3};
  return t;
}

void Demo2() {long a = Demo1().a; // 只要其中的一个成员
}

那么这个时候会汇编成:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rax, [rbp-32]
        mov     rdi, rax
        mov     eax, 0
        call    Demo1
        mov     rax, QWORD PTR [rbp-32]
        mov     QWORD PTR [rbp-8], rax
        nop
        leave
        ret

我们发现,虽然在Demo2中没有刚才那样完整的结构体变量t,但编译器还是会分配一片用于保存返回值的空间,把这个空间的地址写在rdi中,然后拿着这个空间到Demo1中来操作。等Demo1函数执行完,再根据需要,把这片空间中的数据复制给局部变量a

换句话说,编译器其实是创建了一个匿名的结构体变量(我们姑且叫它tmp),所以上面的代码其实等价于:

void Demo2() {struct Test tmp = Demo1(); // 注意这个变量其实是匿名的
  int a = tmp.a;
}
小结

总结上面所说,对于一个函数的返回值:

  1. 如果能在一个寄存器存下,就会存到寄存器中
  2. 如果在一个寄存器存不下,就会考虑拆分到多个寄存器中
  3. 如果多个可用的寄存器都存不下,就会考虑直接用内存来存放,在调用函数之前先开放一片内存空间用于储存返回值,然后函数内部直接使用这片空间
  4. 如果调用方直接接收函数返回值,那么就会直接把这片空间标记给这个变量
  5. 如果调用方只使用返回值的一部分,那么这片空间就会成为一个匿名的空间存在(只有地址,但没有变量名)

这一套体系在C语言中其实并没有太多问题,但有了C++的拓展以后,就不一样了。

考虑上构造和析构函数会怎么样

C++在C的基础上,为结构体添加了构造函数和析构函数,为了能「屏蔽抽象内部的细节」,将构造和析构函数与变量的生命周期进行了绑定。在创建变量时会强制调用构造函数,而在变量释放时会强制调用析构函数。

如果是正常在一个代码块内,这件事自然是无可厚非的,我们也可以简单来验证一下:

struct Test {Test() {}
  ~Test() {}
};

void Demo() {Test t;
}

汇编成:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Demo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

注意C++由于支持了函数重载,因此函数签名里会带上参数类型,所以这里的函数名都比C语言直接汇编出来的多一个括号。

那如果一个自定义了构造和析构的类型做函数返回值的话会怎么样?比如:

struct Test {Test() {}
  ~Test() {}
};

Test Demo1() {Test t;
  return t;
}

void Demo2() {Test t = Demo1();
}

这里我们给编译器加上-fno-elide-constructors参数来关闭返回值优化,这样能看到语言设计的本质,汇编后是:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-1]
        mov     rdi, rax												;注意这里rdi发生了改变!
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-1]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Demo1()
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

这次代码就非常有意思了,首先,编译器自动生成了一个拷贝构造函数Test::Test(const Test &)。接下来做的事情跟纯C语言结构体就有区别了,在Demo2中,由于我们仍然是用变量直接接收了函数返回值,所以它同样还是直接把t的地址,写入了rdi,这里行为和之前是一样的。但是在Demo1中,rdi的值写入了rbp-24的位置,但后面调用构造函数的时候传入的是rbp-1,所以说明这个位置才是Demo1中的t实际的位置,等待构造函数调用完之后,又调用了一次拷贝构造,这时传入的才是rbp-24,也就是外部传进来保存函数返回值的地址。

也就是说,由于构造函数和析构函数跟变量生命周期相绑定了,因此这时并不能直接把「函数返回值转出参」了,而是先生成一个局部变量,然后通过拷贝构造函数来构造「返回值」,再析构这个局部变量。所以整个过程会多一次拷贝和析构的过程。

这么做,是为了保证对象的行为自闭环,但只有当析构函数和拷贝构造函数是非默认行为的时候,这样做才有意义,如果真的就是C类型的结构体,那就没这个必要了,按照原来C的方式来编译即可。因此C++在这里强行定义了「平凡(trivial)」类型的概念,主要就是为了指导编译器,对于平凡类型,直接按照C的方式来编译,而对于非平凡的类型,要调用构造和析构函数,因此必须按照新的方式来处理(刚才例子那样的方式)。

那么这个时候再考虑一点,如果我们还是只使用返回值的一部分呢?比如说:

struct Test {Test() {}
  ~Test() {}
  int a;
};

Test Demo1() {Test t;
  return t;
}

void Demo2() {int a = Demo1().a;
}

结果非常有趣:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        mov     rax, QWORD PTR [rbp-8]
        mov     rdx, QWORD PTR [rbp-16]
        mov     edx, DWORD PTR [rdx]
        mov     DWORD PTR [rax], edx
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-4]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-4]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-4]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-8]
        mov     rdi, rax
        call    Demo1()
        mov     eax, DWORD PTR [rbp-8]
        mov     DWORD PTR [rbp-4], eax						;这里是给局部变量a赋值
        lea     rax, [rbp-8]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        leave
        ret

这里仍然会分配一个匿名的空间用于接收返回值,然后再从这个匿名空间中取值复制给局部变量a。从上面的代码能看出,匿名空间在rbp-8的位置,局部变量arbp-4的位置。但这里非常有意思的是,在给局部变量赋值后,立刻对匿名空间做了一次析构(所以它把rbp-8写到了rdi中,然后cal了析构函数)。这是本篇的第三个重点!!「如果用匿名空间接收函数返回值的话,在处理完函数调用语句后,匿名空间将会被析构」。

左值(Left-hand-side Value)、纯右值(Pure Right-hand-side Value)与将亡值(Expiring Value)

讲了这么多,总算能回到主线上来了,先来归纳一下前文出现的3个重点:

  1. 函数返回值是放在寄存器中传递出去的
  2. 函数返回值会被转换为出参,内部直接操作外部栈空间
  3. 如果用匿名空间接收函数返回值的话,在处理完函数调用语句后,匿名空间将会被析构

其实对应了函数返回数据的3种处理方式:

  1. 直接存在寄存器里
  2. 直接操作用于接收返回值的变量(如果是平凡的,直接操作;如果是非平凡的,先操作好一个局部变量,然后再拷贝过来)
  3. 先放在一个临时的内存空间中,使用完后再析构掉

C++按照这个特征来划分了prvalue和xvalue。(注意,英语中所有以"ex"开头的单词,如果缩写的话会缩写为"x"而不是"e",就比如说"Extreme Dynamic Range"缩写是"XDR"而不是"EDR"; “Extensible Markup Language"缩写为"XML"而不是"EML”。)

所谓prvalue,翻译为“纯右值”,表示的就是第1种,也就是用寄存器来保存的情况,或者就是字面常量。举例来说,1这就是个纯右值,它在汇编中就是一个单纯的常数。然后就是返回值通过寄存器来进行的这种情况。对于C/C++这种语言来说,我们可以尽情操作内存,但没法染指寄存器,所以在它看来,寄存器中的数就跟一个常数值一样,只能感知到它的值而已,不能去操控,不能去改变。换一种说法,prvalue就是「没有内存实体」的值,常数没有内存实体,寄存器中的数据也没有内存实体。所以prvalue没有地址。

而对于第2种的情况,「返回值」的这件事情其实是不存在的,只是语义上的概念。实际就是操作了一个调用方的栈空间。因此,这种情况就等价于普通的变量,它是一个lvalue,它是实实在在可控的,有内存实体,程序可以操作。

对于第3种的情况,「返回值」被保存在一个匿名的内存空间中,它在完成某一个动作之后就失效了(非平凡析构类型的就会调用析构函数)。比如用上一节的例子来说,从Demo1函数的返回值(匿名空间)获取了成员a交给了局部变量,然后,这个匿名空间就失效了,所以调用了~Demo析构函数。我们把这种值称为xvalue(将亡值),xvalue也有内存实体。

以目前得到的信息来说,xvalue和lvalue的区别就在于生命周期。在C++中生命周期比在C中更加重要,在C中讨论生命周期其实仅仅在于初始化和赋值的问题(比如说局部static变量的问题),但到了C++中,生命周期会直接决定了构造和析构函数的调用,因此更加重要。xvalue会在当前语句结束时立刻析构,而lvalue会在所属代码块结束时再析构。所以针对于xvalue的情况,在C中并不明显,反正我们是从匿名的内存空间读取出数据来,这件事情就结束了;但C++中就会涉及析构函数的问题,这就是xvalue在C++中非常特殊的原因。

xvalue取址问题与C++引用

对于prvalue来说,它是纯「值」或「寄存器值」,因此不能取地址,这件事无可厚非。但对于xvalue来说呢?xvalue有内存实体,但为什么也不能取地址呢?

原因就是在于,原本C语言在设计这个部分的时候,函数返回值究竟要写到一个局部变量里,还是要写到一个匿名的内存空间里这件事是不能仅通过一个函数调用语句来判断,而是要通过上下文。也就是说,struct Test t = Demo1();的时候,t本身的地址就是返回值地址,此时返回值是lvalue(因为t就是lvalue);而如果是int ta = Demo1().a;的时候,返回值的地址是一个匿名的空间,此时返回值就是xvalue,而这里的ta就不再是返回值的地址。所以,如果你什么都不给,单纯给一个Demo1();,编译器就无法判断要选取哪一种方式,所以干脆就不支持&Demo1();这种写法,你得表达清楚了,我才能确定你要的是谁的地址。所以前一种情况下的&t就是返回值所在的地址,而后一种情况的&ta就并不是返回值所在地址了。

原本C中的这种方式倒是也合理,但是C++却引入了「引用」的概念,希望让「xx的引用」从「语义上」成为「xx的别名」这种感觉。但C++在实现引用的时候,又没法做到真的给变量起别名,所以转而使用指针的语法糖来实现引用。比如说:

int a = 5;
int &r = a;

语义上,表达的是「a是一个变量,r代指这个变量,对r做任何行为就等价于对a做同样的行为,所以ra的替身(引用)」。但实际上却做的是「定义了一个新的变量pr,初始化为a的地址,对p做任何行为就等价于对*pr做任何行为,这是一个取地址和解指针的语法糖」。

既然本质是指针,那么指针的解类型就是可以手动定义的,同理,变量的引用类型也是可以手动定义的。(本质上就不是别名,如果是别名的话,那类型怎么能变化呢?)比如说:

int a = 5;
char &r = reinterpret_cast(a);

上面这种写法是成立的,因为它的本质就是:

int a = 5;
char *pr = reinterpret_cast(&a);

变化的仅仅是指针的解类型而已。自然没什么问题。既然解类型可以强转,自然也就符合隐式转换特性,我们知道可变指针可以隐式转换为不可变指针,那么「可变引用」也自然可以隐式转换为「不可变引用」,比如说:

int a = 5;
const int &r = a;
// 等价于:
const int &r = const_cast(a);
// 等价于
const int *pr = &a;
// 等价于
const int *pr = const_cast(&a);

绕来绕去本质都是指针的行为。刚才我们说到rvalue是不能取址的,那么自然,我们就不能用一个普通的引用来接收函数返回值:

Test &r = Demo1(); // 不可以!因为它等价于
Test *pr = &Demo1(); // 这个不可以,所以上面的也不可以
常引用与右值(Right-hand-side Value)

虽然引用本质上就是指针的语法糖,但C++并不满足于此,它为了让「语义」更加接近人类的直觉,它做了这样一件事:让用const修饰的引用可以绑定函数的返回值。

从语义上来说,它不希望我们程序员去区分「寄存器返回值」还是「内存空间返回值」,既然是函数的返回值,你就可以认为它是一个「纯值」就好了。或者换一个说法,如果你要屏蔽寄存器这一层的硬件实现,我们就不应该区分寄存器返回值还是内存返回值,而是假设寄存器足够大,那么函数返回值就一定是个「纯值」。那么这个「纯值」就叫做rvalue。

这就是我前面提到的「语言设计」层面,在语言设计上,函数返回值就应当是个rvalue,只不过在编译器实现的时候,根据返回值的大小,决定它放到寄存器里还是内存里,放寄存器里的就是prvalue,放内存里的就是xvalue。所以prvalue和xvalue合称rvalue,就是这么来的。

而用const修饰的引用,它绑定普通变量的时候,语义上解释为「一个变量的替身,并且不可变」,实际上是「含了一次const_cast隐式转换的指针的语法糖」。

当它绑定函数返回值的时候,语义上解释为「一个值的替身(当然也是不可变的)」,实际上是代指一片内存空间,如果函数是通过寄存器返回的,那么就把寄存器的值复制到这片空间,而如果函数是通过内存方式返回的,那么就把这片内存空间传入函数中作为「类似于出参」的方式。

两种方式都同为「一个替身,并且不可变」,因此又把const修饰的引用叫做「常引用」。

等等!这里好像有点奇怪哎?!照这么说的话,常引用去接受函数返回值的情况,不是跟一个普通变量去接受返回值的情况一模一样了吗?对,是的,没错!你的想法是对的!,下面两行代码其实会生成相同的汇编指令:

struct Test {long a, b, c;
};

Test Demo1() {Test t{1, 2, 3};
  return t;
}

void Demo2() {const Test &t1 = Demo1();
  // 汇编指令等价于
  const Test t2 = Demo1();
}

他们都是划分了一片内存区域,然后把地址传到函数里去使用的(也就是返回值转出参的情况)。同理,如果返回值是通过寄存器传递的也是一样:

int Demo1() {return 2;
}

void Demo2() {const int &t1 = Demo1();
  // 汇编指令等价于
  const int t2 = Demo1();
}

所以,上面两个例子中,无论是t1还是t2,本质都是一个普通的局部变量,它们有内存实体,并且生命周期跟随栈空间,因此都是lvalue。这是本文第四个重点!!「引用本身是lvalue」。也就是说,函数返回值是rvalue(有可能是prvalue,也有可能是xvalue),但如果你用引用来接收了,它就会变成lvalue。

目前阶段的结论

这里再回过头来看一下,刚才我们说「函数返回值是rvalue」这事好像就有一点问题了。从理论上来理解用一个变量或引用来接收一个rvalue这种说法是没错的,但其实编译期并不是单纯根据函数返回值这一件事来决定如何处理的,而是要带上上下文(或者说,返回值的长度以及使用返回值的方式)。所以单独讨论f()是什么值类型并没有意义,而是要根据上下文。我们总结如下:

  1. 常量一定是prvalue(比如说1'a'5.6f)。
  2. 变量、引用(包括常引用)都是lvalue,哪怕是用于接受函数返回值,它也是lvalue。(这里一种情况是通过寄存器复制过来的,但复制完它已经成为变量了,所以是lvalue;另一种是直接把变量地址传到函数中去接受返回值的,这样它本身也是lvalue)。
  3. 只有当仅使用返回值的一部分(类似于f().a的形式)的这种情况,会使用临时空间(匿名的,会在当前语句结束后析构),这种情况下的临时空间是xvalue。
这里的设计初衷

所以,各位发现了吗?C++在设计时应当很单纯地认为value分两类:一类是变量,一类是值。变量它有内存实体,可以出现在赋值语句的左边,所以称为「左值」;值没有内存实体,只能出现在赋值语句的右边,所以称为「右值」。

但在实现时,却受到了C语言特性的约束(更准确来说是硬件的约束),造成我们不能把所有的右值都按照统一的方式来传递,所以才按照C语言处理返回值的方式强行划分出了prvalue和xvalue,其作用就是用来指导析构函数的调用,以实现对象系统的自闭环。

C语言原本就比较面相硬件,所以它的处理是对于机器来说更加合理的。而C++则希望能提供一套对程序员更加友好的「语义」,所以它在「语义」的设计上是对人更加合理的,就比如这里的常引用,其实就是想成为一个「不可变的替身」。但又必须向下兼容C的解析方式,因此做了一系列的语法糖。而语法糖背后又会触发底层硬件不同处理方式的问题,所以又不得不为了区分,而强行引入奇怪的概念(比如这里的xvalue)。

原本「找补」到这里(划分出了xvalue和常引用的概念后)基本已经可以子闭环了。但C++偏偏就是非常倔强,又“贴心”地给程序员提供了「移动语义」,让当前的这个闭环瞬间被打破,然后又不得不建立一个新的理论闭环。

下一篇将讲解有关移动语义和右值引用的设计初衷和实现方式,这里C++的这些值类型还会有有趣的新故事。
C++为什么会有这么多难搞的值类别?(下)

你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧


分享标题:C++为什么会有这么多难搞的值类别?(上)-创新互联
地址分享:http://kswjz.com/article/dopdgo.html
扫二维码与项目经理沟通

我们在微信上24小时期待你的声音

解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流