C++左值和右值区别
一、先破除旧定义
教科书常说"能放在赋值号左边的是左值"。这个说法在 C 时代勉强够用,在 C++ 里已经不成立:
const int a = 1; // a 是左值,但不能放在赋值号左边
int arr; // arr 是左值,也不能被赋值
;
S.x = 1; // 能放在左边,但 S{}.x 是右值(xvalue)
准确的判据是两个正交的属性。
二、C++11 的值类别体系
任何表达式都有两个独立的属性:
- 身份(identity):是否指代一个确定的、可以取地址的内存对象
- 可移动(movable):是否允许把它的资源偷走
| 有身份 | 无身份 | |
|---|---|---|
| 不可移动 | lvalue(左值) | — |
| 可移动 | xvalue(将亡值) | prvalue(纯右值) |
组合出五个名字:
expression
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
(交集)
- glvalue(泛左值)= 有身份 = lvalue + xvalue
- rvalue(右值)= 可移动 = xvalue + prvalue
- lvalue(左值)= 有身份且不可移动
日常说的"左值 / 右值"就是 lvalue 和 rvalue 这两个。
三、实用判据
面试和日常写代码,记住这两条就够了:
能取地址(
&expr合法)→ 左值;否则 → 右值。 有名字 → 左值。 ← 这条最容易被忽略
第二条的推论非常反直觉:
void
类型是右值引用 ≠ 表达式是右值。 s 有名字、能取地址,所以它是左值。这是移动语义里最经典的坑:写了 && 参数却忘了 std::move,移动悄悄退化成拷贝。
四、对照表
| 表达式 | 类别 | 说明 |
|---|---|---|
a(变量名) |
lvalue | 哪怕类型是 int&& |
*p |
lvalue | 解引用 |
arr[i] |
lvalue | |
++a |
lvalue | 前置返回自身引用 |
a = 1 |
lvalue | 赋值表达式返回左值引用 |
"hello" |
lvalue | 字符串字面量是 const char[6] 数组,能取地址 |
42、true、nullptr |
prvalue | 字面量(除字符串外) |
a + b |
prvalue | 算术结果 |
a++ |
prvalue | 后置返回旧值副本 |
f() 返回 T |
prvalue | 按值返回 |
f() 返回 T& |
lvalue | |
f() 返回 T&& |
xvalue | |
std::move(a) |
xvalue | 本质是 static_cast<T&&> |
T{}、T(1,2) |
prvalue | 临时对象 |
S{}.member |
xvalue | 右值的成员是将亡值 |
lambda |
prvalue |
五、引用的绑定规则
这张表是右值概念的实际用武之地:
| 引用类型 | 绑左值 | 绑右值 |
|---|---|---|
T& |
✓ | ✗ |
const T& |
✓ | ✓ |
T&& |
✗ | ✓ |
第二行是老 C++ 就有的:const T& 能绑右值,并且延长临时对象的生命周期到引用本身的作用域结束。
const std::string& s = ; // 临时对象活到 s 离开作用域
(注意这个延长不传递:函数返回一个绑到临时对象的 const T&,返回后就是悬垂引用。)
第三行是 C++11 新增的,它让"这个对象我不要了"成为一个可被重载决议识别的信号:
void ; // 左值走这里 → 拷贝
void ; // 右值走这里 → 可以放心偷
六、为什么要引入右值:移动语义
;
Buf ;
Buf b = ; // make() 是 prvalue → 走移动(C++17 起直接省略构造)
Buf c = b; // b 是 lvalue → 走拷贝
Buf d = ;// 转成 xvalue → 走移动,之后 b 处于有效但未指定状态
关键点:移动构造一定要标 noexcept,否则 std::vector 扩容时为了保证强异常安全会退化成拷贝(std::move_if_noexcept)。
七、两个容易混的点
① T&& 不一定是右值引用
void ; // 右值引用,只收右值
void ; // 转发引用!左右值都收
auto&& y = expr; // 转发引用
只有在模板参数推导或 auto 的语境下,T&& 才是转发引用。它靠引用折叠工作:传左值时 T 推成 U&,U& && 折叠成 U&。折叠规则一句话:只要有一个 &,结果就是 &。
② std::move 和 std::forward 的分工
std::forward<T>// 按 T 的推导结果还原原始值类别,只用在转发引用上
std::move 本身不移动任何东西,它只是个 static_cast,真正干活的是随后被选中的移动构造/赋值。
八、C++17 的一个重要变化
C++17 起,prvalue 不再被理解为"一个临时对象",而是"初始化某个对象的配方",只有在真正需要一个对象时才**物化(materialize)**成 xvalue。后果是返回值优化(RVO)从"允许优化"变成"语言保证":
;
NoCopyNoMove
NoCopyNoMove x = ; // C++17 合法:从头到尾只有一个对象,没有任何构造被省略
所以现在写 return local; 时不要画蛇添足写 return std::move(local); —— 那反而会阻止 NRVO,把零成本变成一次移动。
面试一句话答案:
左值有身份、不可移动,能取地址、有名字;右值分纯右值(无身份,如字面量和按值返回的临时量)和将亡值(有身份但允许被偷,如
std::move的结果)。区分它们的意义在于重载决议能识别"这个对象即将销毁",从而用 O(1) 的资源转移替代 O(n) 的深拷贝。注意T&&类型的具名变量本身是左值。
**将亡值(xvalue,eXpiring value)**是 C++11 引入的值类别,指"有身份、但允许被掏空"的表达式。
一、它在值类别体系里的位置
任何表达式有两个正交属性:
| 有身份(指代一个确定的对象) | 无身份 | |
|---|---|---|
| 不可移动 | lvalue 左值 |
— |
| 可移动 | xvalue 将亡值 |
prvalue 纯右值 |
expression
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
↑
两者的交集
glvalue(泛左值)= 有身份 = lvalue + xvaluervalue(右值)= 可移动 = xvalue + prvalue
xvalue 是这个二维表格里唯一"两边都沾"的格子。 C++03 只有 lvalue/rvalue 二分,是移动语义的引入让"有身份且可移动"这个组合第一次有了意义,才需要给它起个名字。
二、名字的含义
"将亡" = 这个对象即将结束生命,所以你可以放心偷它的资源。
但要注意:这是一个许可,不是一个事实。
std::string s = "hello";
; // s 是将亡值 —— 但 s 一行都没少活
std::cout << s; // s 还好好的,还是 "hello"
std::move 不移动任何东西,它只是把表达式标记成"可以被偷"。真正掏空 s 的是随后被重载决议选中的移动构造函数。如果没人接手,什么都不会发生。
三、xvalue 从哪来
| 表达式 | 说明 |
|---|---|
std::move(x) |
本质就是 static_cast<T&&>(x) |
static_cast<T&&>(x) |
显式转成右值引用 |
f(),其中 f 返回 T&& |
返回右值引用的函数调用 |
S{}.member |
右值的非静态数据成员 |
std::move(arr)[0] |
右值数组的下标 |
| 临时量物化(C++17) | prvalue 在需要 glvalue 时自动转成 xvalue |
最后一条最容易被忽略。C++17 起 prvalue 不再是"一个临时对象",而是"初始化对象的配方"。只有当你需要一个真正的对象时,才发生临时量物化转换(temporary materialization),prvalue → xvalue:
const int& r = 42; // 42 是 prvalue → 物化成临时对象(xvalue)→ 绑定引用
sizeof; // 不需要真对象,不物化
std::string.; // 调成员函数需要对象 → 物化
四、怎么判断一个表达式是不是 xvalue
最可靠的办法是用 decltype,它对三种值类别给出不同结果:
int x = 0;
decltype // int ← 特例:具名变量给【声明类型】
decltype // int& ← 加括号变成表达式 → lvalue
decltype // int ← prvalue
decltype // int&& ← xvalue ✓
规律:
| 值类别 | decltype(expr) |
|---|---|
| lvalue | T& |
| xvalue | T&& |
| prvalue | T |
标准库也直接提供了判定:
static_assert;
五、xvalue 和 prvalue 有什么实际区别
两者都是右值,重载决议时行为一致 —— 都能绑到 T&&,都会选移动构造。那为什么还要分?
因为 copy elision(复制省略)只对 prvalue 生效。
;
NoCopyNoMove // prvalue
NoCopyNoMove x = ; // ✓ C++17 保证:全程只有一个对象
prvalue 是"配方",可以直接在目标位置就地构造,一次构造都不需要省略。而 xvalue 已经指代一个存在的对象,只能真的做一次移动。
这直接解释了那条著名的规则:
Widget
std::move 把一个 NRVO 候选变成了 xvalue,把零成本变成了一次移动。所以 return local; 永远不要加 std::move。
六、一个必须记住的陷阱
"类型是右值引用" ≠ "表达式是 xvalue":
void
原因很简单:s 有名字、能取地址,符合左值的定义。具名变量永远是左值,哪怕它的类型带 &&。
这也是 std::forward 存在的理由 —— 在模板里,"调用者原本传的是左值还是右值"这个信息只保存在 T 的推导结果里,必须靠 std::forward<T>(x) 通过引用折叠把它还原出来。
一句话总结
将亡值(xvalue)是"有身份、可移动"的表达式:它像左值一样指代一个具体的对象,又像右值一样允许你把资源偷走。典型来源是
std::move和返回右值引用的函数。它和纯右值同属右值、重载决议行为一致,但纯右值能享受 C++17 保证的复制省略而将亡值不能 —— 这正是return std::move(local)会拖慢代码的原因。
对,但要分清一件事:将亡值是"表达式"的属性,不是"变量"的属性。
一、精确地说
std::string s = "hello";
; // 表达式 `std::move(s)` 是 xvalue —— 这一点永远成立
s; // 表达式 `s` 是 lvalue —— 这一点也永远成立
s 这个变量从来没有"变成"将亡值。每个表达式各自有自己的值类别,逐个独立判定,互不影响。 写一百次 std::move(s),s 本身在别处仍然是左值。
所以"s 是将亡值"这个说法本身就不严谨,准确的说法是"std::move(s) 这个表达式是将亡值"。
二、std::move 到底做了什么
constexpr std::remove_reference_t<T>&&
一个 static_cast,编译后不产生任何指令。它唯一的作用是:让这个表达式在重载决议时被当成右值看待。
所以 std::move(s); 这一行单独写出来是个弃值表达式:类型转换发生了,但结果没人接手,什么都不会发生。等价于写了个 (void)s;。
三、三种"move 了却什么都没发生"的情况
① 结果没被接手
; // ✗ 白写。转换发生了,但没有移动构造被调用
std::string t = ; // ✓ 被移动构造接手,s 才真的被掏空
② 对象是 const
const std::string cs = "hello";
std::string t = ; // 编译通过,但执行的是【拷贝】
std::move(cs) 的类型是 const std::string&&,绑不上 string(string&&),重载决议只好退回 string(const string&)。静默降级,没有任何警告。
③ 类型没有移动构造
; // 无自定义移动构造,移动就是逐字节拷贝
Big b = ; // 走拷贝,a 完全不变
对于平凡可复制的类型,"移动"和"拷贝"本来就是一回事。
四、编译器不会"记住"你 move 过
这一点最关键:语言层面没有任何流分析。
std::string s = "hello";
std::string t = ; // s 内容被搬走
std::cout << s; // ✓ 合法,编译不报错(输出通常是空)
std::string u = s; // ✓ 合法,拷贝 s 当前的内容
s = "world"; // ✓ 合法,s 完全可以复活
std::cout << s; // "world"
编译器对 s 的类型和值类别的看法从头到尾没变过。改变的只是运行期 s 的内容。
所以 use-after-move 是一个语义 bug,不是编译错误。想让工具帮你抓,需要静态分析器:
它会做数据流分析,警告"这个变量被 move 之后又被读了"。
五、被移动后的对象处于什么状态
标准库类型保证处于 "有效但未指定"(valid but unspecified) 的状态:
- 可以做:没有前置条件的操作 —— 赋新值、
clear()、size()、析构 - 不能做:假设它的具体内容
std::string t = ;
s.; // ✓ 合法,返回某个值
s = "new"; // ✓ 合法,s 完全可用
s; // ✗ 有前置条件(要求非空),如果 s 空了就是 UB
; // ✗ 不保证!实践中通常为空,但标准没规定
std::unique_ptr 是少数明确规定了移动后状态的类型(保证变成 nullptr)。其余的都别假设。
六、什么时候该写 std::move
// ✓ 局部变量传给会接管的地方,之后不再用它
v.;
// ✓ 成员初始化
:
// ✓ 右值引用参数往下传(因为 s 本身是左值!)
void
// ✗ 返回局部变量 —— 会阻止 NRVO,把零成本变成一次移动
return ; // 直接写 return local;
// ✗ 转发引用上 —— 会把调用者的左值也偷走
void // 应该用 std::forward<T>(x)
// ✗ 之后还要用这个对象
;
; // cfg 已被掏空
一句话回答
是的,
std::move(s)这个表达式无条件地是将亡值 —— 因为std::move就是一个编译期的static_cast,转换总会发生。但变量s本身没有任何变化:它在其他表达式里仍然是左值,编译器也不会记录"它被 move 过"。s会不会真的被掏空,取决于这个 xvalue 有没有被某个移动构造/赋值接手 —— 结果被丢弃、对象是const、类型没有移动构造,这三种情况下std::move都等于白写。
暂无评论,欢迎留下第一条评论。