C++ | 左值、右值、将亡值和引用的概念 | 聊聊我对它们的深入理解

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

前言

这篇文章是我在探究完美转发这个语法点时引发的相关问题思考为了使自己的理解更深刻故写下这篇博客

左右值的辨析

首先需要明白两个概念类型type和值类别value category看似差不多的两个概念其实毫不相干。类型指的是数据类型intchar这样的内置类型类型主要是用来区别它们的字节大小。除了内置类型还有自定义类型自定义类型中的类型还表征了结构像C语言的结构体由于结构或者说内置类型的顺序的不同引发的内存对齐问题。所以类型表征的是大小结构表征这个数据是怎样的how

而值类别呢就是关于变量的左右值属性先说结论我认为值类别表征了数据的存储位置where左右值也是第一个需要辨析的重要概念。在之前写的博客中我说可以通过是否能取地址判断左右值。如果能取地址说明这个变量是左值我们可以通过地址修改它如果不能取地址则变量是右值我们不能通过地址修改它。比如int num = 10;这行代码将10存储到变量num中num对应一个地址后续可以通过地址修改num的值所以我们称num为左值num在表达式的左边这是左的含义表达式右边的10就是一个右值我们无法通过10的地址修改10。

以上的分析是从高级语言的角度展开的之前我只能做到这样的理解但是现在我们可以从一个更高的角度理解左右值从计算机体系结构的角度理解int num = 10;这行代码在语言层面它表示创建一个int类型的变量num并初始化为10。但跳出高级语言全新的理解是num才不是什么变量名num对应了一个地址是位于进程地址空间上的栈区的地址int也不是什么类型int表示从该地址往后8字节的空间被进程使用了要以8字节为一个整体修改该地址上的内容。而10呢它是一个字面常量用二进制表示为00001010根据赋值对象的不同再进行提升或截断比如10要赋值给int对象所以被提升为00000000 … 00001010前面多出7个全0的序列每个序列有8个0存储时再根据大小端字节序将这些字面值从代码区拷贝到刚才的栈区地址上。

继续分析这行代码被编译后会被放到进程地址空间的正文代码区系统怎么知道你要用10初始化num因为正文代码区的存储了10的二进制序列代码区中还有10要放入的地址没有什么num只有一串地址以及把10放到地址上的指令这些信息都会在代码中表示。并且程序的正文代码区也是有地址的代码区存储系统要执行的指令。现在回头看右值的概念不能取地址的就是右值10有地址吗当然有没有地址系统怎么访问正文代码区怎么知道你要初始化的值是10所以不能取地址不是因为没有地址而是因为这个地址你不能知道地址位于只读数据区代码区的数据可不能随便修改当然是只读数据区或者说该地址上的数据只有在程序运行后才会被系统读取你要取地址编译器直接出手谁能保证你不会做一些危害系统安全的事编译器可不会给你这些地址于是程序编译失败。

所以你看直接创建的局部变量全局变量new出来的变量都是左值为什么就是因为栈区堆区静态区都是系统允许你访问的区域我们对这些区域拥有写入的权限所以系统可以给你它们的地址。但是像什么字面常量临时变量隐式类型转换表达式产生的中间值函数返回产生的中间值…匿名对象就是右值因为程序编译后它们位于代码区或者你没有修改这些数据的必要所以系统才不会把地址给你这些空间就像系统的私人空间你不能随便的访问只有在程序运行后为了运行程序系统才会访问这些空间

最后总结一下不能取地址就是右值的说法有些不准确或者说我不太认同这种说法我认为只要数据位于的区域你没有权限访问这些数据就是右值你有权限访问的区域存储的数据是左值。并且这个权限不是语言限制的而是系统限制的访问权限语言位于系统之上我们可以突破语言的限制但是底层系统的限制我们无法突破也不能突破

一个特殊的问题

字符串字面值是左值(?)

