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

文章详细探讨了C++中的左右值概念,指出值类别不仅关乎能否取地址,更关键在于数据的存储位置和是否可修改。通过分析,作者认为字符串常量虽有地址,但由于存储在代码区,应被视为右值。同时,文章解释了将亡值的概念,它是临时且具有常属性的,既是左值也是右值,取决于是否尝试修改。此外,还阐述了引用的原理,强调其作为地址别名的角色,并通过汇编代码展示了引用在底层的工作方式。
摘要由CSDN通过智能技术生成

前言

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

左右值的辨析

首先需要明白两个概念:类型(type)和值类别(value category),看似差不多的两个概念其实毫不相干
类型指的是“数据类型”,如:int,char这样的内置类型,在内置类型中,“类型”主要表征字节大小,每个类型都有自己的字节大小。除了内置类型还有自定义类型,此时的“类型”还表征了结构,C的struct、C++的class,由于结构(或者说内置类型的顺序)的不同将引发内存对齐问题。总之,类型表征的是大小,结构,即这个数据是怎样的(how?)

而值类别,它描述了表达式的左右值属性,那么它表征了什么呢?先说结论,我认为值类别表征了数据的存储位置(where?)。在之前写的博客中,我说道:通过该变量(变量是一种特殊的表达式)是否能取地址判断该变量是左值还是右值。

  • 如果能取地址,说明这个变量是左值,我们可以通过地址修改它
  • 如果不能取地址,说明这个变量是右值,我们不能通过地址修改它

比如int num = 10;这行代码,将10存储到变量num中,num对应一个地址,后续可以通过该地址修改num的值(将10修改为其他值),所以我们称num为左值(可以这么理解:因为num在表达式的左边,所以被称为左值)。表达式右边的10则是一个右值,我们无法通过10的地址修改10

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

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

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

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

一个特殊的问题

字符串常量是左值(?)

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

比如"abc"这个字符串,我们写一段程序,输出它的地址。程序能正常通过编译,也能正常允许,地址也能被正常的打印。但是这个地址是什么类型的呢?由于g++编译器的typeid打印结果不好观察,这里我使用vs的编译器,使用typeid打印"abc"字符串的类型与其地址的类型

可以看到"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"这个字符串被存储在了正文代码区(这个实验不够直观,但"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++的规则,你就可以修改将亡值,即使编译器禁止你这样做,所以将亡值是左值。综上,我们可以认为:由于C++这门语言的自由,诞生了即是左值又是右值的矛盾的值类别:将亡值

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

引用的深刻理解

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

相较于C语言,C++引入了一种语法:引用,这篇文章不谈引用的基本使用。我们需要深刻的理解为什么C语言没有引用,而C++有呢?
因为引用比指针使用方便,不需要写&和*吗?确实这是一个方面,但是这只是引用的一种语法表现,并不是引用的出现原因

在Linux文件系统中,最顶层的文件对应着底层结构中的inode文件,inode作为文件的唯一标识符,一份文件只有一个inode编号。但是可以有很多顶层文件使用这个inode编号,用不同的文件名映射相同的inode,这就是Linux中的硬链接

还有网络中的进程与端口号之间的关系,通过端口号肯定能找到一个进程,并且只能找到一个进程。就像文件名一样,不同的端口号可以指向同一个进程,一个端口号不可能指向不同的进程。进程就是底层唯一的结构,不管上层怎么变,进程只有一个,而端口号随便有几个

其中,端口号和文件名就有了一些解耦的意思,用户不能(不用)直接接触底层的结构,而只接触较高层的一些结构。这些结构简单易用,不仅降低使用成本,还减少了用户直接接触底层结构会带来的风险。这样的加一层在软件设计中非常的常见,说一个我个人的观点,我认为C++的引用也有点这样的意思,语言的设计者鼓励我们多使用引用,而少用或者不用指针,就是为了减少使用者对底层结构(地址)的直接接触。将使用者与地址解耦,减少直接接触地址可能存在的风险
当然,这只是左值引用的理解。通过左值引用我们知道,可以通过上层的结构(引用)接触底层结构(地址)上的数据。而右值包括了纯右值和将亡值,对于纯右值,由于其存储在代码区,我们不能修改它的地址,所以右值引用会对被引用的右值做一个拷贝,将数据拷贝到可写数据区中。用户对右值引用的修改变成了对一份拷贝的修改(右值引用是可以修改的,可以写个代码验证一下),毕竟不能修改代码区上的数据是系统规定的,语言不能突破系统的规则
而对于将亡值的右值引用,就涉及到移动构造和移动拷贝的问题,这是C++11带来的一块语法糖。如果后续代码还要使用将亡值所拥有的资源,我们可以用右值引用作为函数形参,定义一个移动构造或者移动赋值函数,将工具人的资源转移到自己的左值上。这里心疼将亡值1秒钟,由于移动构造和移动赋值不是我们的重点,我只是简单的提一下

综上,引用就是别名,不是变量的别名,而是地址的别名,是与地址建立的一种映射关系。我们可以通过不同的别名访问同一块地址空间,并且由于引用的使用比指针简单,所以在C++中能使用引用就不使用指针。这使得使用者不直接接触地址,程序出错的可能性减小,安全性也提高了。C++设计者的心思已经被我们狠狠拿捏,毕竟引用的理解和使用比起指针真的太简单了。初学者为什么要在地址中绕来绕去,直接用引用代替指针,学习成本不就降低了吗?

再从汇编看引用


以上是一些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变量存储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是左值,不是右值引用,这是编译器说的

但是这里其实隐藏了一个条件:右值引用是否具名。什么意思呢?如int &&z = 1;这行代码,用一个变量名去引用一个右值,那么这行代码将产生一个名为z的右值引用,我们可以通过z,修改右值的拷贝。综上,具名的右值引用是一个左值

而不具名的右值引用呢?就是一个实实在在的右值了,或者说是一个将亡值,它的资源即将被释放。你看,一些函数可以返回右值引用吧,一些表达式的值也是右值引用吧(std::move()),这些不具名的右值引用不会将右值拷贝到可写数据区,因此你无法修改不具名的右值引用。综上,不具名的右值引用是一个右值

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值