3224 字
16 分钟
C++ 引用:看起来没传地址,为什么能改到原变量?

本文是 C++ 系列 的第三篇。

上一篇: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;
}

这段代码会看到 xref 输出同一个值。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 当前绑定的对象。由于 refa 的别名,所以真正被修改的是 a。执行后 a 会变成 20b 仍然是 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 引用修改对象
}

这段代码的问题在于 nameconst 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 说明这个函数只读数据,不应该修改 timestampranges。读接口写成这样,调用者会更放心。

如果函数确实要修改这帧数据,比如把所有负数测距值修正为 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、结构体数组和传感器数据处理时很常见。

所以我现在更倾向于把引用当成接口说明来读:

  1. const SensorFrame& frame:我要读这帧数据,不复制,也不修改
  2. SensorFrame& frame:我要修改调用者传进来的这帧数据
  3. 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. 小结:引用最重要的是接口意图#

这一篇先抓住几条规则:

  1. 引用是对象的别名,不是新的独立对象
  2. 普通引用必须初始化,不能表达 nullptr
  3. 给引用赋值,就是给它绑定的对象赋值
  4. 引用参数可以让函数直接修改调用者传入的对象
  5. const 引用适合只读访问和避免不必要复制
  6. 普通引用参数通常表示函数会直接操作外部对象
  7. 指针适合表达“可能为空”或“需要改变指向”的场景

理解引用时,我觉得最有用的一句话还是:引用只是另一个名字

只要一直围绕这个模型看代码,int& ref = x;void f(int& x)const std::string& name 这些写法就会清楚很多。等引用和指针的差异稳了,下一步再看 const int&const int*int* const 这类声明时,也不会完全乱成一团。