值类别简介
到这一章为止,我们已经跟变量、类型、const 打过不少交道了。但你有没有想过一个问题:为什么有些表达式能放在赋值号左边,有些就只能放在右边?为什么 int& ref = x; 编译得过,int& ref = 42; 就编译不过?这些看似零散的现象,背后其实有一条统一的线索——值类别(value category)。
值类别听起来像个学术名词,但它直接决定了编译器怎么处理你写的每一个表达式,哪些操作合法、哪些不合法,引用能绑定到什么上面,函数返回值怎么传递……可以说,不理解值类别,后面学引用、学移动语义、学完美转发的时候,你会一直处于"知道怎么写但不知道为什么"的状态。所以我们在这一章把它搞清楚,虽然不会讲得特别深(C++11 之后值类别的分类其实挺复杂的),但至少要把最核心的左值和右值搞明白,把引用的基本用法踩实。
什么是左值——有名字的存储位置
左值(lvalue)这个术语来自"C 的可放在赋值号左侧的值"这个历史定义,虽然这个说法并不完全准确,但它确实给出了一个不错的直觉。用更现代的话来说,左值是一个有名字、有确定内存地址的表达式——你可以对它取地址(用 & 运算符),它的生命周期不会在当前表达式结束时立刻终止。
你可以把左值想象成一个贴了标签的储物箱:箱子有自己的位置(内存地址),标签让你随时能找到它(变量名),你往里面放东西、从里面拿东西都行。
最典型的左值就是普通变量。int x = 10; 中,x 是一个左值——它有名字 x,有内存地址 &x,在它的作用域结束之前一直存在。类似的,解引用后的指针也是左值,*ptr 表示"ptr 指向的那块内存",那块内存有地址、有名字(通过 *ptr 访问),所以是左值。数组元素也一样,arr[3] 指的是数组中第四个位置的内存,当然是左值。
来看几个具体的例子:
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 的时候会把结果放到一个临时位置,这个临时位置没有名字,你没法通过一个变量名去引用它。
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& 表示,它必须绑定到一个左值上——这很合理,因为引用的本质是"别名",你得先有一个真实的、持久的变量,才能给它起别名。
int x = 10;
int& ref = x; // 没问题:ref 是 x 的别名
ref = 20; // 现在 x 也变成了 20但如果你试图让左值引用绑定到一个右值上:
int& ref = 42; // 编译错误!编译器会直接拒绝,错误信息大概长这样:
error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'原因很直观:42 是个临时值,它的生命周期可能就在这一行代码结束时就没了。如果你用一个引用指向它,等这行代码执行完,引用指向的东西可能已经不存在了——这就是"悬空引用"(dangling reference),一个经典的安全隐患。编译器在这里拦住你,是在帮你规避问题。
不过有一个例外——const 左值引用可以绑定到右值上:
const int& ref = 42; // 合法!这看起来有点违反直觉,但 C++ 标准在这里做了一个特殊规定:当 const 左值引用绑定到右值时,编译器会自动延长这个临时值的生命周期,让它活到引用的作用域结束。这其实是一个很实用的特性——后面你会经常在函数参数里看到 const std::string& 这种写法,它既能接受左值参数,也能接受右值参数,就是因为这条规则。
引用基础——不是指针,胜似指针
既然提到了引用,我们就把它的基本用法讲清楚。引用在概念上很简单——它是一个已经存在的变量的别名。从你创建它的那一刻起,它就和被引用的变量绑定在一起,对引用做的任何操作都等同于对原变量操作。
int x = 10;
int& ref = x; // ref 是 x 的别名
ref = 20; // x 现在是 20
std::cout << x; // 输出 20引用有几个重要的特性,需要从一开始就搞对。首先,引用必须在创建时初始化——你不能先声明一个引用,稍后再让它指向某个变量。int& ref; 直接编译不过,编译器会告诉你引用需要初始化。其次,引用一旦绑定就不能更改——不存在"让引用指向另一个变量"的操作。如果你写了 ref = y;,这不是让 ref 重新绑定到 y,而是把 y 的值赋给了 ref 所引用的那个变量。这和指针的行为完全不同,指针可以随时指向不同的地址。
引用最常见的用途是作为函数参数。如果我们按值传递,函数会得到参数的一份拷贝,修改拷贝不影响原始数据;如果按引用传递,函数直接操作原始数据。对于大型对象(比如一个很长的字符串或者一个包含很多元素的容器),按值传递意味着昂贵的拷贝操作,按引用传递则没有任何额外开销。
/// @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;
}⚠️ 踩坑预警:这是新手最容易犯的错误之一——函数返回了一个局部变量的引用。局部变量在函数返回后就销毁了,引用指向的东西已经不存在了,访问它就是未定义行为,可能得到垃圾数据,可能崩溃,也可能碰巧看起来正常——但无论如何都是错的。
int& bad_function()
{
int local = 42;
return local; // 严重错误!local 在函数返回后销毁
} // 返回的引用指向已销毁的变量编译器通常会给出警告,但不会阻止你编译。记住一个简单的原则:永远不要返回局部变量的引用或指针。
右值引用——先混个眼熟(C++11)
在 C++11 之前,C++ 只有一种引用——也就是我们刚才说的左值引用。C++11 引入了右值引用,用 T&& 表示,它只能绑定到右值上。
int x = 10;
int& lref = x; // 左值引用,绑定到左值 x
int&& rref = 42; // 右值引用,绑定到右值 42
int&& rref2 = x + 1; // 右值引用,绑定到临时表达式结果
// int&& rref3 = x; // 编译错误!右值引用不能绑定到左值你可能要问:右值引用有什么用?为什么要专门搞一种只能绑定临时值的引用?答案是移动语义——它让我们能够"偷走"临时值里的资源,而不是做一份昂贵的拷贝。比如一个包含一百万个元素的容器,当你不再需要原始的那个时,用移动语义可以直接把内部指针接管过来,代价几乎是零。
这里我们先不展开,只需要记住 T&& 这个写法,知道它是给右值准备的引用就够了。移动语义是卷二的重要内容,到时候我们会深入讲解。
动手实验——values.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;
}编译运行:
g++ -std=c++11 -Wall -Wextra -o values values.cpp
./valuesx = 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 的区别是个经典的陷阱,值得特别想一想。
练习二:预测引用绑定
下面哪些代码能编译通过?哪些会报错?先在脑子里判断,然后实际编译验证。
int a = 10;
int& r1 = a;
int& r2 = 10;
const int& r3 = 10;
int&& r4 = 10;
int&& r5 = a;
const int& r6 = a;练习三:修复悬空引用
下面这段代码有一个严重的 bug——函数返回了局部变量的引用。找到它并修复:
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 做判断,用循环做重复,让程序真正"思考"起来。