结论:xvalue 本身不会让 C++ 难多少。让 C++ 变难的是移动语义这整套心智模型,xvalue 只是它在类型系统里的"记账单位"。真正被它绊住的,主要是写模板和库的人。
为什么 xvalue 非存在不可
这其实是个很简单的正交问题:
- 一个表达式是否指代已有的对象(有没有身份)
- 它的资源是否允许被搬走
这两件事本来互相独立。C++03 的 lvalue / rvalue 两分法把它们捆在同一根轴上,所以表达不了std::move(x)这种情形:
这仍然是
x本尊,地址、生命周期、别处指着它的指针都还在,但请把我的资源当临时量搬走。
于是有了第三格:
| 类别 | 有身份 | 可被搬走 | 典型例子 |
|---|---|---|---|
| lvalue(左值) | 是 | 否 | 变量名x、*p、a[i] |
| xvalue(将亡值) | 是 | 是 | std::move(x)、返回T&&的函数调用 |
| prvalue(纯右值) | 否 | 是 | 42、T{}、返回T的函数调用 |
另外两个名字只是并集:glvalue = lvalue ∪ xvalue(有身份),rvalue = xvalue ∪ prvalue(可搬走)。所以准确的说法是"3 个基本类别 + 2 个复合类别"。
「rvalue 里包含一种 glvalue」在字面上自相矛盾,这是术语在 C++11 定稿前才由 N3055 定型留下的命名债,看起来难,多半是这个原因。这套完整的体系主要服务于标准文本、编译器实现和极端的重载决议角落。
顺带澄清一个常见误解:std::move本身不移动任何东西,它只是一个把表达式转成 xvalue 的 cast,真正的移动发生在随后匹配到移动构造 / 移动赋值的时候。
日常只需要记一个口诀
具名的变量表达式是 lvalue,要用
std::move转成 xvalue,才能匹配移动重载。
voidfoo(constT&);// (1)voidfoo(T&&);// (2)voidsink(T&&x){foo(x);// x 有名字 → lvalue → 选 (1)foo(std::move(x));// 转成 xvalue → 选 (2)}注意x的类型是T&&,但x这个表达式是 lvalue。这是新手最容易卡住的地方。
这只是口诀,有例外:枚举项有名字,却是 prvalue;非类类型的非类型模板参数同理。入门够用,但别把它当成语言规则本身。
实战里真正咬人的坑
这些坑基本都来自移动语义,而不是 xvalue 这个概念:
const对象上的std::move静默退化成拷贝。const T&&绑不到T&&形参,只能匹配const T&,没有任何警告,是个性能暗坑。- moved-from 对象的状态。标准库类型保证"有效但未指定",自定义类型只是约定俗成。编译器不会检查你是否在 move 之后继续使用它,靠纪律;
clang-tidy的bugprone-use-after-move能抓一部分。 - 模板推导、引用折叠、
auto&&、decltype与 xvalue 交叉。对于不是"裸标识符"的表达式,decltype的三条规则是:prvalue 得T,lvalue 得T&,xvalue 得T&&。这和 C++ 最难的既有部分(推导 + 重载决议)叠在一起。 return std::move(x)通常是负优化。返回局部变量或按值形参时,编译器本来就会先把x当右值做重载决议(隐式移动),还有机会做拷贝省略,手写std::move反而会阻止省略。但如果返回的是成员、全局变量等不在隐式移动范围内的东西,std::move就是必要的。C++20 的 P1825 和 C++23 的 P2266 还在逐步放宽隐式移动的范围(比如返回右值引用形参)。- 生命周期。下面单独展开。
生命周期:哪些会延长,哪些不会
这是之前写得最不严谨的一点,所以专门写成可编译的代码验证。
规则先说结论:
- 生命周期延长发生在"引用直接绑定到 prvalue 物化出的临时量(或它的子对象)"时。延长挂在这个临时量上,而不是挂在 xvalue 这个类别上。(C++17 的临时量物化把 prvalue 变成 xvalue,但延长规则并不因此属于 xvalue。)
T&& r = std::move(x);绑的是已经存在的x,没有新的临时量,谈不上延长,x该死时照样死。- 只要引用的来源中间隔了一次"返回引用的函数调用",链条就断了。临时量在完整表达式结束时销毁,引用悬垂。返回
T&&和返回const T&在这点上完全一样。 - 对xvalue取成员,结果仍是 xvalue,不享受延长;对prvalue取成员,会延长整个临时量。
代码
用g++ 13.3、-std=c++17 -Wall -Wextra编译通过。每个临时量的构造 / 析构都会打印,所以能直接看出生死时刻。
// 编译: g++ -std=c++17 -Wall -Wextra -o lifetime lifetime.cpp#include<cstdio>#include<utility>structNoisy{constchar*name;intvalue=42;explicitNoisy(constchar*n):name(n){std::printf(" ctor %s\n",name);}Noisy(constNoisy&)=delete;~Noisy(){std::printf(" dtor %s\n",name);}};Noisymake(constchar*n){returnNoisy(n);}// 返回 prvalue (C++17 保证省略)Noisy&&pass(Noisy&&n){returnstd::move(n);}// 返回 xvalue,不会延长任何东西constNoisy&identity(constNoisy&n){returnn;}// 对照组:返回 lvalue 引用// A. 引用直接绑定 prvalue:物化出的临时量被延长voidcase_a(){std::puts("-- A: const Noisy& r = make(\"A\");");{constNoisy&r=make("A");std::printf(" in scope, r.value = %d\n",r.value);}std::puts(" (scope ended)");}// B. 右值引用绑定 prvalue:同样延长voidcase_b(){std::puts("-- B: Noisy&& r = make(\"B\");");{Noisy&&r=make("B");std::printf(" in scope, r.value = %d\n",r.value);}std::puts(" (scope ended)");}// C. 引用绑定 prvalue 的成员子对象:整个临时量被延长voidcase_c(){std::puts("-- C: const int& r = make(\"C\").value;");{constint&r=make("C").value;std::printf(" in scope, r = %d\n",r);}std::puts(" (scope ended)");}// D. std::move 一个已有对象:没有新临时量,谈不上延长,r 只是 x 的别名voidcase_d(){std::puts("-- D: Noisy x(\"D\"); Noisy&& r = std::move(x);");{Noisyx("D");Noisy&&r=std::move(x);std::printf(" &r == &x ? %s\n",&r==&x?"yes":"no");std::puts(" leaving scope: only x's own dtor runs");}std::puts(" (scope ended)");}// E. 经过返回 T&& 的函数:临时量在完整表达式结束时就销毁,r 悬垂voidcase_e(){std::puts("-- E: Noisy&& r = pass(make(\"E\")); // 悬垂");{[[maybe_unused]]Noisy&&r=pass(make("E"));std::puts(" next statement: temp is already gone, r dangles (do not use)");}std::puts(" (scope ended)");}// F. std::move(prvalue):物化的临时量活不过这条语句voidcase_f(){std::puts("-- F: auto&& r = std::move(make(\"F\")); // 悬垂");{[[maybe_unused]]auto&&r=std::move(make("F"));std::puts(" next statement: temp is already gone, r dangles (do not use)");}std::puts(" (scope ended)");}// G. 对 xvalue 取成员:结果还是 xvalue,不享受延长voidcase_g(){std::puts("-- G: auto&& r = std::move(make(\"G\")).value; // 悬垂");{[[maybe_unused]]auto&&r=std::move(make("G")).value;std::puts(" next statement: temp is already gone, r dangles (do not use)");}std::puts(" (scope ended)");}// H. 对照组:返回 const T& 的函数同样不延长(lvalue 一侧的经典坑)voidcase_h(){std::puts("-- H: const Noisy& r = identity(make(\"H\")); // 悬垂");{[[maybe_unused]]constNoisy&r=identity(make("H"));std::puts(" next statement: temp is already gone, r dangles (do not use)");}std::puts(" (scope ended)");}intmain(){case_a();case_b();case_c();case_d();case_e();case_f();case_g();case_h();}悬垂的 E / F / H 三个例子里,只让引用存在、绝不去读它,所以程序本身没有触发未定义行为。GCC 13 会对其中三处给出
-Wdangling-reference警告;G 的情形它反而没报,所以别指望编译器替你兜底。
运行结果
-- A: const Noisy& r = make("A"); ctor A in scope, r.value = 42 dtor A ← 作用域结束才析构:被延长 (scope ended) -- B: Noisy&& r = make("B"); ctor B in scope, r.value = 42 dtor B ← 被延长 (scope ended) -- C: const int& r = make("C").value; ctor C in scope, r = 42 dtor C ← 绑成员子对象,整个临时量被延长 (scope ended) -- D: Noisy x("D"); Noisy&& r = std::move(x); ctor D &r == &x ? yes ← 只是 x 的别名,没有新对象 leaving scope: only x's own dtor runs dtor D (scope ended) -- E: Noisy&& r = pass(make("E")); // 悬垂 ctor E dtor E ← 这条语句一结束就析构了 next statement: temp is already gone, r dangles (do not use) (scope ended) -- F: auto&& r = std::move(make("F")); // 悬垂 ctor F dtor F ← 同上 next statement: ... (scope ended) -- G: auto&& r = std::move(make("G")).value; // 悬垂 ctor G dtor G ← xvalue 的成员,不延长 next statement: ... (scope ended) -- H: const Noisy& r = identity(make("H")); // 悬垂 ctor H dtor H ← 对照组:const T& 返回值一样不延长 next statement: ... (scope ended)汇总
| 写法 | 是否延长 | 原因 |
|---|---|---|
const T& r = make(); | 是 | 直接绑定 prvalue |
T&& r = make(); | 是 | 直接绑定 prvalue |
const int& r = make().member; | 是 | 绑定 prvalue 的子对象 |
T&& r = std::move(x); | —(不需要) | 绑定已有对象,r 只是别名 |
T&& r = pass(make()); | 否:悬垂 | 隔了一次返回T&&的调用 |
auto&& r = std::move(make()); | 否: 悬垂 | std::move只是返回引用的函数 |
auto&& r = std::move(make()).member; | 否: 悬垂 | xvalue 的成员仍是 xvalue |
const T& r = identity(make()); | 否: 悬垂 | 对照:返回const T&一样断链 |
一句话记忆:std::move是个普通函数,它不会"保住"任何东西,更不会替你延长什么。把 xvalue 当成能保命的东西,才是真正的坑。
按人群看,难度增量差别很大
- 业务代码开发者:几乎碰不到 xvalue 这个词。只需要养成一个习惯:交出所有权时
move,之后别再用它。 - 模板 / 库作者:难度明显提升。完美转发、引用折叠、
decltype、生命周期并列,是 subtle bug 的高发区。 - 标准文本 / 编译器实现者:确实大幅变难,但普通开发者不用为此付费。
另外,C++17 的保证复制省略简化了 prvalue 一侧的规则(prvalue 不再先造临时量再移动,而是直接初始化目标对象),整体趋势其实是在收敛的。
小结
难度涨在所有权语义,不涨在 xvalue 这个格子。
把"具名的是 lvalue、std::move只是 cast、const上 move 是拷贝、move 之后别再用、引用不会因为你move了就多活一会儿"这几条记牢,日常基本够用。