C++概念:值类别(左值、右值)、引用

2,122 阅读2分钟

C++11引入了许多新的概念,本文主要讲清楚这些概念:左值(lvalue)、右值(rvalue)、纯右值(prvalue)、将忙值(xvalue)、泛左值(glvalue)、左值引用、右值引用。

1. 基本概念

1.1. 表达式

由运算符(operator)和运算对象(operand)构成的计算式。例如:数学上的算术表达式、字面值(literal)、变量(variable)、函数的返回值等都是表达式。

1.2. 值类别

表达式是可求值的,对表达式求值将得到一个结果(result)。这个结果有个属性:值类别(value categories)

注意:(value)和变量(variable)是两个独立的概念:

  • 值只有类别(category)的划分;变量只有类型(type)的划分,例如:intfloatdouble、引用类型(左值引用,右值引用、常引用)等
  • 值不一定拥有身份(identity),也不一定拥有变量名

C++11以后,表达式按值类别分,必然属于以下三者之一:左值(left value,lvalue),将亡值(expiring value,xvalue),纯右值(pure rvalue,pralue)。其中,左值和将亡值合称泛左值(generalized lvalue,glvalue),纯右值和将亡值合称右值(right value,rvalue)

2. 左值、纯右值和将亡值的描述

2.1. 左值

