一、先破除旧定义

教科书常说"能放在赋值号左边的是左值"。这个说法在 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] 数组,能取地址
42truenullptr 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::movestd::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&& 类型的具名变量本身是左值。