7008 字
35 分钟

C++左值和右值

C++ 里”左值”和”右值”最初只是一个关于赋值的记忆口诀:能放在赋值号左边的是左值,只能放右边的是右值。这个说法在 C++11 引入移动语义之后已经不够用了。现代 C++ 中,左值 / 右值是表达式的属性——它描述一个表达式指向的是”一个有身份的对象”还是”一个可以被搬走的临时值”,并由此决定这个表达式能绑定到哪种引用、能不能被移动、能不能取地址。理解这套分类,是理解引用、移动语义乃至类设计的基础。

从赋值号到”身份”#

“左值 / 右值”这套词最早是从赋值语句的位置来的:能写在赋值号左边的叫左值,只能待在右边的叫右值。

int x = 5; // x 能出现在 = 左边,是左值;5 只能在右边,是右值
x = 10; // 合法
5 = x; // 非法:不能往一个字面量里写东西

这个直觉很好用,但很快会露馅。const 变量不能被赋值,可它是个不折不扣的左值:

const int c = 5;
c = 10; // 非法,但 c 仍然是左值
const int* p = &c; // 证据:能取到它的地址

可见”能不能放在赋值号左边”只是表象,不是真正的判据。C++11 把这件事重新讲清楚了:一个表达式属于哪一类,由两个互相独立的属性决定。

判据一:有没有身份#

有身份(identity)指这个表达式对应着一个能定位的具体对象——它有确定的存储位置,原则上可以取地址,能和别的对象区分开。判断方法很直接:看能不能对它用 & 取地址。

int x = 5;
int* p = &x; // OK:x 有身份,&x 就是它的地址
// int* q = &(x + 1); // 编译错误:x + 1 算完就没了,没有对象供你指向

有名字的变量 x 是最典型的有身份表达式:它稳稳待在内存某处,&x 随时能取到。而 x + 1 只是一次运算的结果,没有名字、也没有一个持久对象让你指向,就是”无身份”的。

判据二:能不能移动#

可移动(movable)说的不是”这个对象有没有资源可偷”,而是”这个表达式指向的对象,用完之后还会不会再被用到”。

  • 有名字的变量 x,后面随时可能再被读到、改到,语言就不敢让它被悄悄掏空——所以具名对象作为表达式不可移动
  • x + yfoo() 这种没名字的临时结果,语句一结束就消失,没人再看得到它,可以放心把里面的资源搬走——它们可移动

下面这个例子能体会到区别(std::string 内部持有一块堆缓冲):

std::string a = "hello";
std::string b = a; // a 后面还可能用到 → 只能整份复制一遍
std::string c = a + "!"; // a + "!" 是临时值,用完就没 → 可以把它的
// 内部缓冲直接搬给 c,不必再复制

所以并不是”什么都可移动”。正是”有没有名字、之后还用不用得着”这条线,把可移动和不可移动分开。

这两个属性两两组合,就划出了下面的值类别。

五种值类别#

C++ 的值类别分类

把”有没有身份”和”能不能移动”两个属性组合起来,就得到三个基本类别,再加上两个由它们并出来的类别。

三个基本类别#

  • lvalue(左值):有身份、不可移动。有名字的变量 x、解引用 *p、数组元素 a[i]、前置自增 ++x 都是左值。字符串字面量 "hello" 也是左值——它是一个有存储的 const char 数组。
  • prvalue(纯右值):无身份、可移动。字面量 42truenullptr,算术表达式 a + b,后置自增 x++,取地址 &x,以及按值返回的函数调用 foo(),都是纯右值。它表达的是”一个值”本身,而不是某个具名对象。
  • xvalue(将亡值):有身份、可移动。它指向一个确实存在的对象,但这个对象即将结束生命、资源可以被复用。最典型的是 std::move(x),以及返回类型为 T&& 的函数调用。

两个并出来的类别#

  • glvalue(广义左值) = lvalue + xvalue:凡是”有身份”的表达式。
  • rvalue(右值) = prvalue + xvalue:凡是”可移动”的表达式。

xvalue 同时属于 glvalue 和 rvalue——它既有身份又可移动。这正是 C++11 专门补上的一类:用来表达”这个有名有址的对象,你可以把它搬走”。

