理解C++值类别(lvalue, rvalue, prvalue, xvalue)
C++ 值类别 (value categories)概述
在 C++中值类别并不是语言特性, 他们更多的是表达式的语法属性. 理解它们有助于理解:
- 内置类型和用户定义的操作符
- 引用类型
- 移动语义
值类别也是不断演进的:
- 在早期的 C 语言中, 只有两种值类别: 左值(lvalue)和右值(rvalue). 与之相关的概念也很简单.
- 早期的 C++扩增了类(class),
const
修饰符, 和引用类型. 这使得值类别更加丰富. - 现代 C++ 引入了右值引用(rvalue references). 为此不得不引入更多的值类别来描述相关的行为.
左值和右值的定义
在 C Programming Language 这本书中, Kernighan 和 Ritchie 给出的定义是: 出现在赋值表达式=
号左边的操作数(operand)是左值.
E1 = E2;
-
一个左值(
lvalue
)是指向一个对象(Object)的表达式. 一个对象则指一块存储区域.int n; // 定义了一个名为`n`的int类型对象 n = 1; // 一个赋值表达式
上面代码中:
n
表示一个int
对象, 它是左值.1
表示一个整型常量, 它是右值.
-
右值(rvalue)被定义为: 不是左值的表达式.
下面的语句尝试修改一个整型常量
1
. 当然, C++会拒绝它.1 = n; // 明显不合法
因为一个赋值语句会给一个对象赋值, 赋值语句的左边操作数必须是左值, 但是
1
不是左值, 它是右值.
为什么要区分左值和右值
- 这样的话编译器可以假设右值(rvalue)不会占用存储空间.
- 这给编译器机器码生成提供了更多的自由.
右值的存储
我们再次看这个例子:
int n;
n = 1;
-
一个编译器或许会把字面量
1
存放在数据区, 把1
看做是一个左值. 这样的情况下它会生成下面的汇编代码:one: ; 一个标签, 指代下面对象的地址.word 1 ; 为整数1分配一个存储位置
那么编译器将会从
one
复制到n
move n, one ; 复制`one`地址的值到`n`变量地址
-
实际上, 现代 CPU 上, 有源操作数是立即数(immediate operand)的指令. 源操作数是立即数意味着数值是指令的一部分.
在汇编语言中, 会这样写:
mov n, #1 ; 将数值 #1 拷贝到地址`n`
这种情况下: 右值
1
将会作为指令的一部分在代码区.以 x86-64 为例, 我们假定
n
已经被加载到寄存器RAX
中, 那么n=1
就可以被写为:MOV RAX, 1
, 翻译为机器码就是:B8 01 00 00 00
其中:
B8
是操作码, 表示向RAX
寄存器赋值一个 32 位的立即数.01 00 00 00
是立即数1
的小端序表示(最低有效字节位于最低地址).
字面量
很多的字面量是右值, 他们不一定占用数据存储. 包括:
- 数字字面量, 如 3, 3.14
- 字符字面量, 如 ‘a’, ‘b’
然而, 字符串字面量是左值. 它们占用存储空间. 比如"Hello"
这种字符串字面量.
左值和右值的区别
-
一个左值可以出现在赋值表达式的任意一边, 比如:
int m, n; m = n; // OK, m和n均是左值
这个表达式将左值(
n
)当做右值来使用. 从 C++术语讲, 这进行了一次左值到右值的转换. -
表达式的操作数可以是左值或右值.
比如, '+'操作符必须是两个表达式.
int x;x + 2; // OK, lvalue + rvalue 2 + x; // OK, rvalue + lvalue
-
运算结果是右值: 对于内置的二元操作符(不包含赋值
=
操作符), 比如’+', 它的操作数可能是左值或右值, 它的结果是一个右值.- 表达式
m+n
的结果将放在一个编译器生成的临时变量中, 通常是一个 CPU 寄存器. 这样的临时变量通常被称为右值(rvalue
). - 表达式
m + 1 = n
是错误的, 因为m+1
的结果是一个右值, 不能出现在赋值表达式的左边.
- 表达式
-
解引用操作符
*
是左值: 解引用操作符*
的结果是一个左值. 一个指针p
可以指向一个对象, 所以*p
是一个左值.int arr[10]; int *p = arr; char *str = nullptr; *p = 1; // OK *str = 'a'; // undefined behavior
注意: 左值属性是一个编译时属性.
*str
是一个左值即便str
是空指针. -
并非所有的左值都可出现在
=
左边: 有const
修饰的左值是不可修改的.char const name[] = "hello"; name[0] = 'H'; // 错误, name是const左值
name[0]
是左值, 但是不可修改. -
枚举类型是右值
enum { MAX = 100 }; MAX +=3; // 错误 const int *p = &MAX; // 错误
表达式的数据存储
从概念上将,
- 右值(内置类型)通常不占用空间. 但是实际上可能会.
- 左值(任意类型)都会占用空间, 但是实际上编译器可能会优化掉.
C/C++让程序员假定左值(内置类型)占用空间而右值不占用空间.
class 类型的右值
考虑如下的类型:
struct S {int x;int y;
};
那么编译器如何为下面代码生成汇编代码:
S s1 = {1, 2};
int i = s1.y;
编译器会生成下面的汇编代码:
s1:.long 1.long 2
i:.zero 4mov eax, DWORD PTR s1[rip+4]mov DWORD PTR i[rip], eax
可以看到编译器使用 基址(base)+偏移量(offset) 来加载s1.x
的值到i
变量中.
接着再考虑下面的代码:
S foo(); // 返回值是S类型
int j = foo().y; // 访问返回值对象的y成员
同样的, 会使用 基址+偏移量 来加载foo().y
的值到j
变量中. 因此, foo()
的返回值必须要有一个基址.
从概念上将, 任何有地址的对象都占用存储(废话, 不然地址从哪来). 这就是为什么 class 类型的右值需要被区别对待.
引用类型
左值和右值也有助于解释引用类型(reference type). 引用提供了指针之外的另一种方式来为一个对象创建别名.
int i;
int &ref = i;
最后一行创建了一个指向int
类型的引用ref
, 并用i
来初始化它. 因此, 引用ref
是i
的别名.
引用类型实际上是一个指针, 并且在每次使用的时候解引用. 可以用指针来重写绝大多数引用类型:
引用 | 指针 |
---|---|
int &ref = i; | int *const p = &i; |
ref = 1; | *p = 1; |
int j = ref + 1; | int j = *p + 1; |
一个引用产生一个左值.
const 类型引用
一个指向 const
的引用(reference to const
)能接受一个const
或者非const
类型的参数.
R f(T const& t); // reference to const
在很多情况下, 它与下面的函数表现一致
R f(T t); // by value
也就是调用和返回值都是一样的. 上述两种方式的调用中, 都不会改变x
的值.
- 传参方式:
f
将会得到x
的一个拷贝, 不会修改原来的x
值 - 传引用方式:
f
将会返回一个const
类型的值, 也不会修改原来的x
值
那么为什么要有reference to const
呢?
- 在某些情况下传引用方式要比传值方式更高效, 比如
T
类型是一个很大的对象时. - 这主要依赖于拷贝的代价. 对于小的类型(
int
,bool
), 传值方式比传引用方式更高效.
引用和临时变量
指针只能绑定到左值上, 类似的, 普通的引用只能绑定到左值.
int *pi = &3; // 错误
int& ri = 3; // 错误
但是有个例外, const T&
可以绑定到一个非左值的T
, 如果存在一个从x
类型到T
的类型转换(conversion).
这种情况下, 编译器将一个临时变量来保存 T(从x
转换而来), 然后将临时变量绑定到const T&
.
double const& rd = 3;
当程序执行到此处时:
- 将
3
从int
转为double
- 创建一个临时变量来保存转换后的值
- 将临时变量绑定到
const double&
当程序离开此作用域时, 临时变量被销毁.
两种类型的右值
从概念上讲, 内置类型的右值不占据数据存储. 然而, 创建的临时变量是占用空间的, 即便是内置类型.
临时变量依旧是右值, 只不过他们占用存储. 就像自定义类型的右值一样. 所以就出现了两种该类型的右值:
- 纯右值(Pure rvalues)或者"prvalues", 它们不占据存储.
- 将亡值(Expiring values)或者(Xvalues), 它们占据存储.
临时对象是通过 temporary materialization conversion
创建的. 它将一个 纯右值(prvalue
) 转为 将亡值(xvalue
).
右值引用
C++11 引入了新的引用类型: 右值引用(rvalue references). C++03 中的引用, 在 C++11 中被称为左值引用(lvalue references).
-
右值引用使用
&&
来表示.int&& ri = 10;
-
可以使用右值引用作为函数参数和返回类型:
double &&f(int &&ri);
-
也存在指向常量的右值引用(
rvalue reference to const
), 比如:int const&& rci = 10;
-
右值引用只能绑定右值, 无论是否是
const
.int n = 10; int &&ri = n; // 错误 int const &&rci = n; // 错误
绑定一个右值引用到一个右值会触发一个"temporary materialization conversion"
.
这与绑定一个"lvalue reference to const" 到一个右值类似.
move 操作
现代 C++支持通过移动操作来避免不必要的拷贝. 主要有移动构造函数和移动赋值函数.
class String {public:String(const char*); // 构造函数String& operator=(String const&); // 赋值函数// move operationString(String&&) noexcept; // 移动构造函数String& operator=(String&&) noexcept;// 移动赋值函数
};
给定下面的对象:
String s1, s2, s3;
-
对下面的赋值语句来说, 因为
s2
还要用, 所以s2
不会被移动.s1 = s2; // String::operator=(String const&)
-
对下面的语句来说会使用移动操作, 因为(
s2+s3
是一个右值):s1 = s2 + s3; // String::operator=(String&&)
右值引用作为左值
绑定右值引用到一个右值会创建一个xvalue
.
看下面的代码:
String& String::operator=(String&& rhs) noexcept {String temp(rhs); // calls string(String const&)// ...
}
在 operator=
中, rhs
将会在整个函数周期内存在, 因此在函数体内, 它是一个左值.
一般来说, 有名字的对象是一个左值.
左值(lvalue)作为将亡值(xvalue)
有时候从左值移动是有意义的, 例如:
template <typename T>
void swap(T& a, T& b) {T temp(a); // 拷贝构造a = b; // 拷贝赋值b = temp; // 拷贝赋值
}
这种情况下编译器会拷贝构造temp
, 因为a
后续还在被使用. 但是我们知道a
会在下一行被覆盖, 所以可以将a
的值移动到temp
中.
从一个将要消亡的值中移动是安全的. 但是编译器并不能始终发现这些将亡(Expiring)值. 如何告诉编译器呢?
要从一个lvalue
移动值, 你需要将其转为一个xvalue
. 换句话说就是要将其转换为一个未命名的右值引用. 这就是std::move
做的事情.
template <typename T>
constexpr T&& move(T&& t) noexcept {return static_cast<T&&>(t);
}
对前面提到的swap
函数, 可以这样改造:
template <typename T>
void swap(T& a, T& b) {T temp(std::move(a)); // 移动构造a = std::move(b); // 移动赋值b = std::move(temp); // 移动赋值
}
值类别总结
如下就是 C++11 之后的值类别:
我们可以发现, 新增了如下的值类别:
glvalue
: 泛左值(“generalized lvalue”)prvalue
: 纯右值(“Pure rvalue”)xvalue
: 将亡值(“Expiring lvalue”)
相关阅读
- C++ 必知必会: 移动语义(Move Semantics)