Skip to content

难度攀升的一集:C++的值类别 ​

值类别的区分我认为是一座小山峰,各位,准备好攀登咯!

我们已经跟变量、类型、const 打过不少交道了。但你有没有想过一个问题:为什么有些表达式能放在赋值号左边,有些就只能放在右边?为什么 int& ref = x; 编译得过,int& ref = 42; 就编译不过?不思考这些问题,咱们是很容易在后面的旅途中掉坑的!

值类别直接决定了编译器怎么处理你写的每一个表达式,哪些操作合法、哪些不合法,引用能绑定到什么上面,函数返回值怎么传递……可以说,不理解值类别,后面学引用、学移动语义、学完美转发的时候,你会一直处于"知道怎么写但不知道为什么"的状态。所以我们在这一章把它搞清楚,虽然不会讲得特别深(C++11 之后值类别的分类其实挺复杂的),但至少要把最核心的左值和右值搞明白,把引用的基本用法踩实。

什么是左值?原来是有名字的存储位置 ​

左值(lvalue)这个术语来自"C 的可放在赋值号左侧的值"这个历史定义,虽然这个说法并不完全准确,但它确实给出了一个不错的直觉。用更现代的话来说,左值是一个有名字、有确定内存地址的表达式——你可以对它取地址(用 & 运算符),它的生命周期不会在当前表达式结束时立刻终止。

你可以把左值想象成一个贴了标签的储物箱:箱子有自己的位置(内存地址,不管是栈上,还是堆上),标签让你随时能找到它(变量名),你往里面放东西、从里面拿东西都行。换而言之,当程序在跑的时候,您可以指出来一块内存,说:“呐,找我要x吗?去那看!”这种话。

最典型的左值就是普通变量。int x = 10; 中,x 是一个左值——它有名字 x,有内存地址 &x,在它的作用域结束之前一直存在。类似的,解引用后的指针也是左值,*ptr 表示"ptr 指向的那块内存",那块内存有地址、有名字(通过 *ptr 访问),所以是左值。数组元素也一样,arr[3] 指的是数组中第四个位置的内存,当然是左值。

来看几个具体的例子:

C++
int x = 10;       // x 是左值,栈上一块小空间拿来放了10
int* ptr = &x;    // ptr 是左值,&x 取出了 x 的地址存放到了 ptr 这个内存小个格子了,你可以在内存上找到这个ptr。
*ptr = 20;        // 看看ptr指向啥?哦,他存储的是x的地址,我去看看x在哪,找到了,上面int x的时候编译器丢到栈的一个地方上,涂掉10改成20
int arr[5] = {};
arr[2] = 42;      // arr[2] 是左值——它代表数组第三个位置的内存

这些表达式都有一个共同特征:你可以对它们取地址。&x、&(*ptr)、&(arr[2]) 都是合法的操作。这其实就是判断一个表达式是不是左值最实用的方法——如果你能对它取地址并且它有一个名字,那它基本就是左值。

别把"左值"和"能放在赋值号左边"画等号:const int cx = 10; 里 cx 是左值,但 cx = 20; 编译不过,const 左值不能被赋值。左值描述的是"有身份"(有内存地址),不是"能修改"。

什么是右值——短暂的临时存在 ​

右值(rvalue)和左值正好相反:它是一个没有持久身份的表达式,通常是临时产生的值,你没法对它取地址,它在表达式求值完之后就可能消失。从这个事情上看,您可以理解为立即数一样的东西。用完就丢,你压根找不到这玩意最后在哪。

最典型的右值是字面量。42 是个右值——它是整数 42,但"42"没有内存地址(至少在你的代码层面没有),你写不出 &42。表达式 x + y 的结果也是右值,编译器计算 x + y 的时候会把结果放到一个临时位置,这个临时位置没有名字,你没法通过一个变量名去引用它。