说的是表达式,不是变量或类型#

一个反复出现的混淆需要先澄清:左值 / 右值说的是表达式,不是变量、也不是类型。int&& 是一种右值引用类型,但一个类型为 int&& 的具名变量,它作为表达式时却是个左值:

int&& r = 42; // r 的类型是右值引用(int&&),它绑住了一个右值
int* p = &r; // 但 r 作为表达式是左值:它有名字、能取地址
r = 10; // 也能给它赋值——这都是左值才做得到的事

“类型是右值引用”和”作为表达式是左值”是两回事——想把这样一个具名的右值引用重新当右值用,得靠 std::move

为什么 *p 是左值#

解引用 *p 看着像一步操作(地址 → 值),很容易以为它算出的是个临时右值。其实恰好相反:*p 不是“读出一个值”,而是“指认一个对象”——它不是 p 所指地址里存着的那个数,而是那块存储本身,只不过换用指针来称呼它。所以它有身份,对应一个确定的、已经存在的对象;判据也都对得上:*p = 42 能写进去、&*p 能取到地址(结果正是 p)、int& r = *p; 能绑非常量左值引用——这些都是只有左值才做得到的。

那“从地址把值读出来”这一步去哪了?它确实存在,但那是另一回事:只有当上下文需要一个值时(比如 int x = *p; 要拿 *p 的内容来初始化 x),编译器才在这个左值之上再叠一层左值到右值转换(lvalue-to-rvalue conversion),把对象里的值取出来。换句话说,*p 本身始终是左值;要不要真的“加载进寄存器”,取决于你怎么用它——用作赋值目标、取地址、绑引用时,它自始至终就是那个对象,根本没有读值这一步。你设想的“地址 → 临时值”,正是这层转换,而不是 *p 本身。

*& 的对称也能佐证:& 吃一个左值(对象)、吐出一个纯右值(地址);* 吃一个指针值(地址)、吐出一个左值(那个对象)。两者互逆,&*p 又变回 p。所以 * 的结果天然就是“对象”,也就是左值。

为什么 x++ 是纯右值#

同样的判断反过来用在后置自增 x++ 上:它是纯右值,而前置自增 ++x 是左值,差别正好落在“表达式给你的是对象、还是一个独立的值”上。

  • ++x 先把 x 加一,再把 x 本身交给你——结果就是那个已经变大的 x,所以 ++x = 0&++xint& r = ++x; 都合法,它是左值。
  • x++ 也把 x 加一,但它交给你的是 x 原来的值。可 x 此刻已经变成新值了,这个“旧值”不再存在于任何具名对象里,只能被复制成一个临时快照返回。这个快照没有身份、没有地址:x++ = 0&(x++)int& r = x++; 全部非法。所以 x++ 是纯右值。

用类类型的运算符重载写出来,两者的返回方式一目了然:

T& operator++(); // 前置:改完把对象自己按引用返回 → 左值
T operator++(int); // 后置:先存下旧值,改完把旧值按值返回 → 纯右值

(后置版多出来的那个 int 形参不接收任何实参,只是用来和前置版区分。)后置按值返回一个旧值的副本,正是纯右值的来历;对内建类型两种写法开销一样,但对迭代器等类类型,it++ 要多造一个副本,所以循环里习惯写 ++it

存储在哪里#

关于存储,常见的说法是”左值在内存里、右值在寄存器里”。这并不准确。值类别是编译期对表达式的分类,标准并没有规定右值一定不占内存。更贴切的描述围绕”地址”和”临时对象”展开。

左值、纯右值与临时对象的存储

  • 有名字的对象(左值)有稳定的存储和地址。int x = 42;x 住在栈上某个地址,&x 合法,可以把这个地址存进指针 int* p = &x;。只要 x 活着,这个地址就稳定。
  • 纯右值表达的是抽象的”值”,没有名字也没有稳定地址。&42&(x + 1) 都是编译错误,因为没有一个持久的对象供你指向。
  • 当一个纯右值确实需要一块存储时——比如要绑定到引用、或要访问它的成员——编译器会把它物化(materialize)成一个临时对象。这一步在 C++17 里叫临时量物化转换(temporary materialization conversion),物化之后那个表达式就变成了 xvalue。临时对象通常也在栈上,只是生命周期很短,默认到所在完整表达式结束就析构。

