Skip to content

左值、右值与引用:移动语义的类型系统基石

TIP

本文基于 CppCon 2025 Ben Saks 的 "Back to Basics: Move Semantics" 演讲进行深度二创。上面是 YouTube 链接,国内用户可以直接看 Bilibili 版本。本文实验环境为 Arch Linux WSL,GCC 16.1.1。

上一篇我们用 MyString 的实验证明了:移动语义能把拷贝的堆分配 + memcpy 砍成一次指针赋值,10 万次拼接加速超过 4 倍。结论很振奋,但结尾留了一个关键的悬念——编译器怎么知道什么时候可以安全地"偷"资源?它需要一种语言机制来区分"这个对象还会被用到"和"这个对象马上就要死了"。这个区分机制,就是左值和右值。

说实话,我之前对"左值右值"一直有一种模糊的恐惧感。第一次听到这两个词的时候,脑子里本能的反应就是:"这不就是等号左边和右边吗?"——然后很快就发现事情没这么简单。const int x = 10; 里的 x 是左值,但你不能对它赋值;int&& r = 10; 绑定的明明是个右值,但 r 本身又是个左值……这些看起来互相矛盾的现象,折腾了我好一阵子才彻底搞明白。

K&R 的原始定义:等号的左边与右边

左值和右值这两个术语可以追溯到 C 语言的诞生。K&R 在《The C Programming Language》里引入了这些概念——"L value" 的 "L" 来源于赋值表达式 E1 = E2,其中赋值符号左边(Left)的东西必须具有某些特定属性。具体来说,E1 必须是一个能被定位的表达式——编译器必须能确定它在内存中的位置,才能把 E2 的值写进去。

这就是最原始的直觉:左值 = 可以出现在赋值左边的东西

拿最简单的例子来说:

cpp
int n = 1;   // OK: n 是左值,1 是右值
n = 2;       // OK: n 是左值,可以出现在赋值左边
// 1 = n;    // 错误!1 是右值,不能出现在赋值左边

n 是一个命名变量,它在内存中有一个确定的位置,编译器知道它的地址,所以可以往里面写值。而字面量 12——它们就是纯粹的值,编译器不会给它们分配一个你可以写入的内存地址。你没法跟编译器说"请把 n 的值写到数字 1 里面去",因为数字 1 根本没有"里面"这个概念。

这是理解左值和右值的第一个层次。在这个层次上,一切看起来都很好——左值就是"有地址、能赋值"的东西,右值就是"没地址、不能赋值"的东西。

但等一下——你有没有觉得这个定义隐含了一个假设?它假设"能出现在赋值左边"和"有内存地址"是一回事。在 C 语言的最早期,这个假设基本成立。但 C 很快就引入了 const,C++ 又引入了引用、类类型、临时对象……随着语言越来越复杂,这个假设开始站不住脚了。接下来我们就会看到这条裂缝是怎么出现的,以及为什么理解它对移动语义至关重要。

基本分类:字面量与命名变量

在开始修补那些裂缝之前,我们先把最基本的分类搞清楚,因为这些规则从 C 时代到今天都没有变过。

字面量(literals)是右值。 整型字面量 3、浮点字面量 3.14、字符字面量 'a'、枚举常量——它们都是右值。它们没有内存地址(至少从程序员的角度来说没有),你不能对它们赋值,它们只是"值"本身。

命名变量是左值。 int n; 声明了一个变量 n,它在内存中有位置,你既能读它也能写它。关键的一点是:左值可以出现在赋值表达式的任何一边n = 1n 在左边(被写入),m = nn 在右边(被读取)。但 n 在右边时发生了什么?它被读取了——编译器把 n 所在内存位置存储的值取出来。这个"读取"操作有一个正式的名字:左值到右值转换(lvalue-to-rvalue conversion) [1] C++ Standard, [conv.lval] — 左值到右值转换的标准描述

这个转换几乎无处不在,只是我们平时不会意识到。每当你写 int b = a; 的时候,a 是一个左值,但为了把它赋给 b,编译器必须先把 a 存储的值读出来——这一步就是左值到右值转换。理解这个转换的存在很重要,因为它解释了一个微妙的事实:左值和右值不是两种"东西",而是表达式的两种"属性"。同一个变量 a 在不同的上下文中可以表现出左值属性或右值属性。

