C++左值和右值

本文将从数据类型,引用类型,类型转换为切入点讲解左值和右值概念。本文不涉及模板。

1.数据类型



分类

从图中可以看到,C++标准委员会把所有表达式分为两种类型:泛左值(glvalue)右值(rvalue)。泛左值可以分为左值(lvalue)将亡值(xvalue),右值可以分为纯右值(prvalue)将亡值(xvalue)。其中,将亡值(xvalue)很难不引起我的注意,因为它既是左值,又是右值,这该怎么理解呢?后面会进行解释。首先来了解左值、纯右值、将亡值的概念。

1.1.左值

左值是可以取地址的值,或者说是具有名称的值。这两个说法本质上是一个意思,高级语言中的变量名其实就是汇编语言中的地址,我们通过变量名获取变量值的过程本质上就是通过变量地址获取变量值的过程(被const修饰的变量名例外)。现写一个简单的程序来验证一下:

//variable.cpp
int main()
{
    int a = 5;
    int b = a;
    int c = a;
    return 0;
}

编译成汇编:

g++ -S variable.cpp -o variable.s

生成的代码如下:

# variable.s
main:
pushq	%rbp
movq	%rsp, %rbp
movl	$5, -12(%rbp)
movl	-12(%rbp), %eax
movl	%eax, -8(%rbp)
movl	-12(%rbp), %eax
movl	%eax, -4(%rbp)
movl	$0, %eax
popq	%rbp
ret

可以看到,汇编层面是通过地址-12(%rbp)引用变量a的值,然后通过地址-12(%rbp)a的值赋值给bc

1.2.纯右值

纯右值是不能取地址的值,或者说是没有名称的值。经过前面的讲解,我们可以知道这两个说法其实是一个意思。问题来了,为什么纯右值“不能取地址”?有三点原因:

  • 纯右值可能存放在寄存器上,硬件层面我们无法通过内存地址获取寄存器上的值;
  • 纯右值可能是机器指令的一部分,机器码层面不允许把指令拆开取值;
  • 纯右值可能是栈上的匿名变量,编译层面为了安全不允许对这些匿名变量取地址。

现逐一举例说明这三点原因。

1、怎么理解纯右值存放在寄存器上

借用一张图:

寄存器

可以看到,第一,在x86_64架构上,函数返回值如果不超过64位,会存放在%rax寄存器上;函数返回值如果超过64位,会存放在内存中,并把指向该返回值的指针存放在%rax寄存器上。第二,函数的参数个数不超过6个且参数数据类型不超过64位,会保存在寄存器上。

2、怎么理解纯右值是机器指令的一部分

//高级语言
int a = 5;
//编译后反汇编,查看int a=5;对应的机器码和汇编如下
c7 45 f4 05 00 00 00 	movl  $0x5,-0xc(%rbp)

我的电脑是x86_64架构,采用的是little-endianes模式,可以看出机器码中的05 00 00 00对应的就是常数5。常数5是机器码的一部分,指令层面不允许把机器码拆开取地址。

3、纯右值可能是栈上的匿名变量

在栈上创建匿名对象,对匿名对象取地址,编译程序,编译器会报错,提示无法取地址。

class Object{};

std::cout<<&Object();

1.2.1.纯右值具体形式

纯右值的概念讲了这么多,仍然没有理解?没关系,我们只要记住哪些形式的值是纯右值就可以了。字面值、算数表达式、lambda表达式、栈上的匿名对象、后置自增和自减表达式、函数返回值都是纯右值:

//1、字面值
true; 7; nullptr;
//2、算数表达式
a>b; a+b; a&&b; 1+3;
//3、lambda表达式
[=](int a)->int{return ++a;}
//4、栈上的匿名对象
Object();
//5、后置自增和自减表达式
i++;

1.3将亡值

所谓的将亡值就是即将被编译器销毁的值,举个例子:

//str是将亡值,过了{}作用域,会被编译器销毁
std::string getString(){
std::string str(“hello”);
return std::move(str);
}
前面提到,将亡值既是左值也是右值,现在就可以解释了:
在{}作用域内, str可以取地址,是左值;过了{}作用域, str不能取地址,是右值。

2.引用类型

引用类型分为左值引用和右值引用,Type &表示左值引用,Type &&表示右值引用。无论是左值引用和右值引用都必须在声明时初始化。

看到这句话,我脑海里会浮现出3个问题:

  • 什么是左值引用和右值引用?
  • 为什么只能在声明的时候初始化引用?
  • 为什么要引入“引用”这一概念?应用场景有哪些?

现依次回答上面3个问题。

2.1.第一个问题

什么是左值引用和右值引用?根据前面的讲解,我们已经知道了左值和右值的概念,现在利用左值和右值来介绍引用的概念。左值引用是只能被左值初始化的变量名;右值引用是只能被右值初始化的变量名。

