文章

【信奥业余科普】C++ 的奇妙之旅 | 20:更安全的间接访问——引用的设计动机与实战对比

上一篇文章中,我们深入理解了指针的设计原理——通过存储内存地址,实现函数间的高效数据共享。但我们也看到了指针的另一面:需要手动使用 *& 进行解引用和取址操作,代码中符号密集,容易出错,可读性也会下降。

C++ 的设计者 Bjarne Stroustrup 在设计 C++ 时,为了在保留指针底层能力的同时提供一种更简洁、更安全的替代方案,引入了引用(Reference)。本文将从引用的设计动机出发,讲清它的底层原理、语法规则,以及与指针的核心区别。

本系列文章往期回顾:

第二部分 【C++的奇妙之旅】


一、引用的设计动机:指针好用,但能不能更简单?

回顾上一篇中”通过指针修改外部变量”的代码:

1
2
3
4
5
6
7
8
9
void change_by_pointer(int* p) {
    *p = 0;
}

int main() {
    int data = 100;
    change_by_pointer(&data);  // 调用时必须取址
    return 0;
}

这段代码能正确工作,但存在几个实际使用中的不便:

  1. 调用方必须写 &:每次传参都要手动取址,容易遗忘。
  2. 函数内部必须写 *:每次访问数据都要解引用,代码中 *p 频繁出现,影响可读性。
  3. 指针可以为空(nullptr:函数内部在使用前理论上应该先判空,否则可能引发段错误。
  4. 指针可以被重新指向:函数内部可以让 p 指向其他地方,这增加了出错的可能性。

C++ 的设计者希望提供一种机制,在底层仍然通过地址实现间接访问,但在语法层面让程序员感觉像是在直接操作原始变量,同时消除上述几个风险点。这就是引用的设计初衷。

二、引用的基础语法

声明格式

1
类型名& 引用名 = 目标变量;

其中 & 出现在类型名之后,表示”这是一个引用”。引用必须在声明时就绑定到一个已有的变量上。

1
2
int a = 42;
int& ref = a;  // ref 是 a 的引用(别名)

从这一刻起,refa 就是同一块内存的两个名字。对 ref 的任何读写操作,效果等同于直接操作 a

1
2
3
ref = 99;
std::cout << a << std::endl;    // 输出 99,因为 ref 和 a 是同一块内存
std::cout << ref << std::endl;  // 输出 99

注意: 这里声明中的 & 和上一篇文章中取址符 & 是同一个符号,但含义完全不同:

  • int& ref = a; —— 声明语法,表示 ref 是引用类型。
  • &a —— 取址运算符,获取 a 的内存地址。

编译器根据上下文自动区分这两种用法。

三、引用的底层原理

引用在语法上看起来像是”变量的别名”,但在底层,编译器通常将引用实现为一个隐藏的常量指针

当你写下:

1
2
3
int a = 42;
int& ref = a;
ref = 99;

编译器在底层生成的逻辑大致等价于:

1
2
3
int a = 42;
int* const _ref = &a;  // 隐藏的常量指针,不可改指向
*_ref = 99;            // 自动解引用

关键区别在于:这些 *& 操作由编译器自动插入,程序员在代码中完全不需要手写。这就是引用被称为”语法糖”的原因——它没有引入新的底层能力,而是让已有的指针操作变得更简洁、更不容易出错。

四、引用最核心的应用:函数参数传递

引用最常用的场景就是函数参数传递。我们用与上一篇相同的例子做对比:

指针版本(上一篇):

1
2
3
4
5
6
7
8
9
10
void change_by_pointer(int* p) {
    *p = 0;  // 需要手动解引用
}

int main() {
    int data = 100;
    change_by_pointer(&data);  // 需要手动取址
    std::cout << data << std::endl;  // 输出 0
    return 0;
}

引用版本:

1
2
3
4
5
6
7
8
9
10
void change_by_ref(int& ref) {
    ref = 0;  // 直接赋值,语法上与操作普通变量完全一致
}

int main() {
    int data = 100;
    change_by_ref(data);  // 直接传变量名,无需 &
    std::cout << data << std::endl;  // 输出 0,原始数据已被修改
    return 0;
}

对比两段代码可以看到:

对比项指针传递引用传递
函数参数声明int* pint& ref
函数内操作*p = 0;(需要 * 解引用)ref = 0;(直接使用)
调用方传参change(&data)(需要 & 取址)change(data)(直接传变量)
底层机制传递地址传递地址(编译器自动处理)

效果完全相同,但引用版本的代码明显更简洁。

五、引用与指针的核心区别

虽然引用在底层与指针相似,但在语言规则层面有几条严格的限制,这些限制正是引用”更安全”的来源:

1. 引用必须初始化,不能为空

1
2
3
int& ref;       // 编译错误!引用必须在声明时绑定目标
int* p;         // 合法,但 p 是野指针(危险)
int* p = nullptr; // 合法,p 是空指针

引用不存在”空引用”的概念。只要引用声明成功,它就一定指向一个有效的变量。这从根本上消除了”空指针解引用”这一类常见错误。

2. 引用一旦绑定,不可更改

1
2
3
4
5
int a = 10, b = 20;
int& ref = a;   // ref 绑定到 a
ref = b;        // 注意:这不是让 ref 改指向 b,而是把 b 的值赋给 a!

std::cout << a << std::endl;  // 输出 20(a 的值被修改了)

指针则可以随时改变指向:

1
2
3
int a = 10, b = 20;
int* p = &a;    // p 指向 a
p = &b;         // p 现在改为指向 b

引用的这条”绑定后不可更改”规则,避免了函数内部意外改变引用目标的风险。

3. 不存在”引用的引用”或”指向引用的指针”

在 C++ 的语言规则中,引用不是一个独立的对象。具体表现为:当你对引用使用取址符 &ref 时,得到的不是”引用自身的地址”,而是它所绑定的目标变量的地址。也就是说,你在代码中永远无法获取引用本身存放在哪里——尽管底层编译器可能确实用了一块内存(类似指针)来实现它,但语言层面对此完全透明,不提供任何观察手段。因此:

1
2
3
4
int a = 10;
int& ref = a;
int& ref2 = ref;  // 合法,但 ref2 绑定的是 a,不是 ref 本身
std::cout << &ref2 << " " << &ref << " " << &a << std::endl;  // 三者输出相同的地址

总结对比表

特性指针引用
声明时是否必须初始化
可以为空(null)可以不可以
可以更改目标可以不可以
有独立的内存地址有(指针本身占内存)无(语言层面)
访问目标数据时的语法*p(手动解引用)直接使用变量名
底层实现存储地址通常也是存储地址
适合场景需要空值、需要切换目标、操作动态内存函数传参、避免拷贝

六、常量引用:只读不写的安全保障

在实际编程中,有时我们传递引用只是为了避免拷贝大对象,并不希望函数内部修改原始数据。这时应使用常量引用(const reference)

1
2
3
4
5
6
7
8
9
10
11
// 常量引用:承诺函数内部不会修改 s 的内容
void print_info(const std::string& s) {
    std::cout << s << std::endl;
    // s = "new value";  // 编译错误!const 引用禁止修改
}

int main() {
    std::string name = "Alice";
    print_info(name);  // 高效传递,无拷贝,且保证不被修改
    return 0;
}

const std::string& 可以理解为:传递引用(避免拷贝)+ const(禁止修改)。这是 C++ 中传递大型对象的最佳实践:

  • 小型基础类型(intdouble 等):直接按值传递即可,拷贝开销可以忽略。
  • 大型对象(std::string、数组、结构体等):优先使用 const 类型名&

七、实战练习

练习一:交换两个变量的值

这是理解引用传参最经典的例子。请用引用实现一个交换函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

// 使用引用实现交换
void swap_ref(int& x, int& y) {
    int temp = x;
    x = y;
    y = temp;
}

int main() {
    int a = 3, b = 7;
    std::cout << "交换前:a=" << a << ", b=" << b << std::endl;

    swap_ref(a, b);

    std::cout << "交换后:a=" << a << ", b=" << b << std::endl;
    // 输出:交换前:a=3, b=7
    //       交换后:a=7, b=3
    return 0;
}

思考题: 如果把 swap_ref 的参数改为按值传递 void swap_ref(int x, int y),程序的输出会有什么不同?为什么?

答案: 输出将变为”交换后:a=3, b=7”——交换没有生效。原因是按值传递时,函数接收的 xy 只是 ab 的副本。函数内部确实完成了 xy 的交换,但这只影响副本,函数返回后副本被销毁,ab 的值保持不变。

练习二:用引用简化数组元素的修改

在竞赛中,经常需要对数组元素进行复杂的修改操作。引用可以让代码更清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

int main() {
    int arr[5] = {10, 20, 30, 40, 50};

    // 不使用引用:需要反复写 arr[2]
    arr[2] = arr[2] * 2 + arr[2] / 3;

    // 使用引用:给 arr[2] 起别名,代码更清晰
    int& elem = arr[3];
    elem = elem * 2 + elem / 3;

    for (int i = 0; i < 5; i++) {
        std::cout << arr[i] << " ";
    }
    // 输出:10 20 70 93 50
    return 0;
}

练习三:综合对比——三种传参方式的效果

下面的代码同时展示按值传递、指针传递和引用传递的行为差异,建议自行编译运行并分析输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

void by_value(int n) {
    n = 0;
    std::cout << "by_value 内部: n=" << n << std::endl;
}

void by_pointer(int* p) {
    *p = 0;
    std::cout << "by_pointer 内部: *p=" << *p << std::endl;
}

void by_reference(int& r) {
    r = 0;
    std::cout << "by_reference 内部: r=" << r << std::endl;
}

int main() {
    int a = 100, b = 100, c = 100;

    by_value(a);
    std::cout << "调用后 a=" << a << std::endl << std::endl;

    by_pointer(&b);
    std::cout << "调用后 b=" << b << std::endl << std::endl;

    by_reference(c);
    std::cout << "调用后 c=" << c << std::endl;

    return 0;
}

预期输出:

1
2
3
4
5
6
7
8
by_value 内部: n=0
调用后 a=100

by_pointer 内部: *p=0
调用后 b=0

by_reference 内部: r=0
调用后 c=0
  • a 保持不变:按值传递只修改了副本。
  • bc 都变成 0:指针和引用都实现了对原始数据的修改,效果一致。

八、常见考点总结

  1. 引用必须初始化int& ref; 是编译错误,这是选择题常考点。
  2. 引用绑定后不可更改ref = b 是赋值操作,不是改绑定。
  3. & 的两种身份:声明中的 int& ref 是引用类型标识;表达式中的 &a 是取址运算。注意与上一篇文章中 * 的双重身份类似(声明中是指针标识,表达式中是解引用运算)。
  4. const 引用const int& ref = a 意味着不能通过 ref 修改 a 的值。常用于函数参数,信奥竞赛中高频出现。
  5. 引用 vs 指针的选择:当不需要空值、不需要切换目标时,优先用引用;需要管理动态内存或表示”可选”参数时,使用指针。

结语

引用并没有引入新的底层能力——它能做的事,指针都能做。引用的价值在于:通过语言层面的约束(必须初始化、不可改绑、不可为空),将指针使用中最容易犯错的部分封堵住,同时让代码更简洁易读。

理解了指针和引用这两种间接访问机制后,我们已经具备了进入 C++ 更上层建筑的基础。下一篇文章,让我们踏进现代 C++ 最重要的工具库:STL(标准模板库)与常用容器,看看 C++ 如何用预制的高效组件,帮助我们大幅简化数据处理工作。

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

GESP 学习专题站:GESP WIKI

"luogu-"系列题目可在洛谷题库进行在线评测。

"bcqm-"系列题目可在编程启蒙题库进行在线评测。

欢迎加入Java、C++、Python技术交流QQ群(982860385),大佬免费带队,有问必答

欢迎加入C++ GESP/CSP认证学习QQ频道,考试资源总结汇总

欢迎加入C++ GESP/CSP学习交流QQ群(688906745),考试认证学员交流,互帮互助

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
本文由作者按照 CC BY-NC-SA 4.0 进行授权