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 变量),再讲内存与汇编(是否占空间、底层实现),然后讲使用约束(初始化、重绑定、算术),最后落到风险场景(悬垂、野指针、数组退化、迭代器失效)。能把这四层讲清楚,指针与引用这一题基本就稳了。