C++
42                  // 右值——字面量
3.14                // 右值——浮点字面量
x + y               // 右值——算术表达式的临时结果
static_cast<int>(3.14)  // 右值——类型转换产生的临时值

函数返回值的情况要特别注意:如果函数返回的是值(不是引用),比如 int get_value() { return 42; },那调用 get_value() 得到的结果是右值,从语法层面上,它是函数内部拷贝出来的一个临时值,没有持久身份;但如果写的是 int& get_ref() { return x; },get_ref() 返回的是引用,结果是左值——因为它最终绑定到了一个有身份的变量上。

为什么区分它们——引用绑定的规则 ​

光知道"什么是左值、什么是右值"还不够,关键是要理解这个区分如何影响你实际写的代码。最直接的影响就是引用绑定。

C++ 的引用有几种,我们先从最基础的左值引用说起。左值引用用 T& 表示,它必须绑定到一个左值上——这很合理,因为引用的本质是"别名",你得先有一个真实的、持久的变量,才能给它起别名。

C++
int x = 10;
int& ref = x;     // 没问题:ref 是 x 的别名
ref = 20;          // 现在 x 也变成了 20

但如果你试图让左值引用绑定到一个右值上:

C++
int& ref = 42;    // 编译错误!

编译器会直接拒绝,错误信息大概长这样:

text
error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

原因很直观:42 是个临时值,它的生命周期可能就在这一行代码结束时就没了。如果你用一个引用指向它,等这行代码执行完,引用指向的东西可能已经不存在了——这就是"悬空引用"(dangling reference),一个经典的安全隐患。编译器在这里拦住你,是在帮你规避问题。

不过有一个例外——const 左值引用可以绑定到右值上:

C++
const int& ref = 42;   // 合法!

这看起来有点违反直觉,但 C++ 标准在这里做了一个特殊规定:**当 const 左值引用绑定到右值时,编译器会自动延长这个临时值的生命周期,让它活到引用的作用域结束。**这其实是一个很实用的特性——后面你会经常在函数参数里看到 const std::string& 这种写法,它既能接受左值参数,也能接受右值参数,就是因为这条规则。

个人觉得设计不太好,虽然方便,但是写代码的时候多了一个例外,没关系,我们后续学移动语义的时候,传递右值会匹配到更好的接口上。

引用基础——不是指针,胜似指针 ​

既然提到了引用,我们就把它的基本用法讲清楚。引用在概念上很简单——它是一个已经存在的变量的别名。从你创建它的那一刻起,它就和被引用的变量绑定在一起,对引用做的任何操作都等同于对原变量操作。

好像指针哦!对!一种理解,也是我的理解,就是对指针的二次封装,让你不用写一大堆*了!

C++
int x = 10;
int& ref = x;    // ref 是 x 的别名
ref = 20;        // x 现在是 20
std::cout << x;  // 输出 20

引用有几个重要的特性,需要从一开始就搞对。首先,引用必须在创建时初始化——你不能先声明一个引用,稍后再让它指向某个变量。int& ref; 直接编译不过,编译器会告诉你引用需要初始化。其次,引用一旦绑定就不能更改——不存在"让引用指向另一个变量"的操作。如果你写了 ref = y;,这不是让 ref 重新绑定到 y,而是把 y 的值赋给了 ref 所引用的那个变量。这和指针的行为完全不同,指针可以随时指向不同的地址。

引用最常见的用途是作为函数参数。如果我们按值传递,函数会得到参数的一份拷贝,修改拷贝不影响原始数据;如果按引用传递,函数直接操作原始数据。对于大型对象(比如一个很长的字符串或者一个包含很多元素的容器),按值传递意味着昂贵的拷贝操作,按引用传递则没有任何额外开销。

展开代码收起代码共 23 行
C++
/// @brief 值传递——函数内部修改不影响外部
void add_one_by_value(int n)
{
    n = n + 1;    // 只修改了局部的拷贝
}