const 对象:K&R 定义的第一道裂缝

现在问题来了。我们看这段代码:

cpp
const int max = 100;
// max = 200;    // 错误!max 是 const,不能赋值
printf("&max = %p\n", (void*)&max);  // 但 max 有地址!

max 是一个 const 对象。你不能对它赋值——max = 200 是编译错误。按照 K&R 的"左值 = 能出现在赋值左边"的定义,max 不应该是左值。但实际上,max 确实有一个内存地址,你可以取它的指针(&max 是合法的),你可以通过指针读取它的值。

这就是 K&R 定义的裂缝:const 对象是左值,但不可赋值。标准术语管它们叫"不可修改的左值"(non-modifiable lvalue)。

这个区分非常重要,因为它揭示了左值概念的真正核心——有地址,而不是能赋值。一个 const int 对象有地址但不可赋值;一个整型字面量 3 既没有地址也不可赋值。前者是不可修改的左值,后者是右值。区分它们的关键不是"能不能赋值",而是"有没有一个持久的内存位置"。

GCC 16.1.1 的实际运行结果证实了这一点:

text
max = 100
&max = 0x7ffc47a05dc8

&max 打印出了一个合法的栈地址——这个 const 对象实实在在存在于内存中。

这里我们可以做一个对比来加深理解。const int max = 100; 中的 max 是不可修改的左值:它有地址,你不能赋值,但你能取地址、能通过指针读取。而字面量 100 是右值:它没有地址,你也不能赋值。两者的共同点是"不能赋值",但关键的区别在于"有没有持久的内存位置"。这个区别到了类类型和引用绑定的部分会变得非常重要——因为编译器正是根据"有没有持久位置"来决定哪些引用可以绑定到哪些表达式上的。

类类型的右值:可以调用成员函数

左值和右值的区分在类类型上变得更有意思了。考虑一个简单的结构体:

cpp
struct Widget
{
    int value;
    void f()
    {
        // this 指向调用对象的地址
        printf("Widget::f(), value = %d, this = %p\n", value, (void*)this);
    }
};

我们有两种方式可以获得类类型的右值。第一种是函数返回值:一个按值返回 Widget 的函数,其返回值就是一个类右值。第二种是函数式强制转换:Widget(7) 把整数 7 转换成一个 Widget 类型的临时对象,这也是一个类右值。

有趣的地方在于:你可以对类右值调用成员函数

cpp
Widget(7).f();       // OK!在临时 Widget 上调用 f()
make_widget(42).f(); // OK!在函数返回的临时对象上调用 f()

这看起来有点奇怪——右值不是"没有地址"吗?你怎么能在没有地址的东西上调用成员函数?答案是编译器在背后做了一件事:它为这个临时对象在内存中分配了一个位置——标准管这个过程叫临时实体化转换(temporary materialization conversion) [2] C++ Standard, [conv.rval] — 临时实体化转换this 指针就指向那个临时分配的内存位置。

我在 GCC 16.1.1 上跑了一下,结果很有意思:

text
Widget::f(), value = 7, this = 0x7ffc9a466b04
Widget::f(), value = 42, this = 0x7ffc9a466b04

注意看——两次调用的 this 地址完全相同!这是因为编译器做了 NRVO(命名返回值优化),把 make_widget 返回的临时对象直接放在了调用方的栈空间里,而 Widget(7) 的临时对象恰好也被分配在了同一块区域。这些临时对象虽然生命周期短暂,但它们在存活期间确实拥有真实的内存位置。

临时实体化的版本来历,这里要分清两件事

说"右值没有地址"其实不够准确。准确的说法是——右值不需要有地址,它不是一个持久的内存位置。但如果编译器为了实现某个操作(比如调用成员函数、绑定到引用)而临时给它分配了一块内存,那它在那一瞬间是"有地址"的。这个由编译器隐式分配内存的过程就是临时实体化。