这里可以澄清一个常见的理解:说右值”没有地址”,更准确的说法是”你拿不到它的地址”。一个纯右值一旦物化成临时对象,它就占着一块(通常在栈上的)存储、确实有地址,只是语言禁止你用 & 去取它;而没被物化的纯右值可能压根不对应任何存储——被直接折叠进指令、或只停在寄存器里——那就连地址都无从谈起。所以”右值其实有地址、只是程序员拿不到”这个理解,对已经物化的临时对象成立,但不能一概而论套到所有纯右值上。

至于”是不是真的进了寄存器”,那是编译器后端优化的结果,和值类别不是一回事:一个左值只要没人取它地址,也完全可能被优化进寄存器。所以准确的对应关系是:左值对应可寻址的具名对象,纯右值是没有地址的抽象值,将亡值是有址但即将消亡、资源可被复用的对象。

引用:把值类别接住#

C 里想让两个名字指向同一个对象,只能靠指针:存地址、用 * 解引用、还得当心空指针。C++ 的引用是更省事的办法——它就是给一个已存在的对象起的别名,绑定时不用取地址、用的时候不用解引用,而且一旦绑好就不能改指向别的对象。

int x = 5;
int& r = x; // r 是 x 的别名,不是另一个对象
r = 10; // 等价于 x = 10
// &r 得到的就是 &x

引用分两大类,它们能”接住”的值类别不同——这正是值类别在语言里最直接的用武之地。

不同引用类型能绑定的值类别

左值引用:T&const T&#

左值引用 T& 只能绑(非常量)左值,绑不了右值:

int x = 5;
int& r = x; // OK:x 是左值
// int& bad = 42; // 错误:42 是右值,非常量左值引用绑不上

常量左值引用 const T& 放宽了限制:左值、右值都能绑。绑到右值时,那个临时对象的生命周期会被延长到引用存在的这段时间:

int x = 5;
const int& a = x; // OK:绑左值
const int& b = 42; // OK:绑右值,临时量的生命周期被延长到 b

这就是只读形参惯用 const T& 的原因——一个签名同时接住有名对象和临时量,还不产生拷贝:

void print(const std::string& s); // 左值、临时量都能传,且都不拷贝
std::string name = "Bob";
print(name); // 传左值
print("literal"); // 传临时量

右值引用 T&&#

右值引用 T&&(C++11 引入)正好相反:只绑右值(纯右值和将亡值),不绑左值。

int&& r = 42; // OK:绑右值
// int&& bad = x; // 错误:x 是左值

它的意义是给函数一个信号:形参写成 T&&,就表示”这是个临时的、之后没人再用的对象,我可以放心把它搬空”。移动语义正是建立在这个信号上。

常量右值引用 const T&&#

常量右值引用 const T&& 也只绑右值、不绑左值,这点和 T&& 一样;差别在于它把被引用的对象看作只读。既然不能改,也就没法”搬空”它——所以 const T&& 几乎给不了移动的好处(一个 const 对象本来也无法被移动)。它和 T&& 的分工是:T&& 只绑非 const 右值,const T&& 连 const 右值也能绑。

对同一个右值实参,几种引用的偏好次序是:T&& 最优、其次 const T&&、最后 const T&T& 根本绑不上右值)。既然 const T&& 搬不动东西,它的用途很窄,最典型的是把它 = delete 掉来禁止函数接收右值实参——它对右值的匹配优先于 const T&,删掉它就能精准拦下所有右值、同时放行左值:

void use(const std::string& s); // 左值、临时量本来都能接
void use(const std::string&&) = delete; // 删掉右值重载:它比上面更匹配右值 → 传临时量编译报错

标准库的 std::ref / std::cref 就是用这个办法(删掉 const T&& 重载)防止你把引用包到一个临时量上、用的时候悬垂。

一个陷阱:具名的右值引用是左值#

最后一个坑最反直觉:具名的右值引用本身是左值