如果以是否能取地址作为左右值判断的标准那么字符串字面值确实左值
在这里插入图片描述
比如"abc"这个字符串我们写一段程序输出它的地址make编译这份源文件结果是可以编过的再运行可执行文件地址也被正常的打印出来。但是这个地址是什么类型的呢由于g++编译器的typeid打印结果不好观察这里我使用vs的编译环境使用typeid打印"abc"字符串的类型与其地址的类型
在这里插入图片描述
可以看到字符串字面值的类型是const修饰的char数组大小为4最后有个’\0’这里又涉及到const修饰值的问题先不管它。回到最开始的问题以是否能修改作为判断标准字符串字面值还是左值吗我们先看一下字符串字面值位于地址空间的哪个区域
在这里插入图片描述

这是进程地址空间的划分下面是低地址上面是高地址我们可以通过打印初始化全局数据区变量的地址栈区变量以及堆区变量的地址判断"abc"这个字符串是存储在哪块区域的

#include <iostream>
using namespace std;
int c = 10;

int main()
{
    int a = 10;
    int* b = new int(10);
    cout << "栈区变量的地址      :" << &a << endl;
    cout << "堆区变量的地址      :" << b << endl;
    cout << "初始化全局变量的地址:" << &c << endl;
    cout << "字符串字面值的地址  :" << &("abc") << endl;

    return 0;
}

在这里插入图片描述
结果很明显栈区向下增长地址最高堆区向上增长地址次高初始化全局数据区的地址在两者之下而字符串字面值的地址比初始化全局数据区还低通过进程地址空间的划分我们可以得知字符串字面值被存储在正文代码区。因此程序被编译为可执行文件后"abc"这个字符串被存储在了正文代码区。与字符串数组和通常的字面常量不同字符串数组在程序运行之后才被存储到栈区或者堆区中从代码区中拷贝到其他可修改的区域虽然通常的字面常量在程序被编译好后就被存储在了正文代码区但是它没有表征具体信息的字段比如数据的类型有几个字节但是字符串字面值是有的代码区中有信息表示它的类型大小所以我们可以根据这些信息使程序打印出字符串字面值的地址的类型。

但是我们可以通过这个地址修改字符串字面值的值吗我想的是虽然可以通过强制类型转换去除变量的const属性但是字符串字面值存储在正文区正文区的数据肯定不能修改所以我认为字符串字面值是右值但是我一搜索“修改字符串常量”就被这篇文章打脸仔细一看文章并不简单其中的修改方法是从系统角度修改页的权限得到代码区的写入权限所以可以修改字符串字面值。如果从系统的角度出发我们可以直接修改页的权限获取可读数据区的写权限那么所有的数据都是可写的所有的数据都是左值显然我们不能这样理解我们应该从高级语言的角度上理解代码区的数据就是不可修改的我们对代码区只有读权限因此字符串字面值是右值。这个结论与网上的大多数结论相反究其原因只是我对左右值的判断依据与大部分人不同我认为不能简单的将左右值用是否能取地址来区分这只是方便初学者理解的一种说法学习到现在我认为区分左右值的依据应该是是否能在语言层面上修改数据能修改的数据就是左值不能修改的数据就是右值而是否能修改的本质是我们对地址空间的权限对正文代码区只有读权限对栈区堆区以及静态区我们有读写权限无论语言怎么限制这里点名const我们都能通过一些特殊手段突破这个限制绕过编译器的检查非法的篡改被语言级别限制的数据。比如函数的返回值虽然返回值是一个临时变量具有常属性这是语言级别的限制但是它还是存储在栈区我们当然可以非法篡改具体可以看函数栈帧理解这篇文章。

将亡值

在这里插入图片描述
有意思的是由于C++11引入了右值引用将亡值这一概念随之被提出我们探讨的对象又复杂了起来。刚才我所说的左值与右值对应着图片上的lvalue传统意义上的左值和rvalue传统意义上的右值rvalue中的r除了right的意思还可以理解为read表示只读。而rvalue包括了将亡值xvalue和纯右值prvalue由于将亡值的出现我们需要将这些概念重新梳理一遍

glvalue泛左值= lvalue传统意义上的左值+ xvalue将亡值
rvalue传统意义上的右值= prvalue纯右值+ xvalue将亡值