int value = 7;

int && rvalue = 7; //正确
int & lvalue = value;//正确

到目前为止,引用的概念仍然很抽象,它占内存吗?它在内存中是怎样存放的?它和指针有什么区别?接下来,我们通过汇编代码查看引用到底是什么。

2.1.1.左值引用

写一个简单的程序:

//lvalue.cpp
int main()
{
int value = 7;
int & lvalue = value;
return 0;
}

编译成汇编代码

# lvalue.s
main:
.LFB0:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
xorl %eax, %eax
movl $7, -20(%rbp)
leaq -20(%rbp), %rax
movq %rax, -16(%rbp)
movl $0, %eax
movq -8(%rbp), %rdx
xorq %fs:40, %rdx
je .L3
call __stack_chk_fail@PLT
.L3:
leave
ret

代码说明:首先通过movl $7,-20(%rbp)指令,把常量7赋值给value,然后通过movq %rax, -16(%rbp)value的地址赋值给rvalue。可以得出结论,左值引用实质上是地址。

2.1.2.右值引用

写一个简单的程序:

//rvalue.cpp
int main()
{
int && rvalue = 7;
return 0;
}

编译成汇编代码

# rvalue.s
main:
.LFB0:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
xorl %eax, %eax
movl $7, %eax
movl %eax, -20(%rbp)
leaq -20(%rbp), %rax
movq %rax, -16(%rbp)
movl $0, %eax
movq -8(%rbp), %rdx
xorq %fs:40, %rdx
je .L3
call __stack_chk_fail@PLT
.L3:
leave
ret

代码说明:首先通过 movl $7,%eaxmovl %eax,-20(%rbp)把常量7存放到栈上,然后通过movq %rax,16(%rbp)把栈上7所在地址赋值给rvalue。可以得出结论,右值引用实质上是地址。

2.1.3.一个新的问题

既然左值引用和右值引用都是地址,那么它们和指针有什么区别呢?引用可以看做是被限制的指针,和普通指针的区别在于,引用只能在声明的时候初始化,且不可更改。可以发现,引用和指针常量在功能上是等同的。

2.2.第二个问题

为什么只能在声明的时候初始化引用?这是C++标准规定的。

2.3.第三个问题

为什么要引入“引用”这一概念?有的人可能会回答:减少拷贝开销!这句话可以说答对了一半,或者说是完全错误的,因为有些情况下滥用右值引用,反而会增加拷贝开销。

首先说说为什么答对了一半

如果需要减少左值的拷贝开销,指针完全可以胜任左值引用的功能;如果需要减少右值的拷贝开销,const T&完全可以胜任T&&的工作。因此回答“减少拷贝开销”是不严谨的。前面提到过左值引用的赋值是在编译期完成的,相比于指针,左值引用的运行效率更高,也不会出现野指针的情况,因此在某些场景中使用左值引用更优。至于为什么要引入右值引用,需要结合移动构造函数和模板来理解,后面进行解释。

再谈谈为什么说是错误的

1、先看第一个例子:

//value.cpp
int main()
{
int value = 7;
return 0;
}

编译成汇编代码

# value.s
main:
.LFB0:
pushq %rbp
movq %rsp, %rbp
movl $7, -4(%rbp)
movl $0, %eax
popq %rbp
ret

比较一下value.s和前面的rvalue.s代码,可以看到int &&rvalue=7;反而比int value=7;更加耗时,前者比后者多了一个把地址压栈的过程。

2、接着看第二个例子

//返回右值
Object getRValue(){
Object obj;
return std::move(obj);
}
//返回左值
Object getLValue(){
Object obj;
return obj;
}

具体测试代码和结果就不写了,只说结论,经过编译器优化后,getLValue()拷贝Object的次数比getRValue()更少。

可以得出结论:某些情况下,滥用右值引用反而会降低程序执行效率

2.3.1.一个新的问题

前面看到,左值引用在某些场景下,比指针更优;而右值引用似乎不仅没有提高效率,反而降低了代码的效率,那么为什么要引入右值引用?什么情况下使用右值引用?先说结论,只有同时满足下列条件,右值引用效率更高:

  • 右值是一个对象,对象含有指向堆空间数据的指针成员;
  • 右值作为函数参数传递;
  • 函数内部对右值对象数据进行了拷贝操作。

举个例子:

class Object
{
public:
Object(){m_data = new Data()}
Object(const Object& obj) {m_data = new Data(obj.m_data);};
Object(Object&& obj) = default;
~Object(){delete m_data;m_data=nullptr;}
private:
Data *m_data;//满足条件1
};

std::vector<Object> vec;
vec.push_back(Object());//满足条件2和3

