左值、右值、纯右值、将亡值:值类别入门

你有没有写过 std::move(x) 之后发现它还能被读取,或者重载了 f(T&) 和 f(T&&) 却搞不清到底调了哪个?这些怪现象的根都在「值类别(value category)」上。C++11 起给每个表达式贴了一张隐形标签,它直接决定一个表达式能不能取地址、能不能被移动、以及重载决议时该选哪个版本。这篇把五个概念一次讲清,并用真跑出来的输出告诉你每种实参选中了哪个函数。

官方文档:Value categories (cppreference)

同一个名字,两种命运

先看一个会让人困惑的现象:

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 同时落在两边 ────┘         字面量

官方文档:reference collapsing 与值类别的关系 (Core Guidelines)

怎么判断一个表达式是左值还是右值

记住两条够用的经验法则:

  1. 有名字的变量本身是左值,即使它的类型是右值引用 T&&(见下一篇)。
  2. 能不能取地址:能取地址的通常是左值;取不了地址的(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 是有条件地按原值类别转发;把「有名字的变量是左值」这一条钉死,两者都不会用错。

延伸阅读

收个尾

值类别就是 C++ 给每个表达式贴的隐形标签:左值有名字、可取地址,右值可被移动,将亡值是「马上要被搬空」的那个特殊右值。重载决议选哪个版本、走拷贝还是走移动,全看这张标签。

先把「有名字的变量就是左值」这一条钉死,剩下几个概念基本不会用错。下一篇换引用的视角,把值类别和 T& / T&& 对上。

相关推荐
程序员老陆1 小时前
C++ 将亡值(xvalue)深度解析:从表达式分类到参数传递陷阱
c++·程序设计
沫璃染墨2 小时前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
青少儿编程课堂2 小时前
树上启发式合并详解:子树颜色众数统计与复杂度拆解
c++·python·算法·bfs·信息学竞赛
路弥行至2 小时前
【Head First 设计模式】第 2 章:观察者模式 —— 用现代 C++ 重新解读“交互对象的松耦合
c++·经验分享·笔记·观察者模式·设计模式·入门教程·headfirst
YYYing.2 小时前
【设计模式系列 (九) 】装饰器模式
c++·后端·设计模式·装饰器模式·c/c++
elseif1233 小时前
【2026 CSP-J】【逐题精讲】超详细(15道选择,3道阅读,2道完善)
java·开发语言·c++·算法·csp
殷色玫瑰3 小时前
C语言编译和链接:从 .c到 .exe,程序到底经历了什么?
linux·c语言·c++·算法
keep intensify3 小时前
C++20线程池
c++·算法·c++20
汉克老师3 小时前
GESP2026年9月认证C++一级( 第三部分编程题(2、棋盘上的奖赏))精讲
c++·gesp·小学生·学c++编程