关于它的版本来历,得把两件事拆开看:lvalue / xvalue / prvalue 这个值类别三分法确实是 C++11 引入的;但"临时实体化转换(temporary materialization conversion)"作为一个有名字的标准转换,是 C++17 才正式确立的。它和 C++17 的强制拷贝消除(mandatory copy elision,提案 P0135)一起被写进语言规则,核心思想是:prvalue 本身不一定是对象,只有在需要它作为对象时(比如调成员函数、绑定到引用),才"物化"成一个临时对象。C++11 时代这个机制还在酝酿、没有正式命名。所以严格说,上面 Widget(7).f() 里的那次临时实体化,是 C++17 起才有的标准语义——别把它和 C++11 的值类别三分法混成一回事。

WARNING

类右值可以调用成员函数,这个特性是移动语义的基础。移动构造函数和移动赋值运算符本质上是"在即将消亡的临时对象上调用的成员函数"——通过右值引用,我们获得了对这些临时对象的修改权。

左值引用:绑定的第一条规则

现在我们进入引用的世界。在 C++11 引入右值引用之前,C++ 里说的"引用"就是今天我们正式称为"左值引用"(lvalue reference)的东西。

"到 T 的左值引用必须绑定到一个 T 类型的左值"——这句话听起来很绕,但意思很简单。int& 类型的引用只能绑定到 int 类型的左值上:

cpp
int n = 10;
int& ri = n;       // OK: ri 绑定到左值 n
// int& ri2 = 10;  // 错误!不能把左值引用绑定到右值(字面量)

int& ri = 10 为什么是错误的?因为 10 是一个右值,它没有持久的内存位置。引用需要知道自己引用的是什么东西的地址,但右值没有地址——这就矛盾了。

但这里有个非常重要的例外:const 左值引用可以绑定到右值

cpp
const int& cri = 10;    // OK!const 引用可以绑定到右值
const int& cri2 = 3.14; // OK!甚至可以绑定到不同类型(double -> int 转换)

这背后的机制是:编译器悄悄地创建了一个临时的 int 对象来存储那个值(或转换后的值),然后让 const 引用绑定到这个临时对象上。对于 const int& cri2 = 3.14;,编译器先做了 doubleint 的转换(3.14 变成 3),创建一个临时 int 存着 3,然后 cri2 绑定到这个临时对象。这就是为什么我在 GCC 的输出里看到 const lvalue ref to converted: 3——3.14 被截断了。

你可能会问:为什么必须是 const?因为如果允许非 const 引用绑定到右值,你就可以通过这个引用去修改一个临时对象——而那个临时对象可能马上就被销毁了,修改它毫无意义,反而容易引发 bug。const 引用绑定了临时对象,你只能读它,不能改它,所以是安全的。

这条规则还有一个重要的推论:const 引用延长了临时对象的生命周期。正常情况下,Widget(7).f() 里的临时对象在语句结束后就被销毁了。但如果有一个 const 引用绑定了它,这个临时对象的生命周期会被延长到跟引用一样长。

举个具体的例子来说明这有多重要。假设你写了一个返回 std::string 的函数,然后用 const 引用来接收它:

cpp
std::string get_name() { return "hello"; }

const std::string& name = get_name();
// name 在这里仍然有效!临时对象的生命周期被延长了
printf("%s\n", name.c_str());  // 安全

如果没有 const 引用的生命周期延长规则,get_name() 返回的临时 std::string 在语句结束后就会被销毁,name 就会变成一个悬垂引用。但因为 const std::string& 绑定了这个临时对象,编译器保证临时对象至少活到 name 离开作用域的时候。

不过这里有个微妙的坑——只有"第一个"直接绑定到临时对象的引用才能延长它的生命周期,通过引用链间接绑定的不行。比如 const std::string& r2 = name;r2 绑定到 name(一个左值),不涉及临时对象,所以没有生命周期延长的问题。但如果涉及多层间接绑定临时对象的情况,就要小心了。我们在 vol2 的 右值引用:从拷贝到移动 里有更详细的讨论。

WARNING

注意:右值引用 T&& 同样具有延长临时对象生命周期的效果。std::string&& r = get_name(); 也会让返回的临时对象活到 r 离开作用域。这是右值引用和 const 左值引用的一个共同点——它们都能绑定到临时对象并延长其生命周期。区别在于,右值引用允许你修改这个临时对象,而 const 左值引用不允许。

右值引用:为移动语义而生

C++11 引入了一种新的引用类型——右值引用,用双 && 语法表示。