/// @brief 引用传递——函数内部直接修改外部变量
void add_one_by_ref(int& n)
{
    n = n + 1;    // 修改了原始变量
}

int main()
{
    int a = 10;
    add_one_by_value(a);
    std::cout << a << "\n";   // 输出 10,没变

    add_one_by_ref(a);
    std::cout << a << "\n";   // 输出 11,变了

    return 0;
}

返回局部变量的引用是新手最容易犯的错误之一:局部变量在函数返回后就销毁了,引用指向的东西已经不存在,访问它就是未定义行为——可能得到垃圾数据,可能崩溃,也可能碰巧看起来正常,但无论哪种都是错的。

C++
int& bad_function()
{
    int local = 42;
    return local;    // 严重错误!local 在函数返回后销毁
}                    // 返回的引用指向已销毁的变量

编译器通常会给出警告,但不会阻止你编译。记住一个简单的原则:永远不要返回局部变量的引用或指针。引用的规则到这里先建立一个能用的最小集;它和指针各管什么场景、怎么选,第 4 章会把两者放在一起系统对比。

右值引用——先混个眼熟(C++11) ​

在 C++11 之前,C++ 只有一种引用——也就是我们刚才说的左值引用。C++11 引入了右值引用,用 T&& 表示,它只能绑定到右值上。

C++
int x = 10;
int& lref = x;        // 左值引用,绑定到左值 x
int&& rref = 42;      // 右值引用,绑定到右值 42
int&& rref2 = x + 1;  // 右值引用,绑定到临时表达式结果

// int&& rref3 = x;   // 编译错误!右值引用不能绑定到左值

你可能要问:右值引用有什么用?为什么要专门搞一种只能绑定临时值的引用?答案是移动语义——它让我们能够"偷走"临时值里的资源,而不是做一份昂贵的拷贝。比如一个包含一百万个元素的容器,当你不再需要原始的那个时,用移动语义可以直接把内部指针接管过来,代价几乎是零。

这里我们先不展开,只需要记住 T&& 这个写法,知道它是给右值准备的引用就够了。移动语义是卷二的重要内容,到时候我们会深入讲解。

动手实验——values.cpp ​

说了这么多理论,我们写一个完整的程序来验证这些规则。这个程序会展示各种表达式是左值还是右值,以及引用绑定的各种情况。

展开代码收起代码共 67 行
C++
// values.cpp -- 值类别与引用绑定演示
// Standard: C++11

#include <iostream>

/// @brief 返回一个整数值(右值)
int get_value()
{
    return 42;
}

/// @brief 返回一个整数的引用(左值)
int global = 100;
int& get_ref()
{
    return global;
}

int main()
{
    // ---- 左值 ----
    int x = 10;            // x 是左值
    int* ptr = &x;         // &x 合法:x 是左值,可以取地址
    *ptr = 20;             // *ptr 是左值
    int arr[3] = {1, 2, 3};
    arr[0] = 99;           // arr[0] 是左值

    std::cout << "x = " << x << "\n";            // 20
    std::cout << "arr[0] = " << arr[0] << "\n";  // 99

    // ---- 右值 ----
    // &42;                  // 错误:不能对右值取地址
    // &(x + 1);             // 错误:x + 1 的结果是右值
    // &get_value();          // 错误:函数返回值是右值

    int sum = x + arr[1];   // x + arr[1] 的结果是右值
    std::cout << "sum = " << sum << "\n";        // 22

    // ---- 左值引用 ----
    int& lref = x;          // OK:左值引用绑定到左值
    lref = 30;
    std::cout << "x = " << x << "\n";            // 30

    // int& bad = 42;        // 错误:左值引用不能绑定到右值

    const int& cref = 42;   // OK:const 引用可以绑定到右值
    std::cout << "cref = " << cref << "\n";      // 42

    // ---- 右值引用(C++11)----
    int&& rref = 42;        // OK:右值引用绑定到右值
    int&& rref2 = x + 1;   // OK:x + 1 是右值
    // int&& rref3 = x;     // 错误:右值引用不能绑定到左值

    std::cout << "rref = " << rref << "\n";      // 42
    std::cout << "rref2 = " << rref2 << "\n";    // 31

    // ---- 函数返回值的值类别 ----
    // get_value() 返回右值
    int val = get_value();
    std::cout << "get_value() = " << val << "\n";   // 42

    // get_ref() 返回左值
    get_ref() = 200;       // OK:get_ref() 返回左值引用,可以赋值
    std::cout << "global = " << global << "\n";      // 200

    return 0;
}

