前言
C++03 只区分左值(lvalue)和右值(rvalue),规则相对简单。C++11 引入移动语义后,这套二分法不够用了:既需要描述"有身份、可取地址的对象",也需要描述"即将被销毁、资源可以被搬走的对象",还需要描述"纯粹的计算结果"。于是标准把表达式重新划分为左值(lvalue)、将亡值(xvalue,eXpiring value)和纯右值(prvalue,pure rvalue)三个基本类别,并在此之上定义泛左值(glvalue)与右值(rvalue)两个上位类别。
术语容易让人望文生义。"左值"不是"出现在赋值号左边的值","将亡值"也不是"马上要析构的变量";它们描述的是表达式的类别,不是变量本身的属性。同一个变量,在不同表达式里可能表现为左值;一个类型为右值引用的具名变量,它的名字作为表达式仍然是左值。这类反直觉之处,正是移动语义代码出错的常见根源。
本文先建立五个类别的层次关系,再给出判断表达式类别的实用方法,然后讲解引用绑定、重载决议,最后分析std::move、对象生命周期以及 C++17 对纯右值的重新定义。文中代码以 C++17 为基准,涉及 C++11 与 C++17 差异的地方会单独说明。
一、五个类别与两层划分
C++11 的值类别可以画成两组交集:
- 泛左值(glvalue)= 左值 + 将亡值。共同点是"有身份"(has identity),即可以判断两个表达式是否指称同一个实体。
- 右值(rvalue)= 将亡值 + 纯右值。共同点是"可以被移动"(can be moved from),即允许把资源转移走。
- 左值(lvalue):有身份,且不能被移动。典型例子是变量名、解引用结果、返回左值引用的函数调用。
- 将亡值(xvalue):有身份,且可以被移动。典型例子是
std::move(x)的结果、返回右值引用的函数调用、对将亡值做成员访问。 - 纯右值(prvalue):没有身份,用于初始化对象或计算操作数。典型例子是字面量、算术运算结果、返回非引用类型的函数调用。
| 类别 | 有身份 | 可移动 | 典型表达式 |
|---|
| 左值 lvalue | 是 | 否 | 变量名、*p、返回T&的调用 |
| 将亡值 xvalue | 是 | 是 | std::move(x)、返回T&&的调用 |
| 纯右值 prvalue | 否 | 是 | 42、a + b、返回T的调用 |
需要特别注意"移动"这一列:它描述的是语言允许的语义,不代表编译器一定会执行移动构造。一个纯右值用于初始化同类型对象时,C++17 起可以直接构造目标对象,连移动构造都不调用。
C++17 还修改了纯右值的定义。在此之前,纯右值常被理解为"临时对象";C++17 起,纯右值本身是用来初始化对象的表达式,只有在需要时才会通过临时量实质化(temporary materialization)产生一个临时对象。这个变化是"强制复制消除"(guaranteed copy elision)的语言基础,后文会展开。
二、怎么判断一个表达式的类别
判断值类别时,先问两个问题:这个表达式有没有指称一个具体的对象或函数?它是否允许资源被移走?下面这些规则覆盖了日常代码的绝大多数情况。
左值的常见来源:
- 变量名、函数名、数据成员名(作为表达式使用时)。
- 解引用表达式
*p,无论p本身是什么类别。 - 字符串字面量,它的类型是字符数组,属于左值。
- 内置类型的前置自增
++a、前置自减--a(重载运算符的返回类型由声明决定)。 - 内置类型的赋值表达式
a = b,结果是左值。 - 返回左值引用的函数调用。
- 内置下标表达式
a[i],其结果总是左值。
将亡值的常见来源:
std::move(x)的结果,其类型是T&&。static_cast<T&&>(x)。- 返回右值引用的函数调用。
- 对将亡值做成员访问,例如
std::move(obj).member。
纯右值的常见来源:
- 除字符串字面量外的字面量,如
42、true、nullptr。 - 算术、逻辑、比较运算的结果,如
a + b、a < b。 - 取地址表达式
&a,得到的是指针值。 - 返回非引用类型的函数调用。
- lambda 表达式。
this指针本身。
一个必须记住的反例:具名的右值引用变量是左值。
// 适用于 C++17
#include <iostream>
#include <utility>
void pick(int&) { std::cout << "lvalue\n"; }
void pick(int&&) { std::cout << "rvalue\n"; }
int main() {
int x = 0;
pick(x); // 左值
pick(1); // 纯右值
pick(std::move(x)); // 将亡值
int&& rr = 0;
pick(rr); // 左值!rr 的名字是左值表达式
pick(std::move(rr)); // 将亡值
}rr的类型是int&&,但rr这个表达式是左值。类型和值类别是两个维度:类型回答"它是什么",值类别回答"这个表达式能怎么用"。移动构造函数只看实参的值类别,所以直接把rr传进去不会触发移动,必须写std::move(rr)。
三、引用绑定与重载决议
引用绑定规则直接由值类别决定:
- 非 const 左值引用
T&只能绑定左值。 - const 左值引用
const T&可以绑定左值、将亡值和纯右值,绑定纯右值时延长临时对象生命周期。 - 右值引用
T&&只能绑定右值(纯右值和将亡值),不能绑定左值。 - const 右值引用
const T&&可以绑定右值,但很少使用,因为它无法调用接受T&&的移动构造函数。
// 适用于 C++17
#include <string>
int main() {
int a = 1;
int& r1 = a; // 正确:左值引用绑定左值
const int& r2 = 42; // 正确:const 左值引用绑定纯右值
int&& r3 = 42; // 正确:右值引用绑定纯右值
int&& r4 = std::move(a); // 正确:右值引用绑定将亡值
// int&& bad1 = a; // 错误:右值引用不能绑定左值
// int& bad2 = 42; // 错误:非 const 左值引用不能绑定纯右值
const std::string& s = std::string("temp"); // 生命周期延长到 s 作用域结束
}重载决议在此基础上选择函数。当同时存在f(T&)和f(T&&)时,左值实参只能匹配前者,右值实参优先匹配后者。这也是移动构造函数与拷贝构造函数共存的机制:std::vector的push_back(const T&)和push_back(T&&)分别接收左值和右值。
const T&&的陷阱值得单独说明。如果对一个const对象调用std::move,得到的是const T&&。移动构造函数通常签名是T(T&&),无法接受const T&&,于是重载决议退回到拷贝构造函数。代码看起来用了std::move,实际仍然拷贝。
四、std::move、生命周期与 C++17 的变化
std::move的实现本质上是一次类型转换,返回T&&。它不移动任何东西,也不修改实参;真正搬运资源的是随后被选中的移动构造函数或移动赋值运算符。标准库的移动操作通常把源对象置于"有效但未指定"(valid but unspecified)状态:可以销毁、可以重新赋值,但不应该读取它的值。
// 适用于 C++17
#include <string>
#include <utility>
#include <vector>
int main() {
std::string source = "a fairly long string";
std::string target = std::move(source); // 移动构造
source = "reused"; // 重新赋值是允许的
// 此处不要依赖 source 在赋值前的具体内容
std::vector<int> v{1, 2, 3};
std::vector<int> w = std::move(v); // 转移缓冲区
}返回引用的函数要特别小心生命周期。返回局部变量的左值引用或右值引用都是悬垂引用;返回std::move(局部变量)同样不能延长任何东西,因为函数返回的是引用,临时对象的生命周期不会跨越函数边界。
C++17 的强制复制消除改变了纯右值的使用方式。下面这个类型删除了拷贝构造和移动构造,在 C++17 中仍然可以从函数返回:
// 需要 C++17
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
};
NoCopy make() {
return NoCopy{}; // C++17 保证直接用纯右值初始化返回对象
}
int main() {
NoCopy obj = make(); // 不调用拷贝构造,也不调用移动构造
}在 C++14 及以前,这段代码需要可访问的拷贝或移动构造函数(即使编译器可能优化掉调用)。C++17 把纯右值定义为"初始化对象的表达式",NoCopy{}直接初始化目标对象,因此不再需要拷贝/移动。这是标准语义的变化,不是优化开关。
另外,C++11 起成员函数可以用引用限定符区分调用对象的值类别:
// 适用于 C++17
#include <utility>
#include <vector>
struct Buffer {
std::vector<int> data;
std::vector<int>& get() & {
return data; // 左值对象:返回引用
}
std::vector<int> get() && {
return std::move(data); // 右值对象:搬走内容
}
};对左值Buffer调用get()得到引用;对临时Buffer调用get()得到值。引用限定符让同一成员函数根据对象的值类别提供不同语义,标准库中的std::optional::value等接口就使用了这种设计。
常见坑点
- ❌ 认为
std::move(x)会移动x,之后继续读取x的值
✅std::move只做类型转换,返回T&&;是否移动取决于重载决议,被移动对象处于有效但未指定状态。
- ❌ 把具名的右值引用变量当作右值使用
✅ 具名变量的表达式是左值;要把它再作为右值传递,需要写std::move(rr)。
- ❌ 对
const对象调用std::move并期望触发移动构造
✅ 得到const T&&,无法绑定到T&&参数,通常退回拷贝;移动语义要求对象可被修改。
- ❌ 从函数返回局部变量的
T&&或T&
✅ 返回的是悬垂引用;按值返回,或返回生命周期由调用方保证的对象。
- ❌ 认为纯右值一定有地址,写
&(a + b)
✅ 纯右值没有身份,不能取地址;需要地址时先存进变量。
- ❌ 用
auto&&接收所有实参后,以为它总是右值引用
✅ 推导语境下它是转发引用,左值实参折叠为T&;只有显式写具体类型才是普通右值引用。
- ❌ 认为
a++的结果可以继续自增,写(a++)++
✅ 后置自增返回纯右值,不能绑定非 const 左值引用;前置自增返回左值。
- ❌ 在 C++14 中依赖"返回不可拷贝对象"的写法
✅ 强制复制消除是 C++17 语义;C++14 仍要求可访问的拷贝或移动构造函数,不能把 C++17 行为套用到旧标准。
总结
| 类别 | 上位类别 | 有身份 | 可否移动 | 典型来源 |
|---|
| 左值 | 泛左值 | 是 | 否 | 变量名、*p、++a |
| 将亡值 | 泛左值、右值 | 是 | 是 | std::move(x)、返回T&& |
| 纯右值 | 右值 | 否 | 是 | 字面量、a + b、返回T |
值类别的核心不是记忆名词,而是两条判断线索:有没有身份决定它属于泛左值还是纯右值,能不能被移动决定它属于左值还是右值。std::move只是把左值转换成将亡值,真正的资源转移由移动构造完成;具名右值引用仍是左值,const对象无法被移动。把这几条与引用绑定规则连起来,移动语义相关的大部分错误都能在写代码时识别出来。