cpp
int&& ri = 10;     // OK: 右值引用绑定到右值(字面量 10)
// int&& ri2 = n;  // 错误!右值引用不能绑定到左值

右值引用的绑定规则正好和左值引用"反过来":int&& 只能绑定到 int 类型的右值上。int&& ri2 = n 是编译错误,因为 n 是左值。

WARNING

即使是 const int&& 也只能绑定到右值——给右值引用加 const 不会让它突然能绑定到左值。这一点经常被搞混。const 右值引用在实践中几乎见不到,标准库几乎没有使用它的场景,但它确实存在。

右值引用到底有什么用?关键在这一点:通过右值引用,我们可以修改临时对象

cpp
int&& ri = 10;  // 编译器为字面量 10 创建一个临时 int 对象
ri = 20;        // OK!我们修改了这个临时对象

对于 int 这种简单类型,这没什么实际意义。但当我们讨论类类型的时候——想象一下 MyString&&,它绑定到一个临时的 MyString 对象上,而那个临时对象内部有一块动态分配的字符数组。通过这个右值引用,我们可以直接把那块数组的指针"偷"过来,把临时对象的指针设为 nullptr,然后让临时对象析构时什么都不做。

这正是移动构造函数和移动赋值运算符的签名所表达的:它们通过右值引用接收参数,告诉编译器"我知道这是一个临时对象,我可以安全地偷它的资源"。但这部分是下一篇的内容了,我们先继续把引用体系搞完整。

你可能还会问一个更根本的问题:为什么 C++11 要引入一种全新的引用类型来做这件事?为什么不直接复用左值引用?答案是:如果移动构造函数的签名是 MyString(MyString& s),那它就跟拷贝构造函数 MyString(const MyString& s) 形成了歧义——不对,实际上不会歧义,因为 const 不同。但真正的问题是:如果有一个函数既接受 MyString& 又接受 const MyString&,编译器在看到 s1 + s2(一个右值)时,找不到匹配的非 const 左值引用来绑定它,所以仍然无法触发"移动"。右值引用填补了这个空缺:它专门用来绑定到右值,跟左值引用的绑定规则互不重叠,这样重载解析就能自动区分"这是一个持久的对象(拷贝它)"和"这是一个临时对象(偷它的资源)"。

C++11 的值类别体系:lvalue、xvalue、prvalue

到目前为止我一直在说"左值"和"右值"这两个类别,好像整个世界非黑即白。但实际上,C++11 为了支持移动语义,把值类别(value category)的体系从二元扩展成了三元。

在 C++11 之前,每个表达式要么是 lvalue 要么是 rvalue——就这么简单。但 C++11 引入了第三种类别:xvalue(expiring value,将亡值)。xvalue 表示"这个对象即将消亡,它的资源可以被移走"。

新的分类体系是这样的。首先,所有表达式按"具有身份"(identity,可以确定内存位置)和"可以被移动"两个维度来划分:

类别具有身份可以被移动示例
lvalue命名变量 n*p++i
xvaluestd::move(n) 的结果
prvalue字面量 42Widget(7)、函数返回的临时对象

然后还有两个组合概念:glvalue(generalized lvalue)= lvalue + xvalue,rvalue = xvalue + prvalue。用一张图来表示:

text
            表达式
           /      \
      glvalue    rvalue
      /     \    /    \
  lvalue   xvalue   prvalue
  • lvalue:有身份,不能被移动——普通的命名变量。
  • xvalue:有身份,可以被移动——std::move(x) 的返回值。它有名字(或者说有确定的内存位置),但编译器被告知"你可以移走它的资源"。
  • prvalue(pure rvalue):没有身份,可以被移动——纯粹的临时值,比如字面量和函数返回的临时对象。

这个体系看起来比二元分类复杂了不少,但它的设计逻辑很清晰:移动语义需要一种机制来表达"这个东西的资源可以被偷走",而 xvalue 就是那个桥梁。std::move 本质上做的事情就是把一个 lvalue 转换成 xvalue,告诉编译器"这个对象虽然还有名字,但你可以移走它的资源"。

常见表达式的值类别

光看定义可能还是有点抽象,我们把日常写代码时最常见的表达式列出来,标明它们分别属于哪个类别:

