4. 指针与引用的本质辨析
指针和引用是C++面试中区分度最高的基础考点之一。很多候选人能背出“指针是变量,引用是别名”,但一旦追问“引用占不占内存”“指针算术为什么能跨数组元素”“悬垂指针和野指针有什么区别”,回答就开始含糊。这一节我们从内存布局和汇编视角把这两个概念彻底拆开,再落到典型代码题上,让你在面试中能讲出层次。
一、从内存布局看本质差异
先看一段最基础的代码:
int a = 10;
int* p = &a; // p 是一个独立变量,存储 a 的地址
int& r = a; // r 是 a 的别名,不产生新的对象
在内存层面,p 本身占据一块存储空间(64位平台通常8字节),里面存放的是 a 的地址;而 r 在语言层面不占独立存储,对 r 的读写等价于对 a 的读写。但要注意一个常被误解的点:引用在底层实现上通常也是用指针实现的。编译器往往把引用当作“自动解引用的常量指针”来处理,只是这个实现细节对程序员不可见,标准也不强制。
验证方式很直接,看汇编:
int a = 10;
int& r = a;
r = 20;
// 典型汇编(x86-64, -O0):
// movl $10, -4(%rbp) ; a = 10
// leaq -4(%rbp), %rax ; 取 a 的地址
// movq %rax, -8(%rbp) ; 把地址存起来(引用的底层表示)
// movq -8(%rbp), %rax
// movl $20, (%rax) ; 通过地址写入 20
可以看到,引用确实借助了地址,但它没有独立的“可重新赋值”语义。这就引出第一个关键区别:指针可以重新指向,引用一旦绑定不可更改。面试中常问“引用可以重新绑定吗”,答案是初始化后不能,任何对引用的赋值都是对被引用对象的赋值。
二、指针算术与数组退化
指针算术的规则是:p + n 的地址偏移量是 n * sizeof(*p) 字节,而不是 n 字节。这是面试高频陷阱。例如:
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr; // 数组名退化为指向首元素的指针
std::cout << *(p + 2); // 输出 3,偏移 2*4=8 字节
数组退化(array-to-pointer decay)指的是:在大多数表达式中,数组名会隐式转换为指向首元素的指针。但有两个例外——sizeof(arr) 和 &arr。前者得到整个数组大小(20字节),后者得到“指向数组的指针”,类型是 int(*)[5],加1会跳过整个数组。面试题常这样设套:
void f(int arr[]) { std::cout << sizeof(arr); } // 输出指针大小,不是数组大小
这里形参 int arr[] 等价于 int* arr,数组已经退化,sizeof 得到的是指针大小。要传数组大小必须额外传长度,或用模板推导、std::array、std::span。
三、悬垂指针与野指针
这两个概念经常被混用,但严格来说不同:
- 野指针:未初始化的指针,指向不确定的地址。例如
int* p;后直接*p = 5;,行为未定义。 - 悬垂指针:曾经指向有效对象,但对象已销毁,指针仍保留旧地址。例如返回局部变量的地址,或
delete后未置空。
看一个典型悬垂场景:
int* create() {
int local = 42;
return &local; // 危险:返回栈上局部变量地址
}
int* p = create();
// p 是悬垂指针,local 的栈帧已失效
面试中常追问“为什么危险”。原因是函数返回后栈帧被回收,那块内存可能被后续函数调用覆盖,读取 *p 得到的是垃圾值,写入则可能破坏其他数据。正确做法是返回值、返回堆上对象(配合智能指针),或让调用方传入缓冲区。
另一个高频场景是容器操作导致迭代器/指针失效:
std::vector<int> v = {1, 2, 3};
int* p = &v[0];
v.push_back(4); // 可能触发扩容,重新分配内存
// p 现在悬垂,v[0] 的地址可能已改变
判断依据是:push_back 若导致 size() > capacity(),会重新分配并搬移元素,原有指针、引用、迭代器全部失效。面试时能说出“扩容导致失效”比只说“不要用”更有说服力。
四、引用绑定与常见陷阱
引用必须初始化,且不能绑定到临时对象的“非const引用”。但const引用可以延长临时对象生命周期:
const int& r = 42; // 合法,临时对象生命周期延长到 r 作用域结束
int& r2 = 42; // 非法,非常量引用不能绑定临时量
这个规则在函数返回值和范围 for 循环中很关键。例如:
for (const auto& x : getVector()) { ... } // 安全,延长临时 vector 生命周期
for (auto& x : getVector()) { ... } // 非法,无法绑定临时量到非const引用
还有一个经典面试题:引用作为函数返回值。如果返回的是局部变量的引用,同样是悬垂引用,行为未定义。返回引用通常用于返回成员变量、静态变量或传入参数,且要保证生命周期长于调用方使用期。
五、面试速查对照表
| 维度 | 指针 | 引用 |
|---|---|---|
| 是否可为空 | 可以(nullptr) | 不可以,必须绑定有效对象 |
| 是否可重新绑定 | 可以 | 不可以 |
| 是否占独立内存 | 是(存地址) | 语言层面否,底层常以指针实现 |
| 是否支持算术 | 支持(p+n、p++) | 不支持 |
| 多级形式 | 有二级指针 int** | 无“引用的引用” |
| 典型风险 | 野指针、悬垂指针 | 悬垂引用、绑定临时量 |
最后给一个面试自检清单,回答时按这个顺序展开:先讲语义差异(别名 vs 变量),再讲内存与汇编(是否占空间、底层实现),然后讲使用约束(初始化、重绑定、算术),最后落到风险场景(悬垂、野指针、数组退化、迭代器失效)。能把这四层讲清楚,指针与引用这一题基本就稳了。