至于为什么vector<T>::push_back(T&&)vector<T>::push_back(T&)效率更高,需要了解深拷贝和浅拷贝的概念,这里就不多做讲解,只是说明一下,前者使用的是浅拷贝,后者使用的是深拷贝,因此前者更节省时间。

如果我们修改一下拷贝构造函数,把深拷贝改为浅拷贝:

Object(const Object& obj) {m_data = obj.m_data;obj.m_data=nullptr;};

这时vector<T>::push_back(T&& obj)vector<T>::push_back(T& obj)效率是否一样呢?是的。

3.类型转换

过去常用的类型转化有dynamic_caststatic_castreinterpret_castconst_cast等等,引入左右值引用后,就多了两个类型转换的方式,移动语义std::move和完美转发std::forward

3.1.移动语义

std::move无条件地将实参转为右值引用类型。为什么要引入移动语义?因为要把左值转化为右值;那为什么要把左值转化为右值?使用户能够通过左值初始化同类对象时,调用对象的移动构造函数完成拷贝。这句话比较难理解,现在一步步来解释。

3.1.1.移动构造函数

C++11引入了移动构造函数,默认移动构造函数会执行浅拷贝操作,与拷贝构造函数不同的是,在拷贝结束后,移动构造函数会把源对象内的指针置空,借用一张图:


移动构造

所谓移动(move)指的就是将其他对象(通常是临时对象)在堆上拥有的内存资源“移为己用”。

3.1.2.move

通过例子说明move的作用:

//函数1
void function(int&& t) {
std::cout<<“rvalue\n;
//
}
//函数2
void function(int& t){
std::cout<<“lvalue\n;
//
}

int main()
{
int&& a =2;
function(a);//lvalue
return 0;
}

假设没有std::move,如果我们想用函数1来处理a对应的资源数据,是不可能实现的。根据前面的讲解我们可以知道,如果某块资源能够通过变量名获取,那么这块资源是可取地址的,换句话说,它是一个左值引用。因此当我们把一个引用名传递给function(),调用的始终是函数2。std::move的作用就是告诉编译器,用函数1来处理引用a指向的数据。

我们可以换个角度来理解 move:可以把 move看做是编译器的指令,它告诉编译器该调用函数1还是函数2来处理 a

现在再结合代码来理解这句话:std::move使用户能够通过左值初始化同类对象时,调用对象的移动构造函数完成拷贝。

class Object
{
public:
Object(){m_data = new Data()}
Object(Object& obj) {m_data = new Data(obj.m_data);};
Object(Object&& obj) = default;
~Object(){delete m_data;m_data=nullptr;}
private:
Data *m_data;//满足条件1
};

Object obj1;
Object obj2(obj1);//调用的是拷贝构造函数
Object obj3(std::move(obj1));//调用的是移动构造函数

3.2.完美转发

首先解释一下什么是完美转发,它指的是函数模板可以将自己的参数“完美”地转发给内部调用的其它函数。所谓完美,即不仅能准确地转发参数的值,还能保证被转发参数的左、右值属性不变。举个例子(注意!本例中使用右值的方式并没有提高运行效率,因为它不满足我们前面提到的三个要求,这样写只是为了说明完美转发的作用):

//函数1
void function(int&& t) {
std::cout<<“rvalue\n;
//
}
//函数2
void function(int& t){
std::cout<<“lvalue\n;
//
}

int main()
{
int n = 10;
int & num = n;
function(num); // 输出lvalue
int && num2 = 11;
function(num2); // 输出lvalue
return 0;
}

我们期望function(num2);输出rvalue,但实际上输出的是lvalue,这是因为右值引用num211初始化后,num2就是一个有名称、或者说可以取地址的值了,换句话说,经过编译后,num2由右值引用变成了左值引用。我们修改一下代码:

int main()
{
int n = 10;
int & num = n;
function(std::forward<int>(num)); // 输出lvalue
int && num2 = 11;
function(std::forward<int>(num2)); // 输出rvalue
return 0;
}

结果与预期相符,也就是说完美转发能够保证左值引用在传递过程中始终是左值引用,右值引用在传递过程中始终是右值引用。其实我们把代码修改如下,也能得到同样的输出结果:

int main()
{
int n = 10;
int & num = n;
function(num); // 输出lvalue
int && num2 = 11;
function(std::move(num2)); // 输出rvalue
return 0;
}

如果我们想调用函数1,直接把变量名传递给function,如果我们想调用函数2,对变量名move操作后再传递给function就可以了,代码也很简洁,既然如此,为什么还要引入std::forward?其实在非模板编程中,由于我们很容易知道每个引用到底是左值引用还是右值引用,完全可以不用std::forward;但是在模板编程中,经过层层引用折叠之后,我们很难知道某个类型是左值引用类型还是右值引用类型,std::forward就是为了简化模板编程而出现的。

本文使用 Zhihu On VSCode 创作并发布
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值