void sink(std::string&& s) { // s 绑定到一个右值
std::vector<std::string> v;
v.push_back(s); // 拷贝!s 作为表达式是左值
v.push_back(std::move(s)); // 移动:显式转回右值
}

道理是:s 有名字、有稳定地址,你能对它取地址,所以它是左值。“它的类型是右值引用”和”它作为表达式是左值”是两回事。想再把它当右值用,需要 std::move 转一下。

std::move:把左值转成右值#

T&& 只接右值,可你手上常常是个具名左值——一个你确定不再需要、很想交出去让它被“搬走”的对象。std::move 就是这座桥:把一个左值转成右值,好让它绑上 T&&、被重载决议当成可搬走的对象来处理。

它的实现比这个用途朴素得多——本质上只有一句强制类型转换。简化后的标准库定义是:

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

它是一个纯编译期的转换:不新建对象、不拷贝、也不修改 t 指向的对象,编译后不产生任何运行期代码。它改变的只是这个表达式的值类别——把重载决议看到的东西从左值变成右值(准确说是 xvalue),仅此而已。

参数:为什么左值、右值都能接#

形参写成 T&&,而 T待推导的模板参数——这让它成为转发引用(forwarding reference),而不是普通右值引用。普通右值引用(比如具体类型的 std::string&&)只接右值;转发引用则借助模板推导加引用折叠,接住一切:

传入实参T 推导为形参 T&& 折叠成
类型为 U 的左值U&U&(左值引用)
类型为 U 的右值UU&&(右值引用)

引用折叠的规则是“沾上左值引用就塌成左值引用”:U& &U& &&U&& & 都折叠成 U&,只有 U&& && 折叠成 U&&。所以传左值时形参是 U&、传右值时是 U&&,两者都能绑上——std::move 因此对实参来者不拒

顺带一提:无论 t 的类型是 U& 还是 U&&t 本身是个有名字的参数,作为表达式永远是左值(正是前面那条“具名右值引用本身是左值”)。所以函数体里必须再 static_cast 一次,把它转回右值——std::move 自己就踩在它要替你解决的那个坑上。

返回类型:remove_reference_t 是关键#

返回类型为什么不直接写 T&&,而要先 remove_reference_t<T> 再加 &&?因为直接写 T&& 会被引用折叠毁掉:传左值时 T 推导成 U&T&& 就是 U& &&,折叠成 U&——一个左值引用。那样函数会返回左值,static_cast 等于白转。

先用 remove_reference_t<T>T 里的引用剥掉得到 U,再加 &&,返回类型就恒为 U&&——无论传进来的是左值还是右值,std::move 的结果永远是右值引用类型的 xvalue。这就是它能“无条件转成右值”的全部秘密。

为什么能传右值,传了又会怎样#

既然形参是转发引用、又无条件转成右值,那“std::move 把左值转成右值”其实是个不完整的说法:它根本不检查实参是左值还是右值,“把左值转成右值”只是它唯一有用的场景。它机械做的事是“把任何实参转成右值”——对一个本来就是右值的实参,这个转换是空转。

所以 std::move(42)std::move(foo()) 合法但多余,有时还有害:

int&& r1 = 42; // OK:临时量直接绑到引用,生命周期延长到 r1
int&& r2 = std::move(42); // 悬垂:临时量在 std::move 内部产生,
// 引用穿过函数返回后不再延长它的生命周期

它不保证真的移动#

std::move 只改值类别,能不能真的移动,由重载决议和类型说了算。最常见的意外是对 const 对象用它:

const std::string a = "hi";
std::string b = std::move(a); // 看着像移动,其实是拷贝

std::move(a) 的类型是 const std::string&&const 不会被去掉)。移动构造的形参是非 const 的 std::string&&,接不住 const 右值;能接住它的反而是拷贝构造的 const std::string&——于是重载决议选了拷贝。同理,一个类型如果压根没写移动构造,std::move 之后也照样退回拷贝。

std::move 给不了“一定移动”的保证,它只是把话递到重载决议面前。

