你有没有写过 std::move(x) 之后发现它还能被读取,或者重载了 f(T&) 和 f(T&&) 却搞不清到底调了哪个?这些怪现象的根都在「值类别(value category)」上。C++11 起给每个表达式贴了一张隐形标签,它直接决定一个表达式能不能取地址、能不能被移动、以及重载决议时该选哪个版本。这篇把五个概念一次讲清,并用真跑出来的输出告诉你每种实参选中了哪个函数。
同一个名字,两种命运
先看一个会让人困惑的现象:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <string>
#include <utility>
void who(const std::string&) { std::cout << "走了 const T&\n"; }
void who(std::string&) { std::cout << "走了 T&\n"; }
void who(std::string&&) { std::cout << "走了 T&&\n"; }
int main() {
std::string s{"喵"};
who(s); // 传变量
who(std::move(s)); // 传「被移动」的 s
return 0;
}
text
走了 T&
走了 T&&
同一个 s,直接传和用 std::move 包一下,调的就不是同一个函数。差别不在变量本身,而在「这个实参表达式的值类别」变了。下面拆开这张标签。
五个概念与一张分类图
C++11 之后,表达式的值类别(value category)只有三种基本类型,再归成两个集合:
- 左值(lvalue) :有名字、可取地址的对象。比如变量名
s、解引用*p、++i的返回值。 - 纯右值(prvalue,pure rvalue) :纯粹的「值」,没有身份、不能被取地址。比如字面量
42、临时对象std::string{"t"}、表达式a + b的结果。 - 将亡值(xvalue,eXpiring value) :本来是个左值,但明确「我马上要被搬空了」,比如
std::move(x)的结果、static_cast<T&&>(x)的结果。它既是泛左值(有身份)又是右值(可移动),两个集合里都有它。
两个集合是这么划的:
- 泛左值 glvalue = lvalue + xvalue(有身份、可被寻址语义上存在的对象)
- 右值 rvalue = prvalue + xvalue(可以被移动走)
text
C++ 表达式 (expression)
┌───────────────────┴───────────────────┐
glvalue rvalue
(泛左值:有身份) (右值:可被移动)
┌────────────┐ ┌────────────┐
lvalue xvalue xvalue prvalue
(左值) (将亡值) (将亡值) (纯右值)
变量名/ std::move(x) 将亡值 42 / 临时对象
*p / ++i static_cast<T&&>(x) 同时属于 a + b
└──── xvalue 同时落在两边 ────┘ 字面量
怎么判断一个表达式是左值还是右值
记住两条够用的经验法则:
- 有名字的变量本身是左值,即使它的类型是右值引用
T&&(见下一篇)。 - 能不能取地址:能取地址的通常是左值;取不了地址的(
42、std::move(x)、临时对象)是右值。
下面这张表把三者对上号:
| 特征 | 左值 lvalue | 纯右值 prvalue | 将亡值 xvalue |
|---|---|---|---|
可取地址 & |
能 | 不能(是右值) | 不能(是右值) |
| 有身份(identity) | 有 | 无 | 有(但仍属右值) |
| 典型来源 | 具名变量、*p、前置 ++ |
字面量、临时对象、a+b |
std::move(x)、static_cast<T&&>(x) |
注意 xvalue 是「有身份但不可取地址」的特殊存在:它身份上指向某个具体对象,但作为右值表达式,&std::move(x) 是编译不过的。
值类别如何决定重载决议(核心)
这是理解移动语义最关键的一步。f(T&) / f(const T&) / f(T&&) 三个重载谁被选中,完全由实参的值类别决定。让每个重载打自己的标签,传四种不同值类别的实参,看真跑的是谁:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <string>
#include <utility>
void classify(const std::string&) { std::cout << "[const T&] 只读/转发引用\n"; }
void classify(std::string&) { std::cout << "[T&] 左值引用\n"; }
void classify(std::string&&) { std::cout << "[T&&] 右值引用\n"; }
int main() {
std::string name{"喵喵"};
const std::string cname{"只读"};
classify(name); // 左值 -> T&
classify(cname); // const 左值 -> const T&
classify(std::move(name)); // 将亡值(xvalue) -> T&&
classify(std::string{"临时"}); // 纯右值(prvalue) -> T&&
return 0;
}
text
[T&] 左值引用
[const T&] 只读/转发引用
[T&&] 右值引用
[T&&] 右值引用
规律很清楚:
- 左值(有名字的普通变量)只能绑
T&或const T&,优先选非 const 的T&。 - const 左值绑不上
T&,落到const T&。 - 右值(prvalue 或 xvalue)优先绑
T&&,只有在没有T&&重载时才会退化去绑const T&。
这就是为什么 std::move(x) 之后调的是 T&& 版本------std::move 把左值「抬」成了 xvalue,于是移动语义的重载被选中。值类别,就是重载决议的裁判。
值类别决定「拷贝」还是「移动」
光看选中哪个重载还不够,得看它的实际后果。往 std::vector 里塞对象时,左值走拷贝构造、右值走移动构造。给 Blob 加上构造/拷贝/移动的日志,一眼看清:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <string>
#include <utility>
#include <vector>
struct Blob {
std::string name;
explicit Blob(std::string n) : name{std::move(n)} { std::cout << "构造 " << name << '\n'; }
Blob(const Blob& o) : name{o.name} { std::cout << "拷贝 " << name << '\n'; }
Blob(Blob&& o) noexcept : name{std::move(o.name)} { std::cout << "移动 " << name << '\n'; }
};
int main() {
std::vector<Blob> v;
v.reserve(4);
Blob a{"左值"};
v.push_back(a); // 左值 -> 拷贝构造
v.push_back(std::move(a)); // 将亡值 -> 移动构造
v.push_back(Blob{"临时"}); // 纯右值 -> 移动构造
return 0;
}
text
构造 左值
拷贝 左值
移动 左值
构造 临时
移动 临时
对照着看:a 作为左值 push_back 触发「拷贝」,对象被复制一份;std::move(a) 把它变成将亡值后触发「移动」,资源被搬走而不是复制;Blob{"临时"} 是纯右值临时对象,同样走「移动」。值类别不同,编译器生成的代码就不同,这正是移动语义能提速的根本原因。看这类日志别只数行数,真正要看的是「拷贝」和「移动」这两个词。
易错点:那些「看起来是右值」其实是左值
值类别最容易翻车的地方是「凭直觉猜」。把常见表达式的真实值类别列成一张表,比反复背定义管用:
| 表达式 | 值类别 | 说明 |
|---|---|---|
x(x 的类型是 T&&) |
左值 | 有名字就是左值,类型是右值引用也改变不了这点 |
std::move(x) |
将亡值 | 只是 static_cast<T&&>(x),本身不搬移任何东西 |
"abc" |
左值 | 字符串字面量是 const char[4] 数组,数组是左值 |
42 / 3.14 |
纯右值 | 字面量没有身份 |
++i |
左值 | 返回自增后的对象本身 |
i++ |
纯右值 | 返回自增前的旧值副本 |
*p |
左值 | 解引用拿到的是所指的那个对象 |
&x |
纯右值 | 取地址得到的是一个临时地址值 |
a + b |
纯右值 | 运算产生的是临时对象 |
f(),f 返回 T |
纯右值 | C++17 起返回值是 prvalue(配合强制复制消除) |
f(),f 返回 T& |
左值 | 返回引用时保持被引用对象的值类别 |
x.m,m 是普通成员 |
与 x 同类别 |
std::move(x).m 是将亡值 |
前两条合起来解释了一个经典困惑:为什么 std::move(x) 之后 x 还能被读取?很多人以为 move 会当场把东西搬走,其实它连一个对象字段都没碰。它只做了一次类型转换,x 那个对象一个字节都没动;真正「搬空」它的是接收方 ------实参被 T&& 重载绑定后,接收方内部才动手把资源偷走。记住这个分工,std::move 就不再有神秘感了。
还有个更隐蔽的坑:形参的类型是右值引用时,它在函数体里是左值。看下面这个程序:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <string>
#include <utility>
void sink(std::string&) { std::cout << "sink(T&) 走拷贝路径\n"; }
void sink(std::string&&) { std::cout << "sink(T&&) 走移动路径\n"; }
void relay(std::string&& s) { // 形参 s 的类型是右值引用
sink(s); // 但 s 有名字 -> 它本身是左值 -> 拷贝路径
sink(std::move(s)); // 显式转回将亡值 -> 才走移动路径
}
int main() {
relay(std::string{"临时对象"});
}
text
sink(T&) 走拷贝路径
sink(T&&) 走移动路径
s 明明是以右值引用接进来的,sink(s) 却仍然选了拷贝版本。因为 s 在 relay 的函数体里是个有名字的变量,值类别是左值。这条规则不是缺陷,而是刻意的保护:如果「名字是右值引用的变量」自动算右值,那么第二次使用 s 时它可能已经被搬空了,编译器无从判断,程序会静默出错。想把「可移动」这个信息继续往下传,必须显式写 std::move(s) 或 std::forward,这正是转发(forwarding)话题的起点。
与移动语义的衔接:下一步是 std::forward
把这篇的结论串起来,移动语义的整条链路就通了:
- 值类别是裁判:它决定
f(T&)/f(const T&)/f(T&&)三者谁被选中。 std::move(x)是把左值抬成将亡值的类型转换,本身不移动任何东西。- 真正的「搬移」发生在被选中的那个函数体内------比如
std::vector的移动构造去偷那块缓冲。
于是「什么时候该写 std::move」有了明确答案:当你确定这个对象之后不再需要、且想交给一个会搬走它的接口时 。反过来,对一个马上还要继续读的变量写 std::move,等于把资源提前送人,后续读取落在「有效但未指定」的状态上,属于自伤。
泛型代码里 std::move 就不够用了:模板参数 T 可能被推导成左值引用,也可能是普通类型,需要的是「原样保留调用者传进来的值类别」。那是 std::forward<T>(x) 的职责,它配合转发引用(forwarding reference)和引用折叠(reference collapsing)使用。这一块留到《左值引用右值引用与万能引用》展开,这里只需记住结论:std::move 是无条件转成右值(将亡值),std::forward 是有条件地按原值类别转发;把「有名字的变量是左值」这一条钉死,两者都不会用错。
延伸阅读
- Value categories (cppreference):最权威的分类定义与边界情形。
- std::move (cppreference):看清
move只是「无条件转成右值引用」,不做任何搬移动作。 - std::forward (cppreference):按原值类别转发的实现与边界。
- C++ Core Guidelines · F 章(参数与重载):值类别在接口设计里的实战约束。
收个尾
值类别就是 C++ 给每个表达式贴的隐形标签:左值有名字、可取地址,右值可被移动,将亡值是「马上要被搬空」的那个特殊右值。重载决议选哪个版本、走拷贝还是走移动,全看这张标签。
先把「有名字的变量就是左值」这一条钉死,剩下几个概念基本不会用错。下一篇换引用的视角,把值类别和 T& / T&& 对上。