存储单元内的值,即是有实际存储地址的。故它是能够使用&取地址的表达式。左值一般有如下:

  • 函数名和变量名
  • 返回左值引用的函数调用
  • 由赋值表达式或赋值运算符连接的表达式(a=b, a += b等)
  • 前置自增自减表达式++i、--i (根据前置运算规则,++ii1后再赋给i,最终的返回值就是i,所以,++i的结果是具名的,名字就是i
  • 字符串字面值"abcd"(早期C++将字符串字面值实现为char型数组,实实在在地为每个字符都分配了空间并且允许程序员对其进行操作)
cout<<&("abc")<<endl;
char *p_char="abc";//注意不是char *p_char=&("abc");
  • 解引用表达式*p

2.2. 纯右值

C++11之前纯右值和右值时等价的。所谓右值,即不是存储单元内的值,比如它可能是寄存器内的值也可能是立即数。可以归结满足下列条件之一:

  • 本身就是赤裸裸的、纯粹的字面值,如3、false
  • 求值结果相当于字面值或是一个不具名的临时对象。
int i = 1; // 右值1就是立即数

/**********/
int fn() { return 1; }
int main() { int i = fn(); } // fn()的返回值就是寄存器内值

常见的纯右值右如下:

  • 除字符串字面值外的字面值
  • 返回非引用类型的函数调用
  • 后置自增自减表达式i++、i--(后置运算规则:对于i++而言,是先对i进行一次拷贝,将得到的副本作为返回结果,然后再对i1,由于i++的结果是对i1i的一份拷贝,所以它是不具名的。)
  • 算术表达式(a+b, a*b, a&&b, a==b等)
  • 取地址表达式等(&a)

2.3. 将忙值

C++11中的将亡值是随着右值引用的引入而新引入的。换言之,“将亡值”概念的产生,是由右值引用的产生而引起的,将亡值与右值引用息息相关。所谓的将亡值表达式,就是下列表达式:

  • 返回右值引用的函数的调用表达式
  • 转换为右值引用的转换函数的调用表达

将亡值进一步可以理解为即将要销毁的值,通过“盗取”其它变量内存空间方式获取的值,在确保其它变量不再被使用或者即将被销毁时,可以避免内存空间的释放和分配,延长变量值的生命周期,常用来完成移动构造或者移动赋值的特殊任务。

2.4. 值类别总结

  • gvalue: has identity
  • lvalue: has identity and can not be moved from
  • rvalue: can be moved from
  • xvalue: has identity and can be moved from
  • prvalue: does not have identity and can be moved from

3. 引用:左值引用、右值引用

在《C++的设计与演化》书中曾说过,当初加入“引用”这个语言特性的契机是运算符重载。为了让运算符重载的语法能够更加接近内建的运算符,需要能够让一个函数返回一个左值,通俗的讲就是要能够对一个函数的返回值赋值。例如支持下列语法:

IntArray a;
... ...
a[10] = 9527;  // 你想要的效果

显然你需要为IntArray增加一个运算符重载,而运算符重载是一个函数,上面你想要的那一句实际上是一个函数调用:

IntArray a;
... ...
a.operator[](10) = 9527;  // 实际的语义

这个时候你就得思量一下operator[]的返回值是什么。C语言里只有两个选择,返回int或者返回int*,前者返回出来的是个拷贝,无法赋值,后者的话需要改成*(a[10]) = xxx,失了语法方便的本意。为了解决这个两难问题,所以加上了引用这个特性。

在变量初始化 (initialization) 时,需要将 初始值(initial value)绑定到变量上;但引用类型变量 的初始化 和其他的值类型(非引用类型)变量不同:

  • 创建时,必须显式初始化(和指针不同,不允许 空引用(null reference);但可能存在 悬垂引用(dangling reference)
  • 相当于是 其引用的值 的一个 别名(alias)(例如,对引用变量的 赋值运算(assignment operation) 会赋值到 其引用的值 上)
  • 一旦绑定了初始值,就 不能重新绑定 到其他值上了(和指针不同,赋值运算不能修改引用的指向;而对于 Java/JavaScript 等语言,对引用变量赋值 可以重新绑定)

引用可以分为左值引用(C++11以前都是左值引用)和右值引用(C++11提出的概念)。所谓左值引用就是对左值进行引用的类型,右值引用就是对右值进行引用的类型,他们都是引用,可以看作都是对象的一个别名,并不拥有所绑定对象的内存,所以都必须立即初始化。

type &name = exp; // 左值引用
type &&name = exp; // 右值引用

进一步我们可以左值引用、右值引用细分为如下:

  • 左值引用T & : 只能用于绑定左值的引用,即非const左值。
  • 常量左值引用const T &: 常量左值引用可以绑定左值或者右值,即const左值、非const左值、const右值、非onst右值。(因此,当类的声明中没有移动构造函数时,即A(A&& rhs){},传递临时变量,任然可以调用A(const A& rsh){}构造函数进行构造)
  • 具名非const右值引用T && : 只能绑定到非const右值
  • 具名const右值引用const T && : 绑定到const右值和非const右值,它没有现实意义(毕竟右值引用的初衷在于移动语义,而移动就意味着修改)
  • 无名右值引用std::move(T): 没有变量名的右值引用,不能绑定左值或者右值,生命周期在下一个分号结束。
  • 函数类型例外
int foo() {
  return 0;
}
int a = 0;
int &b = a;                 //可以, 左值引用绑定左值
int &c = 1;                  //不行,左值引用不能绑定右值
int &d = foo();            //不行,左值引用不能绑定右值
const int &e = a;        //可以,常量左值引用可以绑定左值
const int &f = foo();   //可以,常量左值引用可以绑定右值
int && g = b;             //不可以,具名右值引用不可以绑定左值
int && h = foo();        //可以,具名右值引用绑定右值
int && i = a + a;        //可以,具名右值引用绑定右值
std::move(a) = 1;        //不可以,无名右值引用不能绑定左值或者右值
int && j = std::move(a);//可以,具名右值引用绑定无名右值引用
/***********************/
void fun() {}
typedef decltype(fun) FUN;  // typedef void FUN();
FUN       &  lvalue_reference_to_fun       = fun; // ok
const FUN &  const_lvalue_reference_to_fun = fun; // ok
FUN       && rvalue_reference_to_fun       = fun; // ok
const FUN && const_rvalue_reference_to_fun = fun; // ok

3.1 引用参数重载优先级

针对不同左右值 实参 (argument) 重载 引用类型 形参 (parameter) 的优先级如下:

实参/形参T&const T&T&&const T&&
lvalue12--
const lvalue-1--
rvalue-312
const rvalue-2-1
  • 数值越小,优先级越高;如果不存在,则重载失败。
  • 如果同时存在 传值(by value) 重载(接受值类型参数 T),会和上述 传引用(by reference) 重载产生歧义,编译失败。
  • 常右值引用(const rvalue reference)const T&& 一般不直接使用。原因:右值引用主要的目的是移动对象而不是拷贝对象;而移动后的目的在于修改它。

3.1. 深入探索左值引用

本质上是开辟了内存用于保存被引用变量的地址。实际上,只要一旦使用,在编译器内部就会自动进行解应用。也就是说永远不可能访问到引用变量b的地址,因为每当你使用引用时,已经经过解引用。


void func3() 
{
	int a = 5;
	int &ra = a;
}

对应的汇编为:

.globl	_Z5func3v
	.type	_Z5func3v, @function
_Z5func3v:
.LFB2:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5
	subl	$16, %esp
	movl	$5, -8(%ebp)     // 即 a=5
	leal	-8(%ebp), %eax   // 将a的地址保存到ax寄存器
	movl	%eax, -4(%ebp)   // 在栈上开辟空间,将ax的内容(即a的地址)存放到该内存中
	leave
	.cfi_restore 5
	.cfi_def_cfa 4, 4
	ret
	.cfi_endproc
.LFE2:
	.size	_Z5func3v, .-_Z5func3v

3.2. 深入探索具名右值引用

具名右值引用会为自己的引用的内容开辟了一块内存空间。此时一个具名右值引用所需要的空间为 内容地址(x64: 8字节) + 内容大小,比如下面的例子,在栈上开辟了12字节,后4字节存放(int)1,前8字节存放内容的地址。

int main() {
  int &&a = 1;
  return 0;
}

其汇编为:

    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $1, %eax
    movl    %eax, -12(%rbp)     ;  [rbp - 12 , rbp - 8) <-- (int)1
    leaq    -12(%rbp), %rax      ;  取地址,rax = rbp - 12
    movq    %rax, -8(%rbp)      ; 存地址 [rbp - 8, rbp) <--- rbp - 12
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc

因此,我们可以修改具名右值引用的内容,而无需像左值引用一样必须先申请一个变量。例如:

#include <iostream>
int foo() {
  return 0;
}
int main() {
  int &&a = foo();
  a = 2;
  std::cout<<a<<std::endl; //输出为2
}

所以在具名右值引用的操作下,我们可以修改右值通过如下方式:

  • 通过具名右值引用本身直接修改
  • 通过T & = T && 的方式让其他引用修改
#include <iostream>
int main() {
    int &&a = 0;
    int &b=a;
    b=4;
    std::cout<<a<<std::endl;
}

4. 参考文献

左值引用、右值引用、移动语义、完美转发,你知道的不知道的都在这里
C++11新特性:std::move()和std::forward()
C++11 std::move和std::forward
详解C++11中移动语义(std::move)和完美转发(std::forward)
[C/C++]关于C++11中的std::move和std::forward
左值 右值 左值引用 右值引用 std::move std::forward C++11 中的左值、右值和将亡值
用汇编解释左值,右值,临时变量和引用