经验法则:只对“确实不再需要、且非 const 的具名左值”用 std::move;对右值、对 const 对象用它,轻则无用,重则悄悄变成拷贝或制造悬垂。

移动语义:区分右值到底为了什么#

区分左值 / 右值的现实动机,就是移动语义。考虑一个持有堆内存的类,比如 std::vectorstd::string,或自己写的 Buffer

深拷贝的浪费#

  • 源对象是个还要继续用的左值时,复制它必须深拷贝:另开一块内存、逐个复制元素,得到两份独立的数据。
  • 但源对象要是个右值——一个马上就要析构的临时量——再深拷贝就是浪费:反正它下一刻就没了,不如直接把它的内部指针”偷”过来,让新对象接管那块内存,再把源对象的指针置空。这就是移动。

拷贝与移动的区别

移动只改了几个指针,不复制底层数据,因此对持有大量堆资源的对象快得多。

移动构造是怎么来的,怎么写#

移动构造函数(连同移动赋值)是 C++11 才加进标准的新成员函数,和右值引用是一起引入的——正因为有了 T&& 这种”只绑右值”的引用,编译器才第一次有办法区分”实参是个还要用的左值”和”用完即弃的右值”,进而给后者选一条更快的路径。C++11 之前一个类只有拷贝构造 T(const T&),源对象是不是临时的都只能深拷贝。

C++ 靠重载让编译器自动选版本:拷贝构造收 const T&,移动构造收 T&&

class Buffer {
int* data_;
size_t size_;
public:
Buffer(const Buffer& other); // 拷贝构造:深拷贝
Buffer(Buffer&& other) noexcept; // 移动构造:偷指针
};

实参是右值时,重载决议优先选 Buffer&& 版本(移动);是左值时选 const Buffer& 版本(拷贝)。移动构造的典型写法就是”偷指针 + 源置空”:

Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) { // 接管对方的内存
other.data_ = nullptr; // 源置空,避免两个对象析构同一块内存
other.size_ = 0;
}

那个 noexcept 不是可有可无的:像 std::vector 扩容搬元素时,只有在移动构造保证不抛异常的前提下,容器才敢用移动来搬,否则会退回用拷贝以保证异常安全。所以移动操作习惯上都标 noexcept

被移动后的对象#

被移动后的对象处于”有效但未指定”的状态:还能安全析构、还能被重新赋值,但不应假设它的具体值。标准库类型(如 std::string)被移动后通常变空,但标准只保证”有效”,不保证”为空”。

std::move 把一个具名左值转成右值,就能主动触发这套移动:

std::string a = "hello";
std::string b = std::move(a); // std::move 把 a 转成右值 → 触发移动构造
// 此后 a 处于有效但未指定的状态,别再依赖它的值

类的设计:Rule of Five 与 Rule of Zero#

Rule of Five(五法则)#

一旦一个类自己管理资源(裸指针、文件句柄、锁等),就要考虑五个特殊成员函数。把它们摆在一起,形式上的差别一目了然:

class Buffer {
public:
~Buffer(); // 析构函数
Buffer(const Buffer& other); // 拷贝构造:收 const 左值引用
Buffer(Buffer&& other) noexcept; // 移动构造:收右值引用
Buffer& operator=(const Buffer& other); // 拷贝赋值:收 const 左值引用
Buffer& operator=(Buffer&& other) noexcept; // 移动赋值:收右值引用
};

对比它们的形式:

  • 拷贝的两个收 const T&(只读源对象),移动的两个收 T&&(要把源对象搬空,所以不能带 const),移动操作还习惯标 noexcept
  • 两个构造函数没有返回类型;两个赋值运算符返回 T&(返回自身的引用),好支持 a = b = c 这样的连写。
  • 析构函数只有一个,不区分调用它的对象是左值还是右值。

Rule of Five 是说:这五个里只要手写了任意一个,往往意味着五个都得一起考虑,否则编译器生成的默认版本可能做错事——比如默认拷贝是浅拷贝,会让两个对象析构同一块内存。

Rule of Zero(零法则)#

与之相对的是 Rule of Zero:如果类的每个成员都自己管好自己的资源(用 std::vectorstd::stringstd::unique_ptr 等 RAII 类型做成员),这个类就不需要写任何特殊成员函数,编译器自动生成的拷贝 / 移动都正确。实践中优先追求零法则。