我们通常讨论的左值并不是gvalue泛左值而是lvalue通常讨论的右值是rvalue它包含了将亡值xvalue和纯右值pvalue其中的将亡值与右值引用息息相关匿名对象和函数返回值都是将亡值它们都具有常属性并且生命周期较短在下一条语句执行前资源就会被释放。具体的比如隐式类型转换产生的中间变量为了调用类的函数而定义的匿名对象这些变量似乎都是工具人被创建只是为了其他语句的成功执行。可以预见的是这些将亡值都不是字面常量而是程序运行后在栈上堆上创建的变量虽然这些变量都有地址但是我们没有必要知道这些地址因为它们都是程序运行中产生的中间值被创建只是为了完成其他代码当代码执行完将亡值就会被释放它默默地来也默默地走编译器甚至不让我们知道它们的“姓名”。根据将亡值存储在可修改数据区这一条件我们能得到将亡值是一个左值的结论我们可以通过一些特殊手段修改将亡值虽然编译器为将亡值添加了限制我们无法修改将亡值将亡值的生命周期太短了一行代码执行完就被释放所以要修改它的值也是比较困难的但是将亡值存储在可修改的数据区上理论上我们是可以修改的就像我的文章中对函数返回值的篡改一样。

高级语言中这样的中间值肯定是不允许使用者修改的为了程序的正确运行我们也没有修改的必要所以语言对这样的中间值加了限制我们无法修改它们的值一个典型的例子int &x = 1.1;这行代码肯定是无法通过编译的分析一下假设现在的代码是int x = 1.1; 将一个浮点数赋值给一个整形两者的数据存储规则都不一样肯定是无法直接赋值的这里要发生隐式类型转换将1.1这个浮点数转换成int类型的数据用一个中间变量接收转化的结果最后再把中间变量的值赋给x这个过程中谁是工具人已经很明显了。这就是将亡值的产生原因如果代码是int &x = 1.1;呢这个x只是一个引用并不是一个变量所以无法接收数据但最后引用是1.1的引用吗x的类型是int类型的引用int类型的引用肯定无法引用浮点数所以x是中间变量的引用但是我们可以访问中间变量吗不行你想访问。编译器直接出手这就是语法的规则限制无法访问将亡值

但是C++这门语言非常自由我们可以直接访问内存啊只要知道中间变量在内存中的地址我们就可以修改哪管什么编译器的限制编译器只会限制明显的修改将亡值的情况我们不通过变量直接修改将亡值就可以绕过编译器的检查。具体可以看我对函数返回值的篡改这篇文章C++允许你做很多细致的操作只要你遵守它的规则就能用C++做很多事它会非常好用。但是自由也是有代价的规则的限制只能限制那些本就会遵守规则的人无法限制那些无视规则的人。
在这里插入图片描述
现在我想这张图也能解释清楚了为什么将亡值即属于glvalue泛左值还属于rvalue传统意义上的右值这两个看似矛盾的归类其实就是由自由导致的C++成也自由败也自由如果你遵守C++的规则那么将亡值就是右值语言限制你不能去修改它一些直接修改的操作不被编译器允许即使存储将亡值的数据区是可写的但是遵守规则导致的就是将亡值的无法修改将其视为右值也是有理有据的。但是你不遵守C++的规则你就可以修改将亡值谁让将亡值存储在了可写的数据区将其视为左值也是没问题的。综上我们可以认为由于C++这门语言的自由诞生了即是左值又是右值的矛盾的值类别将亡值。

而纯右值prvalue就是字面常量1‘a’这样的数据它们被编译器编译后就是一些二进制数据被嵌入到代码区中作为代码的一部分我们无法在语言层面上修改代码区的数据。lvalue传统意义上的左值呢就是我们经常定义的变量有名字的变量就算被const修饰我们依然可以修改它们的值谁让它们存储在可写的数据区呢但是除了这两个典型的左右值还有一些工具人它们被叫做将亡值至于它的值类别是属于左值还是右值我们无法做出具体的分类界定它的左右值属性将由使用者决定

引用的深刻理解

int &x = 1.1;这行代码涉及到引用所以再补充一下我对引用的理解

