你有没有写过std::move(x)之后发现它还能被读取或者重载了f(T)和f(T)却搞不清到底调了哪个这些怪现象的根都在「值类别value category」上。C11 起给每个表达式贴了一张隐形标签它直接决定一个表达式能不能取地址、能不能被移动、以及重载决议时该选哪个版本。这篇把五个概念一次讲清并用真跑出来的输出告诉你每种实参选中了哪个函数。官方文档Value categories (cppreference)同一个名字两种命运先看一个会让人困惑的现象// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestring#includeutilityvoidwho(conststd::string){std::cout走了 const T\n;}voidwho(std::string){std::cout走了 T\n;}voidwho(std::string){std::cout走了 T\n;}intmain(){std::string s{喵};who(s);// 传变量who(std::move(s));// 传「被移动」的 sreturn0;}走了 T 走了 T同一个s直接传和用std::move包一下调的就不是同一个函数。差别不在变量本身而在「这个实参表达式的值类别」变了。下面拆开这张标签。五个概念与一张分类图C11 之后表达式的值类别value category只有三种基本类型再归成两个集合左值lvalue有名字、可取地址的对象。比如变量名s、解引用*p、i的返回值。纯右值prvaluepure rvalue纯粹的「值」没有身份、不能被取地址。比如字面量42、临时对象std::string{t}、表达式a b的结果。将亡值xvalueeXpiring value本来是个左值但明确「我马上要被搬空了」比如std::move(x)的结果、static_castT(x)的结果。它既是泛左值有身份又是右值可移动两个集合里都有它。两个集合是这么划的泛左值 glvalue lvalue xvalue有身份、可被寻址语义上存在的对象右值 rvalue prvalue xvalue可以被移动走C 表达式 (expression) ┌───────────────────┴───────────────────┐ glvalue rvalue (泛左值有身份) (右值可被移动) ┌────────────┐ ┌────────────┐ lvalue xvalue xvalue prvalue (左值) (将亡值) (将亡值) (纯右值) 变量名/ std::move(x) 将亡值 42 / 临时对象 *p / i static_castT(x) 同时属于 a b └──── xvalue 同时落在两边 ────┘ 字面量官方文档reference collapsing 与值类别的关系 (Core Guidelines)怎么判断一个表达式是左值还是右值记住两条够用的经验法则有名字的变量本身是左值即使它的类型是右值引用T见下一篇。能不能取地址能取地址的通常是左值取不了地址的42、std::move(x)、临时对象是右值。下面这张表把三者对上号特征左值 lvalue纯右值 prvalue将亡值 xvalue可取地址能不能是右值不能是右值有身份identity有无有但仍属右值典型来源具名变量、*p、前置字面量、临时对象、abstd::move(x)、static_castT(x)注意 xvalue 是「有身份但不可取地址」的特殊存在它身份上指向某个具体对象但作为右值表达式std::move(x)是编译不过的。值类别如何决定重载决议核心这是理解移动语义最关键的一步。f(T)/f(const T)/f(T)三个重载谁被选中完全由实参的值类别决定。让每个重载打自己的标签传四种不同值类别的实参看真跑的是谁// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestring#includeutilityvoidclassify(conststd::string){std::cout[const T] 只读/转发引用\n;}voidclassify(std::string){std::cout[T] 左值引用\n;}voidclassify(std::string){std::cout[T] 右值引用\n;}intmain(){std::string name{喵喵};conststd::string cname{只读};classify(name);// 左值 - Tclassify(cname);// const 左值 - const Tclassify(std::move(name));// 将亡值(xvalue) - Tclassify(std::string{临时});// 纯右值(prvalue) - Treturn0;}[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加上构造/拷贝/移动的日志一眼看清// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestring#includeutility#includevectorstructBlob{std::string name;explicitBlob(std::string n):name{std::move(n)}{std::cout构造 name\n;}Blob(constBlobo):name{o.name}{std::cout拷贝 name\n;}Blob(Blobo)noexcept:name{std::move(o.name)}{std::cout移动 name\n;}};intmain(){std::vectorBlobv;v.reserve(4);Blob a{左值};v.push_back(a);// 左值 - 拷贝构造v.push_back(std::move(a));// 将亡值 - 移动构造v.push_back(Blob{临时});// 纯右值 - 移动构造return0;}构造 左值 拷贝 左值 移动 左值 构造 临时 移动 临时对照着看a作为左值push_back触发「拷贝」对象被复制一份std::move(a)把它变成将亡值后触发「移动」资源被搬走而不是复制Blob{临时}是纯右值临时对象同样走「移动」。值类别不同编译器生成的代码就不同这正是移动语义能提速的根本原因。看这类日志别只数行数真正要看的是「拷贝」和「移动」这两个词。易错点那些「看起来是右值」其实是左值值类别最容易翻车的地方是「凭直觉猜」。把常见表达式的真实值类别列成一张表比反复背定义管用表达式值类别说明xx的类型是T左值有名字就是左值类型是右值引用也改变不了这点std::move(x)将亡值只是static_castT(x)本身不搬移任何东西abc左值字符串字面量是const char[4]数组数组是左值42/3.14纯右值字面量没有身份i左值返回自增后的对象本身i纯右值返回自增前的旧值副本*p左值解引用拿到的是所指的那个对象x纯右值取地址得到的是一个临时地址值a b纯右值运算产生的是临时对象f()f返回T纯右值C17 起返回值是 prvalue配合强制复制消除f()f返回T左值返回引用时保持被引用对象的值类别x.mm是普通成员与x同类别std::move(x).m是将亡值前两条合起来解释了一个经典困惑为什么std::move(x)之后x还能被读取很多人以为move会当场把东西搬走其实它连一个对象字段都没碰。它只做了一次类型转换x那个对象一个字节都没动真正「搬空」它的是接收方——实参被T重载绑定后接收方内部才动手把资源偷走。记住这个分工std::move就不再有神秘感了。还有个更隐蔽的坑形参的类型是右值引用时它在函数体里是左值。看下面这个程序// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestring#includeutilityvoidsink(std::string){std::coutsink(T) 走拷贝路径\n;}voidsink(std::string){std::coutsink(T) 走移动路径\n;}voidrelay(std::strings){// 形参 s 的类型是右值引用sink(s);// 但 s 有名字 - 它本身是左值 - 拷贝路径sink(std::move(s));// 显式转回将亡值 - 才走移动路径}intmain(){relay(std::string{临时对象});}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::forwardT(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对上。
阅读完成 · 觉得有帮助?