一、先破除旧定义

教科书常说"能放在赋值号左边的是左值"。这个说法在 C 时代勉强够用,在 C++ 里已经不成立:

const int a = 1;   // a 是左值,但不能放在赋值号左边
int arr[10];       // arr 是左值,也不能被赋值
struct S { int x; };
S{}.x = 1;         // 能放在左边,但 S{}.x 是右值(xvalue)

准确的判据是两个正交的属性。

二、C++11 的值类别体系

任何表达式都有两个独立的属性:

  • 身份(identity):是否指代一个确定的、可以取地址的内存对象
  • 可移动(movable):是否允许把它的资源偷走
有身份 无身份
不可移动 lvalue(左值) —
可移动 xvalue(将亡值) prvalue(纯右值)

组合出五个名字:

        expression
        /        \
   glvalue      rvalue
   /     \      /    \
lvalue   xvalue    prvalue
         (交集)
  • glvalue(泛左值)= 有身份 = lvalue + xvalue
  • rvalue(右值)= 可移动 = xvalue + prvalue
  • lvalue(左值)= 有身份且不可移动

日常说的"左值 / 右值"就是 lvalue 和 rvalue 这两个。

三、实用判据

面试和日常写代码,记住这两条就够了:

能取地址(&expr 合法)→ 左值;否则 → 右值。 有名字 → 左值。 ← 这条最容易被忽略

第二条的推论非常反直觉:

void f(std::string&& s) {   // s 的类型是右值引用
    g(s);                   // 但 s 这个表达式是【左值】!调用 g(const string&)
    g(std::move(s));        // 想传右值必须显式 move
}

类型是右值引用 ≠ 表达式是右值。 s 有名字、能取地址,所以它是左值。这是移动语义里最经典的坑:写了 && 参数却忘了 std::move,移动悄悄退化成拷贝。

四、对照表

表达式 类别 说明
a(变量名) lvalue 哪怕类型是 int&&
*p lvalue 解引用
arr[i] lvalue
++a lvalue 前置返回自身引用
a = 1 lvalue 赋值表达式返回左值引用
"hello" lvalue 字符串字面量是 const char[6] 数组,能取地址
42、true、nullptr prvalue 字面量(除字符串外)
a + b prvalue 算术结果
a++ prvalue 后置返回旧值副本
f() 返回 T prvalue 按值返回
f() 返回 T& lvalue
f() 返回 T&& xvalue
std::move(a) xvalue 本质是 static_cast<T&&>
T{}、T(1,2) prvalue 临时对象
S{}.member xvalue 右值的成员是将亡值
lambda prvalue

五、引用的绑定规则

这张表是右值概念的实际用武之地:

引用类型 绑左值 绑右值
T& ✓ ✗
const T& ✓ ✓
T&& ✗ ✓

第二行是老 C++ 就有的:const T& 能绑右值,并且延长临时对象的生命周期到引用本身的作用域结束。

const std::string& s = std::string("hi");  // 临时对象活到 s 离开作用域

(注意这个延长不传递:函数返回一个绑到临时对象的 const T&,返回后就是悬垂引用。)

第三行是 C++11 新增的,它让"这个对象我不要了"成为一个可被重载决议识别的信号:

void g(const std::string&);  // 左值走这里 → 拷贝
void g(std::string&&);       // 右值走这里 → 可以放心偷

六、为什么要引入右值:移动语义

class Buf {
    char* p; size_t n;
public:
    Buf(const Buf& o)            // 拷贝:分配 + memcpy,O(n)
        : p(new char[o.n]), n(o.n) { memcpy(p, o.p, n); }

    Buf(Buf&& o) noexcept        // 移动:偷指针,O(1)
        : p(o.p), n(o.n) { o.p = nullptr; o.n = 0; }
};