相较于C语言C++引入了一种语法引用这篇文章不谈引用的基本使用我们需要深刻的理解为什么C语言没有引用而C++有呢因为它比指针使用方便不需要写&和*吗确实这是一个方面但是这只是引用的一种语法表现并不是引用的出现原因。在Linux文件系统中最顶层的文件对应着底层结构中的一个inode文件inode作为文件的唯一标识符一份文件只有一个inode编号但是可以有很多顶层文件使用这个inode编号用不同的文件名映射相同的inode这就是硬链接。还有网络中的进程与端口号之间的关系通过端口号肯定能找到一个进程并且只能找到一个进程就像文件名一样不同的端口号可以指向同一个进程进程就是底层唯一的结构不管上层怎么变进程只有一个而端口号随便几个。端口号和文件名就有了一些解耦的意思用户不能不用直接接触底层的结构而是接触较高层的一些结构不仅降低使用成本还减少了用户直接接触底层结构会带来的风险。这样的加一层在软件设计中非常的常见说一个我个人的观点我认为C++的引用也有点这样的意思语言的设计者鼓励我们多使用引用而少使用或者不使用指针就是为了减少使用者对底层结构地址的直接接触将使用者与地址解耦减少直接接触地址可能存在的风险。当然这只是左值引用的理解还有一个右值引用。通过左值引用我们知道可以通过上层的结构引用接触底层结构地址上的数据而右值包括了纯右值和将亡值对于纯右值由于其存储在代码区我们不能修改它的地址所以右值引用会对被引用的右值做一个拷贝将数据拷贝到可写数据区中用户对右值引用的修改变成了对一份拷贝的修改右值引用是可以修改的不了解的读者可以写简单的代码验证一下毕竟不能修改代码区上的数据是系统规定的语言不能脱离系统设计啊。而对于将亡值的右值引用就涉及到移动构造和移动拷贝的问题这是C++11带来的一块语法糖如果后续代码还要使用将亡值所拥有的资源我们可以用右值引用作为函数形参定义一个移动构造或者移动赋值函数将工具人的资源转移到自己的左值上这里心疼将亡值1秒钟由于移动构造和移动赋值不是我们的重点我只是简单的提一下。

所以引用就是别名不是变量的别名而是地址的别名是与地址建立的一种映射关系。我们可以通过不同的别名访问同一块地址空间并且由于引用的书写比指针简单在C++中能使用引用就不使用指针了使用者不直接接触地址程序出错的可能也就小了C++设计者的心思已经被我们狠狠拿捏毕竟引用的理解和书写比起指针真的太简单了初学者为什么要在地址中绕来绕去直接用引用代替指针学习成本不就减低了吗
在这里插入图片描述
lea的意思是加载有效的偏移量地址先看左值引用的那三行代码int y = 1是将1放到ebp-24h这个地址处int& z = y则是创建y的引用z我们知道引用的本质是与地址建立映射关系那么这个映射关系当然就需要保存了创建引用的第一条汇编就是将ebp-24h这个偏移地址存储到eax寄存器中ebp-24h这个偏移地址上存储的是什么呢从int y = 1的汇编可以得知ebp-24h这个地址与变量y的地址相同存储的是1。再看创建引用的第二条汇编将eax中存储的数据移动到ebp-30h中eax存储的不就是变量y的地址吗将y的地址存储到ebp-30h不就是映射关系的保存吗由于x86架构的系统有32位的地址所以y的地址被存储到dword类型双字32比特的地址上。再看最后一行代码z = 2它的汇编有两条第一条是将ebp - 30h地址处的数据移动到eax寄存器中也就是将变量y的地址存储1的地址移动到eax寄存器中最后再将2移动到eax保存的地址处。这么看来虽然在C++中我们没有显式的书写指针但是在汇编层面依然是需要使用指针的可以说从汇编的角度看引用与指针没有任何区别指针保存了变量的地址引用需要保存与被引用对象的地址的映射关系但这也是地址啊我们通过引用找到被引用对象的地址不就是引用与被引用对象之间建立起了映射关系吗引用和指针都是保存对象的地址那么两者有区别吗很好理解虽然两者的底层相同但是在高级语言的层面上引用是对地址的一种封装而指针呢指针没有封装地址直接保存了地址将地址暴露了出来。

