前言C03 只区分左值lvalue和右值rvalue规则相对简单。C11 引入移动语义后这套二分法不够用了既需要描述有身份、可取地址的对象也需要描述即将被销毁、资源可以被搬走的对象还需要描述纯粹的计算结果。于是标准把表达式重新划分为左值lvalue、将亡值xvalueeXpiring value和纯右值prvaluepure rvalue三个基本类别并在此之上定义泛左值glvalue与右值rvalue两个上位类别。术语容易让人望文生义。左值不是出现在赋值号左边的值将亡值也不是马上要析构的变量它们描述的是表达式的类别不是变量本身的属性。同一个变量在不同表达式里可能表现为左值一个类型为右值引用的具名变量它的名字作为表达式仍然是左值。这类反直觉之处正是移动语义代码出错的常见根源。本文先建立五个类别的层次关系再给出判断表达式类别的实用方法然后讲解引用绑定、重载决议最后分析std::move、对象生命周期以及 C17 对纯右值的重新定义。文中代码以 C17 为基准涉及 C11 与 C17 差异的地方会单独说明。一、五个类别与两层划分C11 的值类别可以画成两组交集泛左值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的调用需要特别注意移动这一列它描述的是语言允许的语义不代表编译器一定会执行移动构造。一个纯右值用于初始化同类型对象时C17 起可以直接构造目标对象连移动构造都不调用。C17 还修改了纯右值的定义。在此之前纯右值常被理解为临时对象C17 起纯右值本身是用来初始化对象的表达式只有在需要时才会通过临时量实质化temporary materialization产生一个临时对象。这个变化是强制复制消除guaranteed copy elision的语言基础后文会展开。二、怎么判断一个表达式的类别判断值类别时先问两个问题这个表达式有没有指称一个具体的对象或函数它是否允许资源被移走下面这些规则覆盖了日常代码的绝大多数情况。左值的常见来源变量名、函数名、数据成员名作为表达式使用时。解引用表达式*p无论p本身是什么类别。字符串字面量它的类型是字符数组属于左值。内置类型的前置自增a、前置自减--a重载运算符的返回类型由声明决定。内置类型的赋值表达式a b结果是左值。返回左值引用的函数调用。内置下标表达式a[i]其结果总是左值。将亡值的常见来源std::move(x)的结果其类型是T。static_castT(x)。返回右值引用的函数调用。对将亡值做成员访问例如std::move(obj).member。纯右值的常见来源除字符串字面量外的字面量如42、true、nullptr。算术、逻辑、比较运算的结果如a b、a b。取地址表达式a得到的是指针值。返回非引用类型的函数调用。lambda 表达式。this指针本身。一个必须记住的反例具名的右值引用变量是左值。// 适用于 C17#include iostream#include utilityvoid 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的移动构造函数。// 适用于 C17#include stringint 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、生命周期与 C17 的变化std::move的实现本质上是一次类型转换返回T。它不移动任何东西也不修改实参真正搬运资源的是随后被选中的移动构造函数或移动赋值运算符。标准库的移动操作通常把源对象置于有效但未指定valid but unspecified状态可以销毁、可以重新赋值但不应该读取它的值。// 适用于 C17#include string#include utility#include vectorint main() {std::string source a fairly long string;std::string target std::move(source); // 移动构造source reused; // 重新赋值是允许的// 此处不要依赖 source 在赋值前的具体内容std::vectorint v{1, 2, 3};std::vectorint w std::move(v); // 转移缓冲区}返回引用的函数要特别小心生命周期。返回局部变量的左值引用或右值引用都是悬垂引用返回std::move(局部变量)同样不能延长任何东西因为函数返回的是引用临时对象的生命周期不会跨越函数边界。C17 的强制复制消除改变了纯右值的使用方式。下面这个类型删除了拷贝构造和移动构造在 C17 中仍然可以从函数返回// 需要 C17struct NoCopy {NoCopy() default;NoCopy(const NoCopy) delete;NoCopy operator(const NoCopy) delete;};NoCopy make() {return NoCopy{}; // C17 保证直接用纯右值初始化返回对象}int main() {NoCopy obj make(); // 不调用拷贝构造也不调用移动构造}在 C14 及以前这段代码需要可访问的拷贝或移动构造函数即使编译器可能优化掉调用。C17 把纯右值定义为初始化对象的表达式NoCopy{}直接初始化目标对象因此不再需要拷贝/移动。这是标准语义的变化不是优化开关。另外C11 起成员函数可以用引用限定符区分调用对象的值类别// 适用于 C17#include utility#include vectorstruct Buffer {std::vectorint data;std::vectorint get() {return data; // 左值对象返回引用}std::vectorint 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 左值引用前置自增返回左值。❌ 在 C14 中依赖返回不可拷贝对象的写法✅ 强制复制消除是 C17 语义C14 仍要求可访问的拷贝或移动构造函数不能把 C17 行为套用到旧标准。总结类别上位类别有身份可否移动典型来源左值泛左值是否变量名、*p、a将亡值泛左值、右值是是std::move(x)、返回T纯右值右值否是字面量、a b、返回T值类别的核心不是记忆名词而是两条判断线索有没有身份决定它属于泛左值还是纯右值能不能被移动决定它属于左值还是右值。std::move只是把左值转换成将亡值真正的资源转移由移动构造完成具名右值引用仍是左值const对象无法被移动。把这几条与引用绑定规则连起来移动语义相关的大部分错误都能在写代码时识别出来。
阅读完成 · 觉得有帮助?