Buf make();
Buf b = make();      // make() 是 prvalue → 走移动(C++17 起直接省略构造)
Buf c = b;           // b 是 lvalue → 走拷贝
Buf d = std::move(b);// 转成 xvalue → 走移动,之后 b 处于有效但未指定状态

关键点:移动构造一定要标 noexcept,否则 std::vector 扩容时为了保证强异常安全会退化成拷贝(std::move_if_noexcept)。

七、两个容易混的点

① T&& 不一定是右值引用

void f(std::string&& s);          // 右值引用,只收右值
template<class T> void g(T&& x);  // 转发引用!左右值都收
auto&& y = expr;                  // 转发引用

只有在模板参数推导或 auto 的语境下,T&& 才是转发引用。它靠引用折叠工作:传左值时 T 推成 U&,U& && 折叠成 U&。折叠规则一句话:只要有一个 &,结果就是 &。

② std::move 和 std::forward 的分工

std::move(x)      // 无条件转成右值,用在【你确定不再需要 x】的地方
std::forward<T>(x)// 按 T 的推导结果还原原始值类别,只用在转发引用上

std::move 本身不移动任何东西,它只是个 static_cast,真正干活的是随后被选中的移动构造/赋值。

八、C++17 的一个重要变化

C++17 起,prvalue 不再被理解为"一个临时对象",而是"初始化某个对象的配方",只有在真正需要一个对象时才**物化(materialize)**成 xvalue。后果是返回值优化(RVO)从"允许优化"变成"语言保证":

struct NoCopyNoMove {
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&) = delete;
};
NoCopyNoMove make() { return NoCopyNoMove{}; }
NoCopyNoMove x = make();   // C++17 合法:从头到尾只有一个对象,没有任何构造被省略

所以现在写 return local; 时不要画蛇添足写 return std::move(local); —— 那反而会阻止 NRVO,把零成本变成一次移动。


面试一句话答案:

左值有身份、不可移动,能取地址、有名字;右值分纯右值(无身份,如字面量和按值返回的临时量)和将亡值(有身份但允许被偷,如 std::move 的结果)。区分它们的意义在于重载决议能识别"这个对象即将销毁",从而用 O(1) 的资源转移替代 O(n) 的深拷贝。注意 T&& 类型的具名变量本身是左值。

**将亡值(xvalue,eXpiring value)**是 C++11 引入的值类别,指"有身份、但允许被掏空"的表达式。

一、它在值类别体系里的位置

任何表达式有两个正交属性:

有身份(指代一个确定的对象) 无身份
不可移动 lvalue 左值 —
可移动 xvalue 将亡值 prvalue 纯右值
        expression
        /        \
   glvalue      rvalue
   /     \      /    \
lvalue    xvalue    prvalue
          ↑
      两者的交集
  • glvalue(泛左值)= 有身份 = lvalue + xvalue
  • rvalue(右值)= 可移动 = xvalue + prvalue

xvalue 是这个二维表格里唯一"两边都沾"的格子。 C++03 只有 lvalue/rvalue 二分,是移动语义的引入让"有身份且可移动"这个组合第一次有了意义,才需要给它起个名字。

二、名字的含义

"将亡" = 这个对象即将结束生命,所以你可以放心偷它的资源。

但要注意:这是一个许可,不是一个事实。

std::string s = "hello";
std::move(s);        // s 是将亡值 —— 但 s 一行都没少活
std::cout << s;      // s 还好好的,还是 "hello"

std::move 不移动任何东西,它只是把表达式标记成"可以被偷"。真正掏空 s 的是随后被重载决议选中的移动构造函数。如果没人接手,什么都不会发生。

三、xvalue 从哪来

表达式 说明
std::move(x) 本质就是 static_cast<T&&>(x)
static_cast<T&&>(x) 显式转成右值引用
f(),其中 f 返回 T&& 返回右值引用的函数调用
S{}.member 右值的非静态数据成员
std::move(arr)[0] 右值数组的下标
临时量物化(C++17) prvalue 在需要 glvalue 时自动转成 xvalue

最后一条最容易被忽略。C++17 起 prvalue 不再是"一个临时对象",而是"初始化对象的配方"。只有当你需要一个真正的对象时,才发生临时量物化转换(temporary materialization),prvalue → xvalue:

const int& r = 42;     // 42 是 prvalue → 物化成临时对象(xvalue)→ 绑定引用
sizeof(std::string{}); // 不需要真对象,不物化
std::string{}.size();  // 调成员函数需要对象 → 物化

四、怎么判断一个表达式是不是 xvalue

最可靠的办法是用 decltype,它对三种值类别给出不同结果:

int x = 0;

decltype(x)             // int    ← 特例:具名变量给【声明类型】
decltype((x))           // int&   ← 加括号变成表达式 → lvalue
decltype(x + 1)         // int    ← prvalue
decltype(std::move(x))  // int&&  ← xvalue ✓

规律:

值类别 decltype(expr)
lvalue T&
xvalue T&&
prvalue T

标准库也直接提供了判定:

static_assert(std::is_rvalue_reference_v<decltype(std::move(x))>);

五、xvalue 和 prvalue 有什么实际区别

两者都是右值,重载决议时行为一致 —— 都能绑到 T&&,都会选移动构造。那为什么还要分?

因为 copy elision(复制省略)只对 prvalue 生效。

struct NoCopyNoMove {
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&) = delete;
};

NoCopyNoMove make() { return NoCopyNoMove{}; }   // prvalue
NoCopyNoMove x = make();                         // ✓ C++17 保证:全程只有一个对象

prvalue 是"配方",可以直接在目标位置就地构造,一次构造都不需要省略。而 xvalue 已经指代一个存在的对象,只能真的做一次移动。

这直接解释了那条著名的规则:

Widget f() {
    Widget w;
    return w;                 // ✓ NRVO:直接在调用者的返回槽里构造,零成本
    // return std::move(w);   // ✗ 变成 xvalue,NRVO 失效,强制一次移动构造
}

std::move 把一个 NRVO 候选变成了 xvalue,把零成本变成了一次移动。所以 return local; 永远不要加 std::move。

六、一个必须记住的陷阱

"类型是右值引用" ≠ "表达式是 xvalue":

void f(std::string&& s) {   // s 的【类型】是右值引用
    g(s);                   // 但 s 这个【表达式】是左值!→ 调用 g(const string&),拷贝
    g(std::move(s));        // 必须显式 move 才变回 xvalue → 移动
}

原因很简单:s 有名字、能取地址,符合左值的定义。具名变量永远是左值,哪怕它的类型带 &&。

这也是 std::forward 存在的理由 —— 在模板里,"调用者原本传的是左值还是右值"这个信息只保存在 T 的推导结果里,必须靠 std::forward<T>(x) 通过引用折叠把它还原出来。


一句话总结

将亡值(xvalue)是"有身份、可移动"的表达式:它像左值一样指代一个具体的对象,又像右值一样允许你把资源偷走。典型来源是 std::move 和返回右值引用的函数。它和纯右值同属右值、重载决议行为一致,但纯右值能享受 C++17 保证的复制省略而将亡值不能 —— 这正是 return std::move(local) 会拖慢代码的原因。

对,但要分清一件事:将亡值是"表达式"的属性,不是"变量"的属性。

一、精确地说

std::string s = "hello";

std::move(s);   // 表达式 `std::move(s)` 是 xvalue —— 这一点永远成立
s;              // 表达式 `s` 是 lvalue —— 这一点也永远成立

s 这个变量从来没有"变成"将亡值。每个表达式各自有自己的值类别,逐个独立判定,互不影响。 写一百次 std::move(s),s 本身在别处仍然是左值。

所以"s 是将亡值"这个说法本身就不严谨,准确的说法是"std::move(s) 这个表达式是将亡值"。

二、std::move 到底做了什么

template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}

一个 static_cast,编译后不产生任何指令。它唯一的作用是:让这个表达式在重载决议时被当成右值看待。

所以 std::move(s); 这一行单独写出来是个弃值表达式:类型转换发生了,但结果没人接手,什么都不会发生。等价于写了个 (void)s;。

三、三种"move 了却什么都没发生"的情况

① 结果没被接手

std::move(s);              // ✗ 白写。转换发生了,但没有移动构造被调用
std::string t = std::move(s);   // ✓ 被移动构造接手,s 才真的被掏空

② 对象是 const

const std::string cs = "hello";
std::string t = std::move(cs);   // 编译通过,但执行的是【拷贝】

std::move(cs) 的类型是 const std::string&&,绑不上 string(string&&),重载决议只好退回 string(const string&)。静默降级,没有任何警告。

③ 类型没有移动构造

struct Big { int a[1000]; };     // 无自定义移动构造,移动就是逐字节拷贝
Big b = std::move(a);            // 走拷贝,a 完全不变

对于平凡可复制的类型,"移动"和"拷贝"本来就是一回事。

四、编译器不会"记住"你 move 过

这一点最关键:语言层面没有任何流分析。

std::string s = "hello";
std::string t = std::move(s);   // s 内容被搬走

std::cout << s;                 // ✓ 合法,编译不报错(输出通常是空)
std::string u = s;              // ✓ 合法,拷贝 s 当前的内容
s = "world";                    // ✓ 合法,s 完全可以复活
std::cout << s;                 // "world"

编译器对 s 的类型和值类别的看法从头到尾没变过。改变的只是运行期 s 的内容。

所以 use-after-move 是一个语义 bug,不是编译错误。想让工具帮你抓,需要静态分析器:

clang-tidy --checks='bugprone-use-after-move' main.cpp

它会做数据流分析,警告"这个变量被 move 之后又被读了"。

五、被移动后的对象处于什么状态

标准库类型保证处于 "有效但未指定"(valid but unspecified) 的状态:

  • 可以做:没有前置条件的操作 —— 赋新值、clear()、size()、析构
  • 不能做:假设它的具体内容
std::string t = std::move(s);
s.size();       // ✓ 合法,返回某个值
s = "new";      // ✓ 合法,s 完全可用
s[0];           // ✗ 有前置条件(要求非空),如果 s 空了就是 UB
assert(s.empty());  // ✗ 不保证!实践中通常为空,但标准没规定

std::unique_ptr 是少数明确规定了移动后状态的类型(保证变成 nullptr)。其余的都别假设。

六、什么时候该写 std::move

// ✓ 局部变量传给会接管的地方,之后不再用它
v.push_back(std::move(local_str));

// ✓ 成员初始化
Widget(std::string s) : name_(std::move(s)) {}

// ✓ 右值引用参数往下传(因为 s 本身是左值!)
void f(std::string&& s) { g(std::move(s)); }

// ✗ 返回局部变量 —— 会阻止 NRVO,把零成本变成一次移动
return std::move(local);   // 直接写 return local;

// ✗ 转发引用上 —— 会把调用者的左值也偷走
template<class T> void f(T&& x) { g(std::move(x)); }  // 应该用 std::forward<T>(x)

// ✗ 之后还要用这个对象
process(std::move(cfg));
use(cfg);                  // cfg 已被掏空

一句话回答

是的,std::move(s) 这个表达式无条件地是将亡值 —— 因为 std::move 就是一个编译期的 static_cast,转换总会发生。但变量 s 本身没有任何变化:它在其他表达式里仍然是左值,编译器也不会记录"它被 move 过"。s 会不会真的被掏空,取决于这个 xvalue 有没有被某个移动构造/赋值接手 —— 结果被丢弃、对象是 const、类型没有移动构造,这三种情况下 std::move 都等于白写。