前言
「左值」「右值」是 C++ 里被提到最多、被讲清最少的两个词。很多教程用「等号左边是左值,右边是右值」来解释,这个说法在 C 里勉强够用,在 C++11 之后完全不够:等号左边也可以是右值引用,函数实参、返回值、成员访问都各有自己的值类别。真正决定一个表达式能做什么的,是它的值类别(value category):能不能取地址、能不能被移动、绑定到哪种引用、decltype推导出什么类型。
C++17 把值类别整理成三个基础类别和两个组合类别:左值(lvalue)、将亡值(xvalue)、纯右值(prvalue);左值与将亡值合称泛左值(glvalue),将亡值与纯右值合称右值(rvalue)。理解这套分类,才能解释为什么std::move不移动任何东西、为什么具名的右值引用在下一行就变成左值、为什么decltype(x)与decltype((x))结果不同。
最常见的误解有三个。第一,认为std::move(x)会执行移动——它只做一次类型转换,把表达式变成将亡值,真正的移动发生在被调用的移动构造或移动赋值里。第二,认为声明为T&&的变量就是右值——变量名本身永远是左值,哪怕它的类型是右值引用。第三,认为右值引用一定能延长生命周期——绑定到临时对象的右值引用确实会延长,但从函数返回右值引用就是悬垂。
本文按「值类别、引用绑定、move 与 forward、完整示例」展开,最后列出常见坑点。示例以 C++17 为基准。
一、三个基础类别
左值(lvalue)有身份(identity,能取地址、能比较地址),且不能被隐式移动。变量名、解引用表达式*p、数组下标a[i](内置类型)、前置自增的结果,都是左值。
纯右值(prvalue)没有身份,用于初始化对象或参与运算。字面量42、a + b的结果、按值返回的函数调用结果,都是纯右值。纯右值不能取地址,&42不合法。
将亡值(xvalue)有身份,但可以被移动。它通常由std::move(x)、static_cast<T&&>(x)产生,或者是对将亡值的成员访问。将亡值的存在,是为了让「我确定不再需要这个对象了」这件事能在类型系统里表达出来。
组合关系是:glvalue = lvalue + xvalue;rvalue = xvalue + prvalue。判断一个表达式,先问「有没有身份」,再问「能不能被移动」,就能定位它的类别。
decltype对值类别很敏感。对未加括号的标识符,decltype(x)给出声明时的类型;对加了括号的表达式,decltype((x))按值类别给引用:左值给T&,将亡值给T&&,纯右值给T。这也是模板库中判断「表达式类别」的常用手段。
二、引用绑定规则
左值引用T&只能绑定左值;const T&可以绑定左值,也可以绑定右值,绑定右值时临时对象的生命周期延长到该引用的生命周期结束。右值引用T&&只能绑定右值(将亡值或纯右值);它可以绑定临时对象并延长其生命周期。
函数重载时,编译器按实参的值类别选择:f(T&)接受左值,f(const T&)接受左值与右值,f(T&&)接受右值。同时提供f(const T&)与f(T&&)时,右值优先选后者,这就是「移动语义」在接口层的体现。
模板里的T&&是另一回事。当T需要推导时(函数模板参数、auto&&),T&&是转发引用(forwarding reference),它能绑定左值也能绑定右值,具体类型由实参决定:传左值时T推导为U&,经过引用折叠得到U&;传右值时T推导为U,得到U&&。引用折叠规则是:& + & = &,& + && = &,&& + & = &,&& + && = &&。只有「唯一一个&」的场合才得到左值引用。
注意,类模板成员函数里的T&&不一定是转发引用,因为T在类实例化时已经确定,不再参与推导。判断标准是「是否发生类型推导」,不是「长得像不像T&&」。
三、std::move与std::forward
std::move(x)的定义等价于static_cast<std::remove_reference_t<decltype(x)>&&>(x):它把任意表达式转成将亡值,本身不读取、不复制、不释放任何资源。真正「搬走」资源的是接收这个将亡值的移动构造函数或移动赋值运算符。若类型没有移动操作,重载解析会退回拷贝,此时std::move只是让拷贝发生,没有任何收益。
对const对象调用std::move,得到的是const T&&。const T&&不能绑定到T&&形参,只能绑定到const T&,于是调用的是拷贝构造。这是「对 const 对象 move 会退化成拷贝」的原因。
std::forward<T>(x)用于转发引用:当T是左值引用类型时,它返回左值;当T是普通类型时,它返回右值。它的作用是「保持调用方传入的值类别」,而不是「无条件转成右值」。在泛型包装函数里,参数要一路std::forward下去,否则具名的T&&参数在函数体内是左值,右值语义在这里断掉。
被移动后的对象处于「有效但未指定」(valid but unspecified)状态:可以析构、可以重新赋值,但不要读取它的值,也不要假设它为空。
四、完整示例
下面的示例演示值类别的判定、重载选择、转发引用与decltype的差异。
// 适用于 C++17
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
void take(const std::string& s) {
std::cout << "const lvalue ref: " << s << '\n';
}
void take(std::string&& s) {
std::cout << "rvalue ref: " << s << '\n';
}
// 转发引用:保持调用方的值类别
template <typename T>
void relay(T&& value) {
take(std::forward<T>(value));
}
int main() {
std::string a = "hello";
take(a); // 左值,选 const&
take(std::string("tmp")); // 纯右值,选 &&
take(std::move(a)); // 将亡值,选 &&
const std::string c = "const";
take(std::move(c)); // const T&& 只能绑 const&,退化
std::string b = "world";
relay(b); // T = std::string&,转发为左值
relay(std::string("rvalue")); // T = std::string,转发为右值
relay(std::move(b)); // T = std::string,转发为右值;b 之后不再使用
int n = 0;
int& r = n;
static_assert(std::is_same_v<decltype(n), int>);
static_assert(std::is_same_v<decltype((n)), int&>);
static_assert(std::is_same_v<decltype(r), int&>);
static_assert(std::is_same_v<decltype(std::move(n)), int&&>);
static_assert(std::is_same_v<decltype(n + 1), int>);
std::cout << n << ' ' << r << '\n';
return 0;
}按标准语义推演:take(a)选const&;take(std::string("tmp"))与take(std::move(a))选&&;take(std::move(c))中实参类型是const std::string&&,不能绑定到std::string&&,只能绑定const std::string&,所以仍走左值重载;relay(b)中T为std::string&,转发后是左值;两个右值实参转发后走&&重载;decltype的四条断言按值类别规则都成立。以上只按语言规则推导,未经编译运行核对。
五、值类别对照
| 类别 | 有身份 | 可被移动 | 能否取地址 | 典型表达式 |
|---|
| 左值 lvalue | 是 | 否(需显式 move) | 能 | 变量名、*p、++i |
| 将亡值 xvalue | 是 | 是 | 能 | std::move(x)、static_cast<T&&>(x) |
| 纯右值 prvalue | 否 | 是 | 不能 | 42、a + b、按值返回的调用 |
| 泛左值 glvalue | 是 | 视具体类别 | 能 | 左值与将亡值 |
| 右值 rvalue | 否或视情况 | 是 | 视具体类别 | 将亡值与纯右值 |
常见坑点
一、以为std::move会移动对象
❌std::move(v);单独一行,以为v的资源已经被搬走。
✅std::move只做类型转换;必须把它用在移动构造或移动赋值的实参位置,例如T t = std::move(v);。
二、对const对象使用std::move期望移动
❌const std::string s = "x"; std::string t = std::move(s);实际调用拷贝构造。
✅ 需要移动就不要声明为const;const T&&无法绑定T&&形参。
三、具名右值引用在函数体内当右值用
❌void f(T&& x) { g(x); }——x是左值,调用的是g(T&)或g(const T&)。
✅ 用g(std::forward<T>(x))转发,或者明确g(std::move(x))表示不再使用。
四、移动后继续读取对象的值
❌std::string t = std::move(s); std::cout << s;读取处于「有效但未指定」状态的对象。
✅ 移动后只允许析构或重新赋值;需要保留内容时先拷贝。
五、返回局部变量时画蛇添足地std::move
❌return std::move(local);阻止了返回值优化,强制一次移动构造。
✅ 直接return local;,编译器可以省略拷贝/移动(C++17 对纯右值返回有强制省略的情形)。
六、把类模板成员里的T&&当转发引用
❌ 在template <typename T> class Box { void set(T&& v); };中认为T&&能自动转发。
✅T在类实例化时已确定,不参与推导;需要转发时把成员函数本身写成模板,或按值/按const&接收。
七、返回右值引用导致悬垂
❌T&& f() { T local; return std::move(local); }返回局部对象的右值引用,调用方拿到即悬垂。
✅ 按值返回;右值引用形参/返回值只用于「引用已有对象」的场景。
八、对字符串字面量使用std::move
❌std::move("abc")得到const char(&&)[4],既不能移动也没有意义,还容易让重载解析出意外结果。
✅ 字面量直接传递;需要std::string时显式构造。
总结
| 问题 | 答案 |
|---|
| 左值和右值的区别是什么 | 左值有身份且不可隐式移动;右值可被移动,其中将亡值有身份、纯右值没有 |
std::move做了什么 | 把表达式转成将亡值,不移动任何资源 |
为什么T&&参数在函数内是左值 | 具名变量永远是左值,类型不影响这一点 |
什么时候用std::forward | 转发引用参数需要保持调用方的值类别时 |
为什么对const对象 move 会拷贝 | const T&&只能绑定const T& |
decltype(x)与decltype((x))为何不同 | 前者取声明类型,后者按表达式的值类别给引用 |
左值与右值的核心只有一句话:值类别描述的是「表达式」,不是「变量」;类型是T&&的变量名,作为表达式仍然是左值。把这句话记住,再理解std::move是转换、std::forward是保持,移动语义的绝大部分困惑就会消失。