1. 是什么
- 左值(lvalue):有名称、有持久存储的对象,可以取地址,可出现在赋值号左边。
- 右值(rvalue):临时的、无名称的值,不能取地址,只能出现在赋值号右边。
C++11 将其细化为 lvalue / xvalue / prvalue 三分类,完整体系见 7.2。
核心判定规则:能对表达式取地址吗?能 → 左值;不能 → 右值。
int a = 10, b = 20; a = 20; // a 是左值:有名字、有地址、能被赋值 &a; // 正确:能取地址 10 = a; // 错误:字面量 10 是右值 // &10; // 错误:不能取地址 a + b; // 右值:表达式临时结果 // &(a + b); // 错误:不能取地址2. 值类别完整体系(C++11 三分类)
C++11 起,表达式的值类别由传统的“左值 / 右值”二分法细化为三分类:
| 类别 | 全称 | 一句话定义 |
|---|---|---|
| lvalue | locator value(左值) | 有身份(可取地址)、可复用的对象 |
| xvalue | expiring value(亡值) | 有身份、但资源可被窃取的“将亡”对象 |
| prvalue | pure rvalue(纯右值) | 无身份的临时值(字面量、表达式计算结果) |
三分类通过两个组合概念与传统的二分法衔接:
glvalue(泛左值)= lvalue + xvalue → 判定标准:有身份(能取地址) rvalue(右值) = xvalue + prvalue → 判定标准:可被移动(资源可窃取) ┌────────── glvalue ──────────┐ │ │ lvalue xvalue ──┐ │ │ ├── rvalue └─────────────────────────────┘ │ prvalue关键结论:xvalue 同时属于 glvalue(有身份)和 rvalue(可移动)——它就是二分法中“有身份的右值”。
3. 亡值 xvalue 专项
是什么:亡值是有名字(或有地址)可追踪、但生命周期即将结束的对象。它“将亡”,所以资源可以被安全偷走;它“有身份”,所以仍能区分自己与其他对象。
设计目的:编译器需要精确回答一个问题——“这个对象之后还会被使用吗?”
- 对 lvalue:不能偷资源,之后可能还要用;
- 对 prvalue:直接整体当作结果,无需区分;
- 对 xvalue:资源可偷——这是移动语义的触发开关。
没有 xvalue 这一类别,std::move(x)后的x就无法在类型系统层面标记为“可窃取”,编译器无法安全选择移动构造而非拷贝构造。
产生亡值的三个典型场景:
#include <utility> #include <string> std::string&& make_name(); // 返回右值引用的函数(声明) int main() { std::string s = "hello"; // 场景一:std::move 的返回值——最常用的亡值来源 std::string&& r1 = std::move(s); // 场景二:static_cast<T&&> 的转换结果——std::move 内部就是这么做的 std::string&& r2 = static_cast<std::string&&>(s); // 场景三:调用返回 T&& 的函数(较少见,标准库内部使用) std::string&& r3 = make_name(); return 0; }识别口诀:亡值 = 有身份 + 是右值。
| 判定问题 | lvalue | xvalue | prvalue |
|---|---|---|---|
| 有身份(可取地址)? | ✅ | ✅ | ❌ |
| 可被移动(资源可窃)? | ❌ | ✅ | ✅ |
出现在=左边? | ✅ | ❌ | ❌ |
注意:xvalue 是右值,因此不能取地址(
&std::move(s)非法),也不能出现在赋值号左边;它只是“语义上”拥有身份——绑定它的右值引用仍指向那个真实存在的对象。
4. 特性对比
| 特性 | lvalue | xvalue | prvalue |
|---|---|---|---|
| 名称 | 有 | 无名字但有对象 | 无(临时) |
| 存储 | 持久(作用域内有效) | 即将销毁 | 临时(语句结束即销毁) |
| 取地址 | ✅ | ❌ | ❌ |
出现在=左边 | ✅ | ❌ | ❌ |
可绑定到T& | ✅ | ❌ | ❌ |
可绑定到T&& | ❌ | ✅ | ✅ |
| 资源可被窃取 | ❌ | ✅ | ✅ |
5. 表达式中的行为差异
int a = 10, b = 20; a = b; // b 是左值,但在此处作为右值使用(读取它的值) // 左值在“需要值”的上下文会隐式转换为右值 a + b = 30; // 错误:a+b 的结果是右值,不能被赋值 ++a; // ++a 返回左值:直接修改 a 并返回 a 本身 a++; // a++ 返回右值:返回 a 的旧值副本(临时对象)为什么++a是左值而a++是右值?
++a(前置自增):先自增,返回 a 自身的引用 → 有身份 → 左值。a++(后置自增):先保存旧值副本,自增 a,返回那个临时副本 → 右值。
6. 判定规则汇总
| 表达式 | 值类别 | 原因 |
|---|---|---|
变量名a | 左值 | 有名字、可取地址 |
字面量10、'c'、3.14 | 纯右值 | 无地址(字符串字面量除外) |
算术表达式a + b | 纯右值 | 临时结果 |
前置++a、*p | 左值 | 返回对象本身 |
后置a++ | 纯右值 | 返回旧值副本 |
函数返回T& | 左值 | 返回对象本身 |
函数返回T(按值) | 纯右值 | 临时对象 |
std::move(x) | 亡值 | 转换为右值引用,标记资源可窃取 |
static_cast<T&&>(x) | 亡值 | 同上,std::move 的本质 |
调用返回T&&的函数 | 亡值 | 返回“将亡”对象的引用 |
7. 左值引用与右值引用(C++11)
int a = 10; int& lr = a; // 左值引用:只能绑定左值 // int& lr2 = 10; // 错误:不能绑定右值 int&& rr = 10; // 右值引用(C++11):只能绑定右值 // int&& rr2 = a; // 错误:不能绑定左值 const int& clr = 10; // const 左值引用:万能绑定(左值、右值都行)| 引用类型 | 绑定左值 | 绑定右值 |
|---|---|---|
T& | ✅ | ❌ |
T&& | ❌ | ✅ |
const T& | ✅ | ✅ |
8. 移动语义与 std::move 原理
C++11 引入右值引用的核心目的是实现移动语义(move semantics):右值是临时的、即将销毁的,其资源可以被“偷走”而不必深拷贝,从而大幅提升性能。
std::string s1 = "hello"; std::string s2 = s1; // 拷贝构造:深拷贝字符串内容 std::string s3 = s1 + s2; // 移动构造:s1+s2 是右值(临时),直接偷走资源没有左右值区分,编译器就无法判断“这个对象之后还会不会被使用”,也就无法安全地做移动优化。
std::move 的本质:一个模板函数,只做类型转换(把左值标记为亡值),不移动任何数据。简化后的源码:
// std::move 的简化实现(位于 <utility>,摘除引用折叠细节) template<typename T> constexpr std::remove_reference_t<T>&& move(T&& t) noexcept { // 关键就这一行:无条件转换为右值引用类型 return static_cast<std::remove_reference_t<T>&&>(t); }以std::move(s)(s 是std::string左值)为例的展开过程:
第一步:模板推导 T = std::string&(左值传入时 T 推导为左值引用) 第二步:去引用 remove_reference_t<T> = std::string 第三步:类型转换 static_cast<std::string&&>(s) → 表达式类别变为亡值 xvalue 第四步:返回 返回 std::string&& 类型的亡值转换前后发生了什么:
| 阶段 | s 的值类别 | 编译器视角 |
|---|---|---|
| 转换前 | 左值(lvalue) | “s 之后还要用,不能偷它的资源” |
| 转换后 | 亡值(xvalue) | “调用者承诺不再使用 s 的资源,可以偷” |
std::string s = "hello"; std::string t = std::move(s); // 触发移动构造:偷走 s 的内部缓冲区 // 此时 s 处于“有效但未定义”状态(通常为空),不要再读它的值设计要点:
noexcept修饰:移动操作必须保证不抛异常,std::vector扩容时才会放心用移动而非拷贝。constexpr:允许在编译期常量表达式中使用。remove_reference_t:保证无论传入左值还是右值,返回的都是“去掉引用后再加上&&”的干净右值引用类型。
9. 注意事项
- 有名字的右值引用变量本身是左值:
int&& rr = 10;中rr有名字、可取地址,是左值;要再次触发移动需std::move(rr)。 std::move只做类型转换(左值 → 亡值),本身不移动任何东西。- 被 move 后的对象仍可安全析构:移动构造/移动赋值必须保证源对象处于“有效但未定义”状态(通常为空),不要读取其值,但可以给它赋新值。
- 亡值不能取地址:
&std::move(x)编译错误——亡值是右值,“有身份”指的是其引用目标真实存在,不代表表达式本身可取地址。 - 区分值类别的本质是判断:这个对象之后还会不会被使用。不会 → 可以安全移动(标记为亡值)。
10. 常考面试题
以下题目围绕左值、右值、引用以及移动语义展开,是 C++ 面试中的高频考点。
10.1 如何一句话区分左值和右值?
核心判断:能否对表达式取地址。能取地址、有名字的是左值;不能取地址、临时的是右值。C++11 后右值进一步细分为 xvalue 和 prvalue。
10.2 为什么需要引入右值引用?
为了实现移动语义。右值通常是临时对象,资源即将销毁,可以安全地把内部资源转移给新对象,避免深拷贝,从而提升性能。
10.3 std::move 真的移动数据吗?
不会。std::move只做类型转换,把参数无条件转换为右值引用,使表达式变成亡值,从而让编译器选择移动构造或移动赋值。真正的资源转移发生在移动构造函数或移动赋值运算符中。
// std::move 的核心等价操作 template<typename T> std::remove_reference_t<T>&& move(T&& t) noexcept { return static_cast<std::remove_reference_t<T>&&>(t); }10.4 被 std::move 后的对象还能使用吗?
对象处于“有效但未定义”状态,可以安全析构,也可以重新赋值,但不应继续读取其原有值。例如std::string被移动后通常为空。
10.5 为什么说有名字的右值引用变量本身是左值?
int&& rr = 10;中rr有名字、可以取地址,因此rr本身是左值。若想再次触发移动语义,需要写成std::move(rr)。
10.6 为什么前置 ++ 返回左值,后置 ++ 返回右值?
前置++a直接修改对象并返回自身引用,所以是左值;后置a++先保存旧值副本,再返回这个临时副本,所以是右值。
10.7 移动构造函数有哪些关键点?
- 通常声明为
noexcept,保证标准容器扩容时优先使用移动。 - 转移堆资源、指针等内部资源后,把源对象置回“有效但未定义”状态,如将指针置空。
- 移动构造不会分配新资源,复杂度通常为 O(1)。