在 C++ 中,引用和指针都可以用来间接访问或修改一个已经存在的变量,但它们的使用规则与适用场景却有很大不同。
平时编写代码或设计函数时,什么时候应该使用引用,什么时候又应该使用指针呢?只要把两者的底层差异放在一起对比一下,判断标准其实非常清晰。
C++ 指针与引用的区别
引用本质上是原变量的别名,而指针是一个独立存放内存地址的变量。两者最明显的共同点,就是都能在不复制原数据的前提下操作目标变量。
虽然都能读写原变量,但它们在初始化要求、空状态、能否更换目标以及内存占用上有着本质区别,如下表所示。
| 对比维度 | 引用(reference) | 指针(pointer) |
|---|---|---|
| 定义时初始化 | 必须初始化并绑定已有变量 | 可指向变量,也可设为 nullptr |
| 能否为空 | 不能为空,必须代表有效变量 | 可以设为 nullptr 空指针 |
| 能否更换目标 | 一旦绑定,中途不能改绑其他变量 | 可随时赋新地址,改为指向其他变量 |
| 访问目标写法 | 直接使用引用名 ref | 需要使用星号解引用 *p |
| 取地址的结果 | &ref 得到原变量的地址 | p 保存原变量地址,&p 是指针自身的地址 |
| sizeof 测量结果 | 原变量类型的大小 | 指针变量自身的大小(64 位下占 8 字节) |
从上表可以看出,访问时的符号差异只是表面现象,更关键的是它们在内存中的存在形式与绑定规则。下面我们通过两个例子来分别验证一下。
示例 1:引用与指针操作同一变量
#include <iostream>
int main() {
int score = 80;
int& ref = score; // 定义引用
int* p = &score; // 定义指针
std::cout << "&score = " << &score << '\n';
std::cout << "&ref = " << &ref << '\n';
std::cout << "p = " << p << '\n';
std::cout << "&p = " << &p << '\n';
ref = 90; // 通过引用修改 score
std::cout << "score = " << score << '\n';
*p = 100; // 通过指针修改 score
std::cout << "score = " << score << '\n';
return 0;
}运行结果如下。
&score = 0x7ffdf3a37cc4
&ref = 0x7ffdf3a37cc4
p = 0x7ffdf3a37cc4
&p = 0x7ffdf3a37cc8
score = 90
score = 100分析:
从运行结果可以看出,&score、&ref 和 p 打印出来的内存地址完全相同,都是 0x7ffdf3a37cc4。而 &p 打印出来的则是另一个独立的地址 0x7ffdf3a37cc8。
这说明引用 ref 只是原变量 score 的别名,本身不会额外开辟新的内存空间。因此,我们对 ref 取地址或使用 sizeof(ref) 测量大小时,拿到的都是 score 自身的地址和大小(4 个字节)。
而指针 p 拥有自己独立的内存空间 &p(64 位下占 8 个字节),只不过它里面保存的内容恰好是 score 的地址。此外,定义时如果暂时还没有明确目标,引用不能悬空不初始化,而指针可以先初始化为空指针 nullptr。
// 错误方式
int& ref;
// 正确方式
int* p = nullptr;另一个小伙伴们容易混淆的地方,是 “能不能中途更换目标”。引用一旦绑定某个变量,就不能再改绑其他变量。而指针可以随时修改自己保存的地址。我们把这两种操作放在同一个例子里对比看看。
示例 2:给引用赋值与修改指针指向
#include <iostream>
int main() {
int a = 10;
int b = 20;
int& ref = a; // ref 绑定变量 a
int* p = &a; // p 指向变量 a
ref = b; // 把 b 的值赋给 a
p = &b; // 将 p 改为指向变量 b
b = 30;
std::cout << "a = " << a << '\n';
std::cout << "ref = " << ref << '\n';
std::cout << "*p = " << *p << '\n';
return 0;
}运行结果如下。
a = 20
ref = 20
*p = 30分析:
在这个例子中,ref = b 和 p = &b 虽然都使用了赋值号,但实际执行的操作完全不同。
因为引用一旦绑定就不能改绑,所以 ref = b 并没有让 ref 改绑到变量 b 身上,而是把 b 当前的值 20 赋给了 a。对于这个例子来说,下面 2 种方式是等价的。
// 方式 1
ref = b;
// 方式 2
a = b;而 p = &b 修改的是指针 p 自身保存的内存地址,将指向的目标从变量 a 换成了变量 b。因此后面执行 b = 30 时,已经指向 b 的 *p 读到了最新的 30,而始终绑定在 a 身上的 ref 读到的依然是 20。
如何选择指针与引用?
弄清楚两者的区别后,大家在平时写代码以及设计函数参数时只需要问自己两个问题:① 目标变量有没有可能不存在?② 中途需不需要更换指向的目标?根据答案,我们可以按照以下 3 条标准来选择:
- 目标必须存在且不换目标:如果函数要求调用者必须传入一个真实有效的变量,并且内部不需要中途更换目标,我们优先使用引用。采用引用传参不仅写法直观,调用时直接传变量名即可,函数内部也省去了检查
nullptr的步骤。 - 目标允许为空:有时某个函数参数是可选的,允许 “有目标变量就处理,传入
nullptr就跳过”。由于引用不能为空,遇到这种场景我们就应该使用指针来实现。 - 中途需要更换指向目标:如果我们在代码中需要先关联变量
a,执行到后面又改为关联变量b,就只能使用指针,因为引用一旦绑定就无法更改目标。
下面我们通过一个例子来对比函数参数中引用与指针的具体用法。
示例 3:函数参数中的引用与指针
#include <iostream>
// 使用引用接收必传目标
void addOne(int& value) {
value++;
}
// 使用指针接收可选目标
void resetIfPresent(int* value) {
if (value != nullptr) {
*value = 0;
}
}
int main() {
int score = 10;
addOne(score);
std::cout << "score = " << score << '\n';
resetIfPresent(nullptr); // 传入空指针
std::cout << "score = " << score << '\n';
resetIfPresent(&score); // 传入变量地址
std::cout << "score = " << score << '\n';
return 0;
}运行结果如下。
score = 11
score = 11
score = 0分析:
addOne(int& value) 要求调用者必须传入一个有效的整型变量来加 1,因此我们将形参定义为引用。调用时直接写 addOne(score),score 的值就从 10 变成了 11。
而 resetIfPresent(int* value) 允许调用者传入 nullptr 表示不提供目标,因此我们将形参定义为指针。
第 1 次调用 resetIfPresent(nullptr) 时,我们传入了空指针,函数检测到 value != nullptr 不成立就会直接跳过,因此 score 保持 11 不变。第 2 次调用传入 &score 后,条件成立,函数执行 *value = 0 将 score 重置为 0。
对于指针与引用的选型,小伙伴们只需要记住这么一句话就可以了:在 C++ 实际开发中,能使用引用就优先使用引用。只有当目标可能为空,或者中途需要更换指向时,才使用指针。
常见问题
1. 既然引用比指针更安全、写起来也更简单,为什么 C++ 不直接取消指针,只保留引用呢?
这主要有以下两方面的原因:
兼容 C 语言与系统接口:C++ 需要兼容 C 语言以及大量操作系统底层接口,而 C 语言里只有指针,没有引用。
满足空状态与灵活指向的需求:引用定义时必须初始化、不能为空且不能改绑。当我们需要表示 “可选参数为空”、计算数组地址偏移,或者在后续章节管理动态内存时,都必须依靠能设为 nullptr 且能随时改指向的指针来实现。
2. 引用在编译器底层是怎么实现的?
在语法层面上,我们可以把引用单纯看作原变量的 “别名”。而在编译器底层,引用通常是借助指针常量来实现的,它的指向一旦确定就固定不变。只不过编译器在背后自动帮我们完成了取地址和解引用的操作,并在编译阶段加上了 “必须初始化且不能改绑” 的语法检查。