表达式值类别原因
n(命名变量)lvalue有名字,有确定的内存位置
*p(解引用)lvalue指针指向的对象有内存位置
++i(前置自增)lvalue返回修改后的 i 本身
i++(后置自增)prvalue返回的是旧值的副本,是一个临时值
42(整型字面量)prvalue没有内存位置的纯值
"hello"(字符串字面量)lvalue字符串字面量是 const char 数组,有地址
Widget(7)(函数式转换)prvalue创建一个临时的 Widget 对象
make_widget()(按值返回)prvalue函数返回的临时值
std::move(n)xvalue把 lvalue 显式转为"可移动"状态
a.m(成员访问,a 是 lvalue)lvalue跟随 a 的身份属性
std::move(a).m(成员访问,a 是 xvalue)xvalue跟随 a 的 xvalue 属性

有几个值得特别留意的点。字符串字面量 "hello" 是左值,这个经常让人意外——它实际上是 const char[6] 类型的数组,存储在程序的只读数据段,有确定的地址,所以是 lvalue。后置 ++ 返回的是旧值的副本(一个临时值),所以是 prvalue;而前置 ++ 返回的是修改后的对象本身,所以是 lvalue。成员访问表达式 a.m 的值类别跟 a 的值类别保持一致——如果 a 是 lvalue,a.m 就是 lvalue;如果 a 是 xvalue,a.m 就是 xvalue。

用编译器验证值类别

理论说了一堆,我们用 decltype 和类型特征来实际验证一下。decltype 有一个很有用的特性:如果作用于一个带括号的变量名 decltype((x)),它会根据表达式的值类别给出不同的类型——lvalue 给出 T&,xvalue 给出 T&&,prvalue 给出 T

展开代码 (共 28 行)收起代码
cpp
#include <type_traits>
#include <utility>
#include <cstdio>

template<typename T>
void print_category()
{
    printf("  is lvalue ref: %s\n",
           std::is_lvalue_reference_v<T> ? "yes" : "no");
    printf("  is rvalue ref: %s\n",
           std::is_rvalue_reference_v<T> ? "yes" : "no");
}

int main()
{
    int n = 10;

    printf("decltype((n)):\n");          // n 是 lvalue
    print_category<decltype((n))>();     // int& → lvalue ref: yes

    printf("decltype(10):\n");           // 10 是 prvalue
    print_category<decltype(10)>();      // int → 都不是引用

    printf("decltype(std::move(n)):\n"); // std::move(n) 是 xvalue
    print_category<decltype(std::move(n))>(); // int&& → rvalue ref: yes

    return 0;
}

GCC 16.1.1 的输出完美印证了理论:

text
decltype((n)):
  is lvalue ref: yes
  is rvalue ref: no
decltype(10):
  is lvalue ref: no
  is rvalue ref: no
decltype(std::move(n)):
  is lvalue ref: no
  is rvalue ref: yes

decltype((n)) 得到 int&,因为 (n) 是一个 lvalue 表达式。decltype(10) 得到 int(裸类型),因为 10 是 prvalue。decltype(std::move(n)) 得到 int&&,因为 std::move 的返回值是 xvalue,而 xvalue 在 decltype 中表现为 T&&

"有名字就是左值"——右值引用参数的陷阱

到这里我们该聊一个几乎所有 C++ 新手都会踩的坑了。Ben Saks 在演讲中特别强调了这条规则:如果一个东西有名字,那它就是一个左值

考虑一个接收右值引用的函数:

cpp
void process(MyString&& s)
{
    // 在这里,s 是左值还是右值?
}

从函数外部来看,你调用 process(s1 + s2) 时,s1 + s2 是一个右值,所以这个调用没问题——右值引用可以绑定到右值。但在函数内部,参数 s 有名字。它是一个命名对象。根据"有名字就是左值"的规则,在函数体内,s 被当作左值处理

这意味着什么?如果你在函数体内想再次从 s 移动资源,你不能直接移动——编译器会把 s 当作左值,选择拷贝而不是移动。你必须显式地使用 std::move(s) 来告诉编译器"我知道我在做什么,请把它当作右值"。

cpp
void process(MyString&& s)
{
    MyString copy(s);            // 拷贝!因为 s 在这里是左值
    MyString moved(std::move(s)); // 移动!std::move 把 s 转为右值
}

