首页 >> 大全

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

2024-01-01 大全 37 作者:考证青年

文章目录 引用的深刻理解

前言

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

左右值的辨析

首先需要明白两个概念:类型(type)和值类别(value ),看似差不多的两个概念其实毫不相干。类型指的是数据类型,int,char这样的内置类型,类型主要是用来区别它们的字节大小。除了内置类型还有自定义类型,自定义类型中的类型还表征了结构,像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呢?它是一个字面常量,用二进制表示为,根据赋值对象的不同再进行提升或截断,比如10要赋值给int对象,所以被提升为 … (前面多出7个全0的序列,每个序列有8个0),存储时再根据大小端字节序将这些字面值从代码区拷贝到刚才的栈区地址上。

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

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

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

一个特殊的问题

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

如果以是否能取地址作为左右值判断的标准,那么字符串字面值确实左值

比如"abc"这个字符串,我们写一段程序,输出它的地址,make编译这份源文件,结果是可以编过的,再运行可执行文件,地址也被正常的打印出来。但是这个地址是什么类型的呢?由于g++编译器的打印结果不好观察,这里我使用vs的编译环境,使用打印"abc"字符串的类型与其地址的类型

可以看到字符串字面值的类型是const修饰的char数组,大小为4(最后有个’\0’),这里又涉及到const修饰值的问题,先不管它。回到最开始的问题,以是否能修改作为判断标准,字符串字面值还是左值吗?我们先看一下字符串字面值位于地址空间的哪个区域

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

#include 
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引入了右值引用,将亡值这一概念随之被提出,我们探讨的对象又复杂了起来。刚才我所说的左值与右值对应着图片上的(传统意义上的左值)和(传统意义上的右值),中的r除了right的意思,还可以理解为read,表示只读。而包括了将亡值和纯右值,由于将亡值的出现,我们需要将这些概念重新梳理一遍

(泛左值)= (传统意义上的左值)+ (将亡值)

(传统意义上的右值)= (纯右值)+ (将亡值)

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

高级语言中,这样的中间值肯定是不允许使用者修改的,为了程序的正确运行,我们也没有修改的必要,所以语言对这样的中间值加了限制,我们无法修改它们的值,一个典型的例子,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++做很多事,它会非常好用。但是自由也是有代价的,规则的限制只能限制那些本就会遵守规则的人,无法限制那些无视规则的人。

现在,我想这张图也能解释清楚了,为什么将亡值即属于(泛左值),还属于(传统意义上的右值),这两个看似矛盾的归类,其实就是由自由导致的,C++成也自由,败也自由,如果你遵守C++的规则,那么将亡值就是右值,语言限制你不能去修改它,一些直接修改的操作不被编译器允许,即使存储将亡值的数据区是可写的,但是遵守规则导致的就是将亡值的无法修改,将其视为右值也是有理有据的。但是你不遵守C++的规则,你就可以修改将亡值,谁让将亡值存储在了可写的数据区,将其视为左值也是没问题的。综上,我们可以认为由于C++这门语言的自由,诞生了即是左值又是右值的矛盾的值类别:将亡值。

而纯右值就是字面常量,1,‘a’,这样的数据,它们被编译器编译后就是一些二进制数据,被嵌入到代码区中,作为代码的一部分,我们无法在语言层面上修改代码区的数据。(传统意义上的左值)呢,就是我们经常定义的变量,有名字的变量,就算被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提出了右值引用,将亡值这一概念才会被提出,两者的关系非常紧密,将亡值是右值,右值引用可以引用右值,当然也能引用将亡值了,但是引用后的右值引用却是一个左值,我们可以通过右值引用修改将亡值的数据,你看,虽然将亡值被释放了,但是它的资源却给了右值引用,一般在构造函数中,我们将右值引用得到的将亡值资源转移到我们自己对象上。

关于我们

最火推荐

小编推荐

联系我们


版权声明:本站内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 88@qq.com 举报,一经查实,本站将立刻删除。备案号:桂ICP备2021009421号
Powered By Z-BlogPHP.
复制成功
微信号:
我知道了