编译运行:

bash
g++ -std=c++11 -Wall -Wextra -o values values.cpp
./values
text
x = 20
arr[0] = 99
sum = 22
x = 30
cref = 42
rref = 42
rref2 = 31
get_value() = 42
global = 200

我们来梳理一下这个程序的几个关键点。被注释掉的行都是会导致编译错误的——你可以尝试把它们取消注释,看看编译器会报什么错,这是理解值类别最快的方式。get_value() 返回 int,调用它得到的是一个右值,所以 &get_value() 不合法。get_ref() 返回 int&,调用它得到的是一个左值引用,可以直接对它赋值——get_ref() = 200; 看起来有点怪,但它确实是在给 global 赋值。

const int& cref = 42; 是一个非常重要的用法。const 左值引用可以绑定到右值上,编译器会自动延长 42 这个临时值的生命周期。这个技巧在函数参数中极为常见——当我们不想拷贝大对象,又不需要修改它的时候,用 const T& 作为参数类型是最好的选择。

动手试试 ​

到这里,我们把左值、右值、左值引用、const 引用、右值引用的概念和它们之间的关系都过了一遍。接下来检验一下学习成果。

练习一:分类判断 ​

判断以下每个表达式是左值还是右值,并说明理由:

  • x(假设 int x = 5;)
  • x + 3
  • "hello"
  • *ptr(假设 int* ptr = &x;)
  • x++(后置递增)
  • ++x(前置递增)

如果你拿不准,可以写一个小程序,试试能不能对它们取地址——能取地址的大概率是左值。其中 x++ 和 ++x 的区别是个经典的陷阱,值得特别想一想。

练习二:预测引用绑定 ​

下面哪些代码能编译通过?哪些会报错?先在脑子里判断,然后实际编译验证。

C++
int a = 10;
int& r1 = a;
int& r2 = 10;
const int& r3 = 10;
int&& r4 = 10;
int&& r5 = a;
const int& r6 = a;

练习三:修复悬空引用 ​

下面这段代码有一个严重的 bug——函数返回了局部变量的引用。找到它并修复:

C++
int& get_max(int a, int b)
{
    int result = (a > b) ? a : b;
    return result;
}

int main()
{
    int& m = get_max(3, 7);
    std::cout << m << "\n";
    return 0;
}

提示:思考一下,这个函数应该返回值还是返回引用?局部变量 result 在函数返回后还存在吗?

参考答案

result 是函数体内的局部变量,函数返回时它的生命周期就结束了。因此,原代码返回 int& 会得到一个悬空引用;调用者随后通过这个引用读取 m 的行为是未定义的。这里 a 和 b 也是按值传入的局部副本,同样不能通过引用返回。

这个函数应按值返回 int。返回时,函数把结果初始化到调用者可接收的返回对象中;随后 result 即使被销毁,也不会影响已经返回的整数。调用者也应使用普通的 int 接收,而不是再声明一个引用:

C++
#include <iostream>

int get_max(int a, int b)
{
    return (a > b) ? a : b;
}

int main()
{
    int m = get_max(3, 7);
    std::cout << m << "\n";
    return 0;
}

编译运行:

bash
g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main

运行结果:

text
7

pdf-latest-4-g85128cc · 85128cc · 2026-10-05