这个规则背后的逻辑其实很合理:函数体可能有很多行代码,s 在第一行被移动之后可能还会在第十行被使用。编译器不能假设"你只在最后一行使用它",所以它选择保守策略——有名字的东西就不自动移动,你必须显式授权。

TIP

这个"名字 = 左值"的规则可以用 decltype 来验证。如果你在一个函数模板里写 decltype((s)),当 s 的声明类型是 MyString&& 时,decltype((s)) 仍然会给出 MyString&(左值引用),而不是 MyString&&。因为带括号的 decltype 看的是表达式的值类别,而 s 作为一个命名对象,其值类别是 lvalue。这一点经常被用来在面试题里挖坑。

TIP

这条"有名字就是左值"的规则有一个重要例外:return 语句return s; 中的 s 虽然有名字,但在 C++11 起它被视为"隐式可移动的"(implicitly movable entity),编译器可以直接对它做移动而不需要你写 std::move(s)。而且实际上编译器可能做得更好——通过 NRVO 直接消除拷贝。关于这个话题的完整讨论,我们留到下一篇。

引用绑定规则速查表

我们把这一篇涉及到的所有引用绑定规则整理成一张表,方便查阅:

引用类型能绑定到 lvalue?能绑定到 rvalue?能绑定到不同类型?能修改被引用对象?
T&
const T&是(带转换)
T&&
const T&&

这张表的信息量不小,但有几个关键结论值得特别记住。第一,const T& 是"万能接收器"——它能绑定到几乎任何东西(lvalue、rvalue、甚至不同类型),代价是你不能通过它修改被引用的对象。第二,T&& 只绑定到右值,这正是移动语义需要的:它保证了绑定到的一定是一个"可以安全偷取资源"的对象。第三,const T&& 虽然存在,但几乎没什么用——它既能绑定到右值又不能修改,这就失去了右值引用"允许修改临时对象"的核心优势。

到这里我们搞清楚了什么

这一篇我们从 K&R 的"等号左边"出发,一步步构建了 C++ 值类别的完整图景。我们看到了 const 对象如何打破"左值 = 可赋值"的旧定义,看到了类右值如何通过临时实体化获得内存位置,看到了左值引用和右值引用截然不同的绑定规则,最后在 C++11 的 lvalue/xvalue/prvalue 三元体系中找到了移动语义的理论基础。

核心的收获是两条:第一,右值引用 T&& 只绑定到右值,这给编译器提供了一个天然的信号——"绑定到的东西是临时的,可以安全地偷走它的资源"。第二,"有名字就是左值"的规则意味着我们有时需要 std::move 来显式地告诉编译器"请允许移动"。

回头看,左值和右值的区分并不是 C++11 凭空发明的——它从 C 语言时代就存在,只是当时简单得多。C++ 引入了 const、类类型、引用、运算符重载这些特性,每一步都让值类别的边界变得更加模糊,直到移动语义需要一个精确的机制来区分"持久的"和"临时的"对象,C++11 才终于把这套体系正式化成了 lvalue/xvalue/prvalue 的三级分类。理解了这套体系的演进逻辑,后面学 std::move、移动构造函数、完美转发这些概念就会顺畅很多——因为它们的设计都是在响应同一个问题:"编译器怎么知道这个对象能不能被安全地移动?"

有了这些理论基础,下一篇我们就可以进入实战了——为 MyString 实现移动构造函数和移动赋值运算符,看看 std::move 到底是怎么工作的,以及拷贝消除(copy elision)在什么条件下可以让我们连移动都不需要。

如果你想要一个更系统化的右值引用讲解,vol2 的 右值引用:从拷贝到移动 是很好的补充材料。

参考文献
1
ISO/IEC 14882:2020C++ Standard, [conv.lval] — Lvalue-to-rvalue conversion2020, 左值到右值转换的标准描述
2
ISO/IEC 14882:2020C++ Standard, [conv.rval] — Temporary materialization conversion2020, 临时实体化转换的标准描述
4
cppreference.comValue categories
5
cppreference.comReference declaration
6
Brian W. Kernighan, Dennis M. RitchieThe C Programming Language, 2nd Edition1988, Lvalue 的原始定义

v0.7.1-2-g3718060 · 3718060 · 2026-07-06