这里有个联动规则要注意:一旦你声明了拷贝构造 / 拷贝赋值 / 析构中的任何一个,编译器就不会再自动生成移动操作。这样的类只能拷贝、不能移动,可能悄悄损失性能。所以要么全都不写(零法则),要么该写的都写全。

引用限定符#

成员函数还能按”调用它的那个对象”是左值还是右值来重载,这叫引用限定符(ref-qualifier):

class Widget {
std::vector<int> data_;
public:
// 在左值对象上调用:返回内部数据的只读引用,不拷贝
const std::vector<int>& data() const & {
return data_;
}
// 在右值对象上调用:对象反正要没了,直接把成员搬出来
std::vector<int> data() && {
return std::move(data_);
}
};

Widget 是个临时量(右值)时,w.data() 选中 && 版本,直接把内部的 data_ 移动出来,省掉一次拷贝;对左值对象则安全地返回引用。

什么时候用哪个#

函数形参

  • 只读、不保存:小而廉价的类型(int、指针、小 struct)按值传;大对象用 const T&,一个签名接住左值和右值、且无拷贝。
  • 需要把实参”存下来”(sink 形参):常见两种写法。
    • 按值传 + std::movevoid set(std::string s) { name_ = std::move(s); }。调用方传左值时拷贝一次、传右值时移动一次,一个签名兼顾两种情况,代码简单,多数场景首选。
    • 分别重载 const T&T&& 两个版本:性能上限略高(左值不多一次移动),代价是要写两份。
  • 模板里做完美转发:形参写成 T&&T 为待推导的模板参数)时它是转发引用(forwarding reference),既能接左值也能接右值,配合 std::forward<T>(arg) 把实参原样(保持左右值属性)传给下一层。注意只有”待推导的模板参数 + &&”才是转发引用;具体类型的 std::string&& 就是普通右值引用。

返回值

  • 按值返回,把优化交给编译器的复制消除(copy elision)。返回一个纯右值临时量(return Buffer(...);)在 C++17 起保证被消除,不产生任何拷贝或移动。
  • 返回具名局部变量(NRVO):编译器允许但不保证消除;即使没消除,返回语句里的局部变量也会被当作右值处理,触发移动而非拷贝。
  • 不要为了”优化”而返回指向局部变量的 T&&const T&——局部变量返回后即销毁,那是悬垂引用。也不要对返回的局部变量写 return std::move(x);,这会抑制 NRVO,通常反而更慢。

怎么判断一个表达式的类别:一个实用技巧是 decltypedecltype((expr))(注意双层括号)对左值给出 T&、对将亡值给出 T&&、对纯右值给出 T。更直观的办法是看能不能取地址、能不能绑到非常量左值引用:能的基本是左值。

小结#

  • 左值 / 右值是表达式的属性,由”有没有身份”和”能不能移动”两个维度决定,细分为 lvalue、xvalue、prvalue 三类,以及它们的并集 glvalue 与 rvalue。
  • 有身份的左值对应可寻址的具名对象;纯右值是没有地址的抽象值,需要时才物化成临时对象;将亡值是有址但即将消亡、资源可被复用的对象。
  • 引用把值类别接住:T& 只接左值,const T& 左右值都接,T&& 只接右值并开启移动。
  • 区分右值的意义在于移动语义——对反正要消亡的对象偷资源而非深拷贝。
  • std::move 不搬任何东西,只是把实参无条件转成右值的类型转换:参数是转发引用、左右值都能接,所以对本来就是右值的东西用它是空转;只有用在”确实不再需要的具名左值”上才有意义,真正的移动发生在移动构造 / 移动赋值里。
  • 类设计上优先 Rule of Zero,必要时按 Rule of Five 写全;形参按”只读用 const T&、要存下用按值 + move、模板用转发引用”来选。
C++左值和右值
https://blog.gzher.com/posts/cpp-lvalue-rvalue/
作者
中会 / Claude Opus 4.8
发布于
2026-07-05
许可协议
CC BY-NC-SA 4.0