再看右值引用的两行代码int&& x = 1是创建一个右值引用引用1这个字面常量我们知道程序编译后字面常量被存储在代码区代码区对我们来说是只读的我们不能修改上面的数据而引用呢引用是对地址的一种封装但是引用可以封装一个只读数据区的地址吗当然不能你也没见过&1这样的表达式吧那么引用封装的地址又是什么地址呢由于只读数据区的数据不能修改所以编译器会将代码翻译为先在可写数据区创建只读数据的一份拷贝再把这份拷贝的地址给引用让引用封装这个地址。所以我们通过引用修改的数据只是只读数据的一份拷贝并不是真正的只读数据。因此第一条汇编就是将1移动到ebp-18h地址处显然这是在栈上开辟了空间存储1接着再把1的偏移地址ebp-18h加载到eax寄存器中最后把eax寄存器的数据移动到ebp-0ch地址处。可以看出int&& x = 1这条代码干了两件事一是int x = 1在栈区创建一个int变量存储1然后再将存储1的地址保存到栈区的另一块空间作为引用的映射关系保存仔细一看第二件事不就是左值引用的创建过程吗

右值引用是右值吗

这是一个让我困扰很久的问题先给出答案右值引用不是右值它是一个左值直接看代码

void print(int& x)
{
	printf("void print(int& x)\n");
}

void print(int&& x)
{
	printf("void print(int&& x)\n");
}

int main()
{
	int x = 0;
	int& y = x;
	int&& z = 1;
	print(y);
	print(z);
	return 0;
}

print有两个重载的版本一个是形参类型为左值引用一个为右值引用将左值引用y作为print的参数调用的是形参为左值引用的print这没什么问题那么将右值引用z作为print的形参会调用形参为右值引用的print吗
在这里插入图片描述
这也能从侧面说明右值引用是左值了吧从刚才讲解的汇编我们也能理解右值引用拷贝了右值引用的是拷贝后的左值所以严格来说右值引用是左值用右值引用作为实参不能调用形参为右值引用的函数。再来一个例子在这里插入图片描述
j作为右值引用却不能引用同为右值引用的兄弟z但是i作为左值引用却能引用同为左值引用的y。报错信息显式右值引用不能绑定左值所以z是左值不是右值引用这是编译器说的

但是这里其实隐藏了一个条件就是是否创建变量去引用右值什么意思呢使一个变量名去引用一个右值这个变量的类型就是右值引用我们可以通过右值引用得到的变量名找到可写数据区中数据首地址通过这个变量名修改地址上的数据所以右值引用后得到的变量是一个左值注意这里侧重引用后的结果是否用变量接收这才是正确的结论。

而没有创建变量接收的右值呢就是一个实实在在的右值了或者说是一个将亡值它的资源即将被释放。你看一些函数可以返回右值引用吧一些表达式的值也是右值引用吧比如匿名对象的创建所以一些函数表达式匿名对象的表达式它们的运算结果都是右值引用且右值引用没有名字这时的右值引用就是右值了

这个变量名就像一个索引我们通过变量名修改其指向地址上的数据一旦没有了这个索引我们就无法通过正常手段修改地址上的数据所以说只要把这个索引暴露出来索引指向的数据就是左值是可以修改的但是没有把索引暴露出来就说明了其指向的数据不想被修改是一个右值编译器也是根据数据是否可以被修改对代码进行优化以提高程序的运行效率。通过上面的讲解可以说右值引用大部分时间是一个左值为什么只有在函数的返回以及使用匿名对象的过程中右值引用才是实实在在的右值啊但是这些过程都非常短这个时候就不得不提同样很“短”的将亡值了我们知道因为C++11提出了右值引用将亡值这一概念才会被提出两者的关系非常紧密将亡值是右值右值引用可以引用右值当然也能引用将亡值了但是引用后的右值引用却是一个左值我们可以通过右值引用修改将亡值的数据你看虽然将亡值被释放了但是它的资源却给了右值引用一般在构造函数中我们将右值引用得到的将亡值资源转移到我们自己对象上。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++

“C++ | 左值、右值、将亡值和引用的概念 | 聊聊我对它们的深入理解” 的相关文章