本文是 C++ 系列 的第三篇。
上一章刚写完指针,我脑子里其实很容易把引用也当成“另一种指针”。
直到看到这种代码:
int x = 10;int& ref = x;
ref = 20;
std::cout << x << std::endl; // 20这段代码最别扭的地方是:看起来我们没有把 x 的地址传给谁,也没有写 *p = 20,但最后 x 还是被改了。
原因不是 ref 把自己的值复制给了 x,而是 ref 本来就是 x 的另一个名字。
这就是 C++ 引用最核心的心智模型:引用是对象的别名。
如果把变量名理解成访问对象的入口,那么引用就是给同一个对象再起一个入口。后面那些“必须初始化”“不能重新绑定”“函数参数能改外部变量”的规则,基本都能从这个模型推出来。
1. 先别把引用想成“自动解引用的指针”
先把完整代码补出来:
#include <iostream>
int main() { int x = 10; int& ref = x;
ref = 20;
std::cout << "x = " << x << '\n'; std::cout << "ref = " << ref << '\n';
return 0;}这段代码会看到 x 和 ref 输出同一个值。int& ref = x; 的意思不是创建一个新的 int,再把 x 复制进去,而是让 ref 成为 x 的别名。之后写 ref,实际访问的还是 x 那个对象。
这里最容易误会的是把引用当成“特殊指针”。从实现角度看,编译器可能用类似指针的方式处理引用,但从语言使用角度看,引用应该先按别名理解。你不需要写 *ref 才能访问对象,因为 ref 本身就表现得像原对象。
再看地址:
#include <iostream>
int main() { int x = 10; int& ref = x;
std::cout << "&x = " << &x << '\n'; std::cout << "&ref = " << &ref << '\n';
return 0;}这段代码通常会输出相同的地址。&ref 取到的不是某个“引用变量自己的地址”,而是它所绑定对象的地址。也就是说,ref 这个名字最终还是指向 x 那个对象。
2. 既然是别名,就必须一开始说清楚是谁
引用的另一个重要规则是:定义时必须绑定到一个已有对象。
int x = 10;int& ref = x; // 正确这段代码是合法的,因为 ref 一开始就绑定到了 x。绑定完成之后,ref 就可以像 x 一样使用。
下面这样不行:
int& ref; // 错误:引用必须初始化这段代码无法通过编译,因为普通引用不能处在“暂时不知道引用谁”的状态。指针可以先写成 nullptr,但引用没有对应的空引用语义。它既然是别名,就必须从一开始就说明是谁的别名。
这个规则对接口设计也很有用。看到一个函数参数是引用时,通常可以先假设调用者必须传入一个真实对象,而不是一个可能为空的东西。
3. ref = b 不是换绑定
引用初始化之后,不能再改成另一个对象的别名。
#include <iostream>
int main() { int a = 10; int b = 20;
int& ref = a; ref = b;
std::cout << "a = " << a << '\n'; std::cout << "b = " << b << '\n';
return 0;}这段代码里,ref = b; 不是让 ref 改去引用 b,而是把 b 的值赋给 ref 当前绑定的对象。由于 ref 是 a 的别名,所以真正被修改的是 a。执行后 a 会变成 20,b 仍然是 20。
这点和指针差别很大:
int a = 10;int b = 20;
int* p = &a;p = &b;这段代码里,p = &b; 是修改指针变量 p 保存的地址,让它从指向 a 改为指向 b。指针可以重新指向别的对象,而普通引用绑定后就不再表达“换个目标”这件事。
所以写引用时要把赋值看清楚:给引用赋值,就是给它绑定的对象赋值。
4. 函数参数里,引用最容易暴露它的意义
引用最常见的使用场景之一,是作为函数参数。
先看普通传值:
#include <iostream>
void changeByValue(int x) { x = 100;}
int main() { int a = 10; changeByValue(a); std::cout << a << '\n';}这段代码输出仍然是 10。因为 changeByValue 拿到的是 a 的一份副本,函数内部修改的是副本,不会影响外面的 a。
再看引用参数:
#include <iostream>
void changeByReference(int& x) { x = 100;}
int main() { int a = 10; changeByReference(a); std::cout << a << '\n';}这段代码输出 100。函数参数 x 是调用者传入对象的别名,所以函数内部给 x 赋值,其实就是在修改外面的 a。
这就是引用参数最直接的作用:不复制对象,而是让函数直接操作调用者传进来的对象。
如果用指针也能做到类似效果:
void changeByPointer(int* p) { if (p == nullptr) { return; }
*p = 100;}这段代码通过指针修改外部对象,但接口语义和引用不一样。指针参数意味着调用者可能传入空指针,所以函数内部通常要检查 nullptr。引用参数则更像是在说:这个函数需要一个真实存在的对象,并且会直接操作它。
5. const 引用:我只看,不改,也不复制
如果函数只需要读取参数,不希望修改它,可以使用 const 引用。
#include <iostream>#include <string>
void printName(const std::string& name) { std::cout << name << '\n';}这段代码里,name 是外部字符串对象的别名,但因为前面有 const,所以函数内部不能通过 name 修改原对象。这样既避免了复制字符串,又表达了“我只读取,不修改”的意图。
如果试图修改,会编译失败:
void printName(const std::string& name) { name = "new name"; // 错误:不能通过 const 引用修改对象}这段代码的问题在于 name 是 const std::string&。它允许读取字符串内容,但不允许重新赋值或调用会修改对象的成员函数。
对初学阶段来说,可以先记住一个简单规则:普通引用适合表达“函数可能修改这个对象”,const 引用适合表达“函数需要读取这个对象,但不应该复制,也不应该修改”。
6. 工程里的引用:只读、修改和避免复制
前面一直用 int 举例,是为了把语义讲干净。但真实代码里,引用更常见的价值不是少写一个 *,而是把函数接口的意图写清楚:这个参数只是读取,还是要被修改,需不需要避免一次不必要的复制。
假设项目里有一帧传感器数据:
#include <iostream>#include <vector>
struct SensorFrame { double timestamp; std::vector<float> ranges;};这段代码里的 SensorFrame 不再是一个很小的 int。它里面有时间戳,也有一组距离数据。std::vector<float> 可能保存很多元素,如果每次传参都复制一份,代码能跑,但成本不一定合理。
如果函数只想打印这帧数据的基本信息,可以用 const 引用:
void printFrameInfo(const SensorFrame& frame) { std::cout << "time: " << frame.timestamp << '\n'; std::cout << "range count: " << frame.ranges.size() << '\n';}这段代码表达了两个意思:第一,frame 是外部那帧数据的别名,不会复制整个 SensorFrame;第二,const 说明这个函数只读数据,不应该修改 timestamp 或 ranges。读接口写成这样,调用者会更放心。
如果函数确实要修改这帧数据,比如把所有负数测距值修正为 0,那就应该用普通引用:
void clampInvalidRanges(SensorFrame& frame) { for (float& range : frame.ranges) { if (range < 0.0f) { range = 0.0f; } }}这段代码里有两层引用。SensorFrame& frame 说明函数会直接修改调用者传进来的那一帧;float& range 说明循环里拿到的是每个元素本身,而不是元素副本。所以 range = 0.0f; 会真的改到 frame.ranges 里的数据。
如果把循环写成传值,就不会修改原数组:
void wrongClamp(SensorFrame& frame) { for (float range : frame.ranges) { if (range < 0.0f) { range = 0.0f; } }}这段代码看起来很像上一个版本,但 float range 是每个元素的副本。函数内部改的是副本,循环结束后 frame.ranges 里的原始数据没有变化。这类问题在写 vector、结构体数组和传感器数据处理时很常见。
所以我现在更倾向于把引用当成接口说明来读:
const SensorFrame& frame:我要读这帧数据,不复制,也不修改SensorFrame& frame:我要修改调用者传进来的这帧数据SensorFrame frame:我要拿一份副本,函数内部怎么改都不影响外面
这个判断比单纯背“引用是别名”更接近工程里的使用方式。别名是底层语义,接口意图是写代码时真正要表达给别人看的东西。
7. 引用和指针:差别不只是写法
指针和引用都能让我们间接访问另一个对象,但它们表达的接口语义不同。
| 对比点 | 引用 | 指针 |
|---|---|---|
| 是否必须初始化 | 必须 | 可以稍后再赋值 |
| 是否可以为空 | 普通引用没有空语义 | 可以是 nullptr |
| 是否可以重新绑定 | 初始化后通常不能换目标 | 可以改为指向别的对象 |
| 访问对象 | 直接写 ref | 需要写 *p |
| 常见接口含义 | 参数必须存在 | 参数可能为空或需要换指向 |
这张表的重点不是背语法,而是理解接口表达。函数参数写成 int&,调用者通常会认为这个对象必须存在,并且函数可能修改它;写成 const int&,通常表示必须存在、只读、避免复制;写成 int*,则要考虑它可能为空,也要考虑函数是否会保存或修改指针指向。
看一个选择上的例子:
void reset(int& value) { value = 0;}
void resetIfExists(int* value) { if (value != nullptr) { *value = 0; }}这段代码里,reset 表示调用者必须传入一个对象,函数会把它清零;resetIfExists 表示参数可以不存在,所以函数内部需要判断。两者都能修改外部对象,但表达的约束不一样。
8. 我会特别小心的几个坑
8.1 把引用赋值当成重新绑定
int a = 1;int b = 2;
int& ref = a;ref = b;这段代码不会让 ref 改成 b 的别名。它只会把 b 的值赋给 a。如果看到引用赋值,第一反应应该是“被绑定的对象要被修改了”,而不是“引用要换目标了”。
8.2 返回局部变量的引用
int& bad() { int x = 10; return x; // 错误:x 离开函数后就不存在了}这段代码的问题和悬空指针类似。x 是函数里的局部变量,函数结束后它的生命周期结束,返回它的引用就会得到一个悬空引用。后面再使用这个引用,结果就是未定义行为。
8.3 误以为引用会自动保护对象
void update(int& value) { value += 1;}这段代码非常短,但它会修改调用者传入的对象。引用让代码看起来比指针更自然,也更少出现 * 和 &,但这不代表它更“安全地不修改”。如果函数不应该改参数,就应该写成 const int&,或者对于很小的类型直接传值。
9. 小结:引用最重要的是接口意图
这一篇先抓住几条规则:
- 引用是对象的别名,不是新的独立对象
- 普通引用必须初始化,不能表达
nullptr - 给引用赋值,就是给它绑定的对象赋值
- 引用参数可以让函数直接修改调用者传入的对象
const引用适合只读访问和避免不必要复制- 普通引用参数通常表示函数会直接操作外部对象
- 指针适合表达“可能为空”或“需要改变指向”的场景
理解引用时,我觉得最有用的一句话还是:引用只是另一个名字。
只要一直围绕这个模型看代码,int& ref = x;、void f(int& x)、const std::string& name 这些写法就会清楚很多。等引用和指针的差异稳了,下一步再看 const int&、const int*、int* const 这类声明时,也不会完全乱成一团。