1. 项目概述:指针面试题的“江湖地位”
在C/C++的面试江湖里,指针就像是一把“玄铁重剑”,用得好,能写出高效、灵活、直击内存本质的代码;用不好,轻则程序崩溃,重则内存泄漏,面试官眉头一皱,你的机会可能就少了一半。我见过太多候选人,谈起面向对象、设计模式头头是道,但一碰到指针相关的代码片段分析,就开始眼神飘忽,逻辑混乱。
这八道题,是我从过去十多年面试和被面试的经历中,精挑细选出来的“经典款”。它们不像那些偏门、冷僻的题目,而是直指指针、数组、内存布局这些核心概念的“七寸”。很多题目看似简单,几行代码,但背后考察的是你对内存模型、类型系统、运算符优先级和求值顺序的深刻理解。把这些题吃透,不仅能让你在面试中从容应对,更能从根本上提升你阅读和编写C/C++底层代码的能力。无论你是正在准备校招、社招的求职者,还是希望夯实基础的开发者,这三千字的详解都值得你花时间细细琢磨。
2. 核心概念与内存模型预热
在深入题目之前,我们必须统一“战场”的基本规则。指针面试题之所以容易出错,往往是因为对底层内存布局和语言规则的模糊认识。
2.1 指针的本质与内存布局
指针的本质是一个变量,其值是另一个变量的内存地址。在32位系统上,指针变量本身通常占4字节;在64位系统上,占8字节。但指针所指向的内存区域的大小,取决于指针的类型。这是理解所有指针操作的基础。
例如:
int a = 10; int *p = &a;这里,p是一个指针变量,它存储了整型变量a的地址。*p(解引用)操作会根据p的类型(int*)去访问从该地址开始的sizeof(int)个字节(通常是4字节),并将其解释为一个整数。
内存布局是理解数组、结构体和指针运算的关键。对于数组int arr[5];,它在内存中是连续存储的5个int空间。arr作为数组名,在大多数表达式中会“退化”(decay)为指向数组首元素的指针(即&arr[0]),类型是int*。
2.2 数组名与指针的微妙区别
这是最经典的陷阱之一。虽然数组名在很多情况下可以当作指针使用,但它们并不完全相同。
sizeof运算符:sizeof(arr)返回的是整个数组占用的字节数(5 * sizeof(int)),而sizeof(p)返回的是指针变量本身的大小(4或8字节)。&取地址运算符:&arr得到的是“指向整个数组的指针”,类型是int (*)[5];而&p得到的是“指向指针变量p的指针”,类型是int**。+运算:arr + 1会根据int的大小移动4字节(指向arr[1]);而如果p指向arr,p + 1行为相同。但&arr + 1则会移动整个数组的大小(5 * sizeof(int)字节),直接跳到数组末尾之后。
2.3 运算符优先级与结合性
指针题目经常密集使用*(解引用)、&(取址)、[](下标)、++、--等运算符。记错优先级会导致完全错误的理解。
*和++同级,结合性为从右向左。[]的优先级非常高。- 当
*和++相邻时,如*p++,由于后置++的优先级高于*,但后置++的特点是“先使用原值,再自增”,所以其含义是:先对p进行解引用得到目标值,然后p自增指向下一个位置。等效于*(p++)。
注意:在分析复杂表达式时,最稳妥的方法是不要依赖记忆,而是适当加上括号来明确意图。但在面试中,你必须能准确解析不加括号的表达式。
3. 八道经典指针面试题深度剖析
下面我们进入正题,逐题拆解。我会先给出题目代码,然后分析常见错误理解,最后给出逐步推演的正确分析过程。
3.1 第一题:数组名在sizeof和&下的真面目
#include <stdio.h> int main() { int a[5] = {1, 2, 3, 4, 5}; int *ptr = (int *)(&a + 1); printf("%d, %d\n", *(a + 1), *(ptr - 1)); return 0; }错误理解:有人认为&a + 1就是a + 1,所以ptr指向a[1],那么*(ptr - 1)就是a[0],输出2, 1。
逐步推演:
int a[5];定义了一个包含5个整数的数组。&a的含义是“取数组a的地址”。它的类型不是int*,而是int (*)[5],即“指向长度为5的整型数组的指针”。- 指针运算
&a + 1:根据指针运算规则,&a + 1会在&a的基础上,加上sizeof(a)个字节,也就是5 * sizeof(int)个字节。这会让指针直接跳过整个数组a,指向数组末尾之后的内存位置。 (int *)(&a + 1)将这个int (*)[5]类型的指针强制转换为int*类型。此时ptr的值是数组a最后一个元素a[4]之后那个地址。*(a + 1):a作为数组名,在此处退化为指向首元素a[0]的int*指针。a + 1指向a[1],解引用得到值2。*(ptr - 1):ptr是int*类型,ptr - 1会向前移动一个int的大小,即4字节。这正好指向了数组a的最后一个元素a[4]。解引用得到值5。
最终输出:2, 5
实操心得:这道题完美诠释了“数组名”和“数组地址”的区别。
&a和a的值(地址数值)虽然相同,但类型不同,导致指针运算的步长天差地别。这是指针面试题的必考点。
3.2 第二题:结构体内存对齐与指针运算
#include <stdio.h> struct Test { int num; char *pcName; short sDate; char cha[2]; short sBa[4]; } *p; int main() { p = (struct Test*)0x100000; printf("%p\n", p + 0x1); printf("%p\n", (unsigned long)p + 0x1); printf("%p\n", (unsigned int*)p + 0x1); return 0; }错误理解:直接进行数值计算,认为所有+1都是在地址0x100000上加1。
逐步推演: 此题考察指针运算的步长取决于其指向的数据类型。
- 首先,我们需要知道
struct Test的大小。假设在32位系统下(默认对齐):int num: 4字节char *pcName: 指针,4字节short sDate: 2字节,但为了对齐,其起始地址必须是2的倍数。在pcName(4字节)后,地址偏移是4+4=8,是2的倍数,故sDate紧接存放,占用2字节。此时总大小10字节。char cha[2]: 2个char,共2字节。从偏移10开始存放。总大小12字节。short sBa[4]: 4个short,共8字节。short要求2字节对齐。当前偏移12,是2的倍数,可以存放。总大小变为20字节。- 整个结构体的最大对齐数是
int和指针的4字节。最终大小20字节是4的倍数,满足要求。所以sizeof(struct Test) = 20。
p = (struct Test*)0x100000;将p指向一个虚构的地址。printf("%p\n", p + 0x1);:p是struct Test*类型。p + 1意味着向前移动一个struct Test的大小,即20字节。地址值增加20(十进制),即0x100014(0x100000 + 0x14)。printf("%p\n", (unsigned long)p + 0x1);:这里将指针p强制转换为unsigned long整数。整数之间的加法就是简单的算术加1。所以结果是0x100001。printf("%p\n", (unsigned int*)p + 0x1);:这里将p强制转换为unsigned int*类型。unsigned int通常为4字节。所以(unsigned int*)p + 1会在p的地址值上增加一个unsigned int的大小,即4字节。结果是0x100004。
最终输出(假设32位系统):
0x100014 0x100001 0x100004注意事项:结构体大小因编译器和编译选项(如#pragma pack)而异。面试时如果没给大小,可能需要你根据环境进行推算,并说明你的假设。指针运算永远是“加上
sizeof(所指向类型)个字节”,这是铁律。
3.3 第三题:二维数组与指针的层级关系
#include <stdio.h> int main() { int a[4][2] = {{1, 2}, {3, 4}, {5, 6}, {7, 8}}; int (*p1)[2] = &a[1]; int (*p2)[2] = a; printf("%d\n", (*p1)[1]); printf("%d\n", (*(p2 + 2))[0]); return 0; }错误理解:混淆int (*)[2]和int **,认为p2是二级指针,对p2+2的解引用操作出错。
逐步推演: 理解二维数组的关键在于:二维数组在内存中仍然是连续的一维空间,只是逻辑上分为行和列。int a[4][2]是一个4行2列的数组。它可以被看作一个包含4个元素的数组,每个元素是一个包含2个int的数组(即int [2])。
int (*p1)[2] = &a[1];:a[1]是二维数组的第二行(下标从0开始),它是一个int [2]的一维数组。&a[1]是“取这一行的地址”,类型是int (*)[2],即指向一个含有2个整数的数组的指针。所以p1指向第二行{3, 4}。(*p1)解引用,得到的就是第二行这个数组(int [2]),其行为退化为指向该行首元素a[1][0]的指针。(*p1)[1]等价于a[1][1],值为4。
int (*p2)[2] = a;:数组名a在表达式中退化为指针,但它退化成什么类型?它退化成指向其首元素的指针。a的首元素是a[0],而a[0]是一个int [2]。所以a退化的类型是int (*)[2]。因此这个赋值是合法的,p2指向第一行{1, 2}。p2 + 2:p2是int (*)[2]类型,+2会移动2个int [2]的大小,即2 * 2 * sizeof(int)字节。这使p2+2指向第三行{5, 6}。*(p2 + 2)解引用,得到第三行数组(int [2]),其行为退化为指向a[2][0]的指针。(*(p2 + 2))[0]等价于a[2][0],值为5。
最终输出:4, 5
实操心得:
int (*p)[N]是“数组指针”,指向一个长度为N的数组。它与int *p[N](指针数组)有天壤之别。理解a、a[0]、&a[0][0]、&a这几者的类型差异,是驾驭二维数组指针的关键。
3.4 第四题:指针数组与多重指针的遍历
#include <stdio.h> int main() { char *str[] = {"work", "at", "alibaba"}; char **ps = str; ps++; printf("%s\n", *ps); return 0; }错误理解:认为ps++移动了若干个字符,或者对*ps的理解有误。
逐步推演:
char *str[] = {...};定义了一个指针数组。str是一个数组,包含3个元素,每个元素是一个char*类型的指针。这些指针分别指向存储在常量区的字符串字面量"work"、"at"、"alibaba"的首地址。char **ps = str;:数组名str退化为指向其首元素的指针。首元素str[0]是一个char*,所以指向它的指针类型就是char**。因此ps指向str[0]。ps++;:ps是char**类型,+1操作会使它移动一个char*的大小(在32位下是4字节,64位下是8字节)。于是ps从指向str[0],变为指向str[1]。printf("%s\n", *ps);:*ps对ps进行一次解引用。此时ps指向str[1],所以*ps得到的是str[1]的值,即指向字符串"at"的char*指针。printf的%s格式期望一个char*(指向字符串的起始地址),它会从这个地址开始打印字符,直到遇到\0。
最终输出:at
注意事项:
char **ps是一个指向指针的指针。ps存储的是某个char*变量的地址。*ps得到的是那个char*变量的值(即另一个地址)。**ps才会得到最终的字符。这道题清晰地展示了指针数组与二级指针的配合。
3.5 第五题:多级指针的“俄罗斯套娃”
#include <stdio.h> int main() { char *c[] = {"ENTER", "NEW", "POINT", "FIRST"}; char **cp[] = {c + 3, c + 2, c + 1, c}; char ***cpp = cp; printf("%s\n", **++cpp); printf("%s\n", *--*++cpp + 3); printf("%s\n", *cpp[-2] + 3); printf("%s\n", cpp[-1][-1] + 1); return 0; }错误理解:被复杂的++、--和*运算符组合吓到,顺序分析错误。
逐步推演:这是经典难题,考察运算符优先级、结合性和求值顺序。我们画个关系图来理解初始状态:
c: 指针数组,c[0]->"ENTER",c[1]->"NEW",c[2]->"POINT",c[3]->"FIRST"。cp: 指针数组,元素是char**。cp[0] = c + 3(指向c[3]),cp[1] = c + 2,cp[2] = c + 1,cp[3] = c(指向c[0])。cpp = cp,即cpp指向cp[0]。
第一句printf("%s\n", **++cpp);:
++cpp:前置++,先自增。cpp原指向cp[0],自增后指向cp[1]。表达式++cpp的值是自增后的cpp(即指向cp[1])。*++cpp:解引用。得到cp[1]的值,即c + 2(指向c[2]的指针)。**++cpp:再次解引用。*(c + 2)得到c[2]的值,即指向字符串"POINT"的指针。- 打印
"POINT"。执行此句后,cpp已经指向cp[1]。
第二句printf("%s\n", *--*++cpp + 3);: 运算符优先级:后缀++>*> 前缀++/-->+。结合性:++/--和*从右向左。
++cpp:cpp当前指向cp[1],自增后指向cp[2]。表达式值为指向cp[2]的cpp。*++cpp:解引用,得到cp[2]的值,即c + 1(指向c[1]的指针)。--*++cpp:对c + 1进行前置--。c + 1指向c[1],--操作使其指向c[0]。注意:这修改了cp[2]的值,现在cp[2] = c。*--*++cpp:对现在指向c[0]的指针解引用,得到c[0]的值,即指向"ENTER"的char*。*--*++cpp + 3:指针加法。"ENTER"的地址加3,指向该字符串中第4个字符('E'、'N'、'T'、'E'...),即从'E'开始。- 打印
"ER"(从'E'开始到\0结束)。执行此句后,cpp指向cp[2],且cp[2]的值被改为c。
第三句printf("%s\n", *cpp[-2] + 3);:cpp[-2]等价于*(cpp - 2)。此时cpp指向cp[2]。
cpp - 2:从cp[2]向前移动2个char**单位,指向cp[0]。cpp[-2]即*(cpp - 2),得到cp[0]的值,即c + 3(指向c[3])。*cpp[-2]即*(c + 3),得到c[3]的值,即指向"FIRST"的指针。*cpp[-2] + 3:"FIRST"地址加3,指向第4个字符'S'。- 打印
"ST"。注意:此句没有改变cpp本身的值。
第四句printf("%s\n", cpp[-1][-1] + 1);:cpp[-1][-1]等价于*(*(cpp - 1) - 1)。cpp仍指向cp[2]。
cpp - 1:指向cp[1]。*(cpp - 1):得到cp[1]的值,即c + 2(指向c[2])。*(cpp - 1) - 1:c + 2指向c[2],减1后指向c[1]。*(*(cpp - 1) - 1):得到c[1]的值,即指向"NEW"的指针。cpp[-1][-1] + 1:"NEW"地址加1,指向第2个字符'E'。- 打印
"EW"。
最终输出:
POINT ER ST EW避坑技巧:面对如此复杂的表达式,最好的方法是在草稿纸上画出内存关系图,并逐步、分项计算,注意每一步操作后各指针变量的当前值和指向关系的变化。尤其要警惕像
--*++cpp这种会修改原始数据的操作。
3.6 第六题:函数指针与回调机制
#include <stdio.h> int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int div(int a, int b) { return b != 0 ? a / b : 0; } int main() { int (*pf[4])(int, int) = {add, sub, mul, div}; int x = 10, y = 5; for (int i = 0; i < 4; i++) { printf("result%d = %d\n", i, pf[i](x, y)); } return 0; }错误理解:对函数指针数组的声明和调用语法不熟悉。
逐步推演:
int (*pf[4])(int, int):这是核心。[]的优先级高于*,所以pf先与[4]结合,说明pf是一个大小为4的数组。*pf[4]表示数组的元素是指针。int (*)(int, int)表示这个指针指向一个函数,该函数接受两个int参数并返回int。所以,pf是一个“函数指针数组”,数组的每个元素都是一个指向特定类型函数的指针。{add, sub, mul, div}:初始化这个数组。函数名add等本身就代表函数的地址,因此可以直接赋值给对应的函数指针。- 在循环中,
pf[i](x, y):这是通过函数指针调用函数的语法。它先通过下标i取得数组中的函数指针pf[i],然后使用参数(x, y)调用该指针所指向的函数。这等价于(*pf[i])(x, y),两种写法均可。
最终输出:
result0 = 15 result1 = 5 result2 = 50 result3 = 2实操心得:函数指针是实现回调函数、策略模式等高级技巧的基石。声明函数指针时,记住核心格式
返回类型 (*指针变量名)(参数列表)。使用函数指针数组可以轻松实现“命令表”或“跳转表”,是编写可扩展代码的常用手段。
3.7 第七题:野指针与内存访问违规
#include <stdio.h> #include <stdlib.h> int main() { int *p1 = (int*)malloc(sizeof(int)); *p1 = 100; free(p1); int *p2 = (int*)malloc(sizeof(int)); *p2 = 200; printf("*p1 = %d, *p2 = %d\n", *p1, *p2); return 0; }错误理解:认为free(p1)后,p1指向的内存内容会被自动清零或变得不可访问,输出不确定的值。
逐步推演与风险分析:
malloc分配一块内存,p1指向它,并存入值100。free(p1)释放这块内存。关键点来了:free操作只是告诉内存管理器“这块内存我不用了,你可以回收并分配给其他请求”。它并不会:- 将
p1指针本身置为NULL。 - 清空被释放内存中的数据。
- 阻止程序继续通过
p1访问该内存。 此时,p1成了一个悬垂指针(Dangling Pointer)或野指针,它指向的内存是“无效的”,但里面的数据100可能暂时还在。
- 将
malloc再次分配一块大小相同的内存给p2。内存管理器为了效率,很可能将刚刚释放的那块内存重新分配出去。在许多实现中(尤其是调试模式不那么严格时),第二次malloc返回的地址很可能与第一次相同。- 此时,
p1和p2指向了同一块内存!*p2 = 200将这块内存的值改为了200。 printf语句中*p1试图读取这块已被释放的内存。这是未定义行为(Undefined Behavior, UB)。程序可能崩溃,也可能“正常”运行并输出你看到的结果。在本例的典型运行环境下,它会输出*p1 = 200, *p2 = 200,因为p1和p2巧合地指向了同一地址。
最终输出(一种可能):*p1 = 200, *p2 = 200
严重警告:这是面试官考察你对动态内存管理理解深度的“杀手锏”。访问已释放的内存是严重的编程错误,会导致数据损坏、安全漏洞(Use-After-Free)和不可预知的崩溃。良好习惯是:
free(p)之后,立即p = NULL;。
3.8 第八题:常量指针与指针常量的文字游戏
#include <stdio.h> int main() { const int a = 10; const int *p1 = &a; // 常量指针 int const *p2 = &a; // 同上 int * const p3 = (int*)&a; // 指针常量 const int * const p4 = &a; // 指向常量的指针常量 // 以下哪些操作是合法的? // (1) *p1 = 20; // (2) p1 = NULL; // (3) *p3 = 30; // (4) p3 = NULL; // (5) *p4 = 40; // (6) p4 = NULL; return 0; }错误理解:分不清const在*左边和右边的区别。
逐步推演与规则总结: 理解口诀:const在*左边,修饰的是指向的对象(数据是常量);const在*右边,修饰的是指针本身(指针是常量)。
const int *p1和int const *p2:两者完全等价。const在*左边(或与类型名交换位置),表示p1和p2可以指向不同的地址,但不能通过p1或p2来修改它们所指向的数据。数据是常量。int * const p3:const在*右边,紧挨着p3。表示p3本身是一个常量,一旦初始化指向某个地址后,不能再指向别的地址。但可以通过p3来修改它所指向的数据(前提是数据本身不是常量)。指针是常量。const int * const p4:const在*两边都有。表示p4既不能修改指向的地址,也不能通过p4修改指向的数据。两者都是常量。
现在分析操作:
(1) *p1 = 20;:非法。p1是指向常量的指针,不能通过它修改a。(2) p1 = NULL;:合法。p1本身不是常量,可以改变指向。(3) *p3 = 30;:未定义行为,但语法上可能允许。p3是指针常量,可以通过它修改指向的数据。但这里p3指向的是const int a。我们通过强制转换(int*)&a绕过了编译器的类型检查。修改一个声明为const的变量是未定义行为,程序可能崩溃或修改失败(如果变量被编译器放入只读存储区)。(4) p3 = NULL;:非法。p3是指针常量,初始化后不能再赋值。(5) *p4 = 40;:非法。p4是指向常量的指针常量,不能通过它修改数据。(6) p4 = NULL;:非法。p4本身也是常量,不能改变指向。
注意事项:区分“常量指针”和“指针常量”是阅读和理解他人代码的基础。在函数参数中,使用
const int *可以防止函数内部意外修改外部数据,是一种重要的契约和防护手段。而int * const则较少见,通常用于确保函数内部某个指针不改变指向。
4. 面试实战技巧与避坑指南
经过上面八道题的洗礼,相信你对指针的理解更深了一层。但在真实的面试场景中,如何清晰、准确地表达你的思路,同样至关重要。
4.1 解题时的表述框架
当面试官在白板或在线编辑器上给出指针代码时,不要急于说出答案。遵循以下步骤:
- 复述与确认:“我理解这道题是考察XX知识点(比如数组名退化、指针运算、内存布局等),对吗?”这显示你的沟通能力和对考点的敏感度。
- 画出内存图:这是最有效的方法。在纸上或虚拟白板上,画出变量、数组、指针在内存中的布局,标出地址、类型和值。边画边讲:“这里我们有一个数组
a,它在内存中连续存放...数组名a在这里退化为指针,类型是...” - 分步推演:像我们上面做的那样,一步一步解释每个操作。特别是对于
++、*、&的组合,要明确说出运算符的优先级、结合性和求值顺序。“这里有一个*p++,因为后置++优先级高,所以先取p的当前值用于解引用,然后p再自增...” - 总结输出:最后清晰地给出输出结果,并简要说明原因。
4.2 高频易错点自查清单
在面试前,快速过一遍这个清单,能帮你避开大多数陷阱:
- [ ]数组名退化:除了
sizeof和&操作,数组名在表达式中都会退化为指向其首元素的指针。 - [ ]
&操作符:&数组名得到的是指向整个数组的指针,类型不同,步长不同。 - [ ]指针运算的步长:
指针 + N移动的是N * sizeof(指针所指向类型)个字节。 - [ ]
const的位置:左定值,右定向。const在*左,数据不可改;const在*右,指针不可改。 - [ ]野指针:
free或delete后,指针不会自动置空,访问其指向内存是未定义行为。务必手动置NULL。 - [ ]函数指针声明:记住格式
返回类型 (*指针名)(参数列表)。调用时指针名(参数)或(*指针名)(参数)均可。 - [ ]多级指针解引用:每多一个
*,就多一层间接访问。画图是理清关系的不二法门。 - [ ]结构体指针访问成员:
(*p).field等价于p->field。注意.和->的优先级很高。
4.3 面对“未定义行为”类题目的策略
像第七题那样的题目,面试官可能期望你指出这是“未定义行为”,而不是仅仅给出一个具体答案。你可以这样回答: “这段代码在free(p1)后,p1成为了野指针。后续的*p1访问是未定义行为。在实际运行中,由于内存分配器的行为,p2有可能分配到p1原先的内存,导致输出*p1和*p2都是200。但这完全依赖于编译器和运行时环境,不是可靠的行为。正确的做法是在free(p1)后立即将p1置为NULL,并在使用前检查指针有效性。”
这样的回答,不仅展示了你知道结果,更体现了你对语言底层风险和编程规范的深刻理解,绝对是加分项。
指针是C/C++的灵魂,也是区分平庸与优秀程序员的一道分水岭。把这八道题及其背后的原理彻底消化,形成肌肉记忆,下次面试再遇到指针问题,你就能从容不迫,像一位经验丰富的向导一样,在内存的迷宫中清晰指出每一条路径。