Skip to content

值类别简介

到这一章为止,我们已经跟变量、类型、const 打过不少交道了。但你有没有想过一个问题:为什么有些表达式能放在赋值号左边,有些就只能放在右边?为什么 int& ref = x; 编译得过,int& ref = 42; 就编译不过?这些看似零散的现象,背后其实有一条统一的线索——值类别(value category)。

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

什么是左值——有名字的存储位置

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

你可以把左值想象成一个贴了标签的储物箱:箱子有自己的位置(内存地址),标签让你随时能找到它(变量名),你往里面放东西、从里面拿东西都行。

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

来看几个具体的例子:

cpp
int x = 10;       // x 是左值
int* ptr = &x;    // ptr 是左值,&x 取出了 x 的地址
*ptr = 20;        // *ptr 是左值——它代表 x 那块内存
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 的时候会把结果放到一个临时位置,这个临时位置没有名字,你没法通过一个变量名去引用它。

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

你没办法对它们取地址:&42 编译不过,&(x + y) 也编译不过。编译器会直接告诉你——这些是临时值,没有地址可取。

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

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

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

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

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

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

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

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

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

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

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

cpp
const int& ref = 42;   // 合法!

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

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

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

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

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

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

cpp
/// @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;
}

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

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

编译器通常会给出警告,但不会阻止你编译。记住一个简单的原则:永远不要返回局部变量的引用或指针

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

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

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

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

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

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

动手实验——values.cpp

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

cpp
// 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 的区别是个经典的陷阱,值得特别想一想。

练习二:预测引用绑定

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

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

练习三:修复悬空引用

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

cpp
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 在函数返回后还存在吗?

小结

这一章我们花了不少篇幅来理解值类别——左值、右值、以及它们与引用的关系。左值是有名字、有地址、生命周期较长的表达式,右值是临时的、没有持久身份的表达式。左值引用 T& 只能绑定左值,const 左值引用 const T& 可以绑定一切,右值引用 T&&(C++11)只能绑定右值。引用必须初始化、不能重新绑定、最常见的陷阱是返回局部变量的引用。

这些知识看起来偏理论,但它们是你理解后续内容的基石。当我们学到移动语义(卷二)的时候,你会发现今天的这些概念会变成决定程序性能的关键因素。不过现在不用着急,先把基础打扎实。

下一章我们进入控制流——学习用 if/else 做判断,用循环做重复,让程序真正"思考"起来。

基于 VitePress 构建