2. 左值与右值:左值、右值、纯右值、将亡值的定义与区分

好,咱们正式开始聊移动语义之前,必须先把这个“值类别”的概念掰扯清楚。说实话,我当年刚接触 C++11 时,看到“左值”、“右值”、“纯右值”、“将亡值”这几个词,第一反应是——这谁起的名字?太绕了。

但后来在实际项目中踩过几次坑,我才明白:不理解值类别,移动语义就是空中楼阁。你想想看,连哪个对象能“偷”、哪个对象不能“偷”都分不清,怎么写得出正确的移动代码?

2.1 左值与右值的经典定义

咱们从最朴素的定义开始。在 C 语言时代,左值和右值的区分很简单:

  • 左值(lvalue):可以出现在赋值号左边的表达式。说白了,就是有名字、有地址的东西。
  • 右值(rvalue):只能出现在赋值号右边的表达式。就是那些临时值、字面量,没有固定地址。

举个例子:

int a = 42;   // a 是左值,42 是右值
int b = a;    // a 是左值,b 也是左值
a = b + 1;    // a 是左值,(b+1) 是右值

嗯,这里要注意:b + 1 这个表达式的结果是一个临时值,它没有名字,你没法对它取地址。这就是典型的右值。

核心判断方法:能不能取地址?

能取地址 → 左值;不能取地址 → 右值。这个方法简单粗暴,90% 的情况都管用。

2.2 C++11 的精细化:纯右值与将亡值

C++11 引入了移动语义之后,原来的“左值 vs 右值”二分法不够用了。为什么呢?因为有些东西“看起来像右值,但又能被移动”——比如即将销毁的临时对象。

于是标准把值类别细化成了三种:

值类别 英文 特征 能否取地址 能否被移动
左值 lvalue 有名字、有持久性 ✅ 能 ❌ 不能(除非用 std::move)
纯右值 prvalue 临时值、字面量、函数返回值(非引用) ❌ 不能 ✅ 能
将亡值 xvalue 即将销毁的资源,可被“偷走” ✅ 能(但即将失效) ✅ 能

我个人习惯把这三类记成:左值 = 常住居民,纯右值 = 过路游客,将亡值 = 即将搬走的租客。将亡值最特殊——它还有地址(所以能取地址),但它的资源马上就要被释放了,你可以在它“搬走”之前把它的东西拿走。

2.3 如何通过地址判断?

咱们直接上代码,看看怎么用 & 运算符来区分:

#include <iostream>
#include <string>

int main() {
    // 左值:可以取地址
    int x = 10;
    std::cout << "x 的地址: " << &x << std::endl;  // ✅ 编译通过

    // 纯右值:不能取地址
    // std::cout << &(x + 1) << std::endl;  // ❌ 编译错误

    // 将亡值:可以取地址(通过 std::move 转换)
    int* p = &(std::move(x));  // ✅ 编译通过
    std::cout << "std::move(x) 的地址: " << p << std::endl;

    // 字符串字面量是左值(特殊!)
    const char* s = "hello";
    std::cout << "\"hello\" 的地址: " << &"hello" << std::endl;  // ✅ 编译通过

    return 0;
}

⚠️ 注意:字符串字面量是左值!

虽然 "hello" 看起来像个临时值,但它在静态存储区有固定地址。这是 C/C++ 的历史遗留问题。我曾经在代码审查时看到有人用 std::move("hello"),这就是典型的误用——字符串字面量根本不需要移动。

2.4 将亡值的典型场景

将亡值最常见的来源有两个:

  1. std::move 的返回值std::move(x) 把左值 x 转换成将亡值,告诉编译器“你可以偷走 x 的资源”。
  2. 返回右值引用的函数:比如 std::move_if_noexcept 等。

举个例子:

std::string createString() {
    std::string temp = "hello";
    return temp;  // C++11 之后,这里隐式将 temp 视为将亡值
}

int main() {
    std::string s = createString();  // 触发移动构造(或拷贝消除)
    // createString() 返回的临时对象就是将亡值
}

这里有个细节:return temp; 中的 temp 是左值还是将亡值?答案是——在返回语句中,局部变量被当作将亡值处理。这是 C++11 引入的特殊规则,目的就是让返回值能自动触发移动语义。

💡 避坑指南

我曾经在项目中看到有人这样写:return std::move(temp);。其实完全没必要!编译器会自动把局部变量当作将亡值。画蛇添足反而可能阻止编译器做返回值优化(RVO/NRVO)。记住:不要对返回值用 std::move

2.5 值类别关系图

下面这张图展示了 C++11 中值类别的完整关系。我建议你把它存下来,每次搞混了就拿出来看看:

表达式 泛左值 (glvalue) 右值 (rvalue) 左值 (lvalue) 将亡值 (xvalue) 纯右值 (prvalue) 💡 关键记忆点: • 左值 = 有地址 + 有名字(如变量名) • 纯右值 = 无地址 + 临时值(如字面量、表达式结果) • 将亡值 = 有地址 + 即将销毁(如 std::move 的结果)

2.6 常见误区与避坑

最后,我总结几个实际项目中容易踩的坑:

  • 误区一:认为所有临时对象都是纯右值。其实 std::move(x) 返回的是将亡值,不是纯右值。将亡值有地址,纯右值没有。
  • 误区二:认为左值一定不能被移动。用 std::move 可以把左值转换成将亡值,然后就能移动了。但要注意——移动之后原对象处于“有效但未指定”的状态,不能再安全使用。
  • 误区三:混淆引用折叠和值类别。引用折叠是模板推导时的规则,值类别是表达式本身的属性。两者有关联,但不是一回事。

一句话总结:

左值有地址有名字,纯右值没地址没名字,将亡值有地址但即将消亡。判断方法很简单——&expr 能编译通过就是左值或将亡值,编译失败就是纯右值。

嗯,值类别这部分确实有点绕,但它是理解移动语义的基石。我建议你多写几个小例子,用 static_assertstd::is_lvalue_reference 之类的工具验证一下自己的判断。下一节咱们就正式进入移动构造函数和移动赋值运算符,到时候你会感谢今天打下的基础。


公众号:蓝海资料掘金营,微信 deep3321