1. 从命名看本质:数组指针和指针数组一字之差,用途天差地别
很多人学到指针进阶的时候,第一个卡点就是int *p[5]和int (*p)[5]。这两个声明放在一起,初学者几乎都会懵——长得实在太像了。但如果你把 C 语言的声明规则吃透了,这个区分其实不费脑子,关键是记住一条铁律:下标运算符[]的优先级高于解引用运算符*。
int *p[5]里,p先和[5]结合,所以p是一个数组,数组里有 5 个元素,每个元素都是int *类型。这就是指针数组——本质上它是一个数组,只是装的东西是地址。
而int (*p)[5]里,p先和*结合,所以p是一个指针变量,这个指针指向一个含 5 个int元素的一维数组。这就是数组指针——本质上它是一个指针,只是它指向的目标是一个数组。
注意:数组指针这种类型,在不少教材里也被称为"指向数组的指针"。你做项目写代码时不需要背这些别名,但你必须能一眼判断出变量名到底先跟谁结合。
1.1 数组指针的初始化方式
数组指针最常见的初始化方式是取数组的地址:
int a[5] = {1, 2, 3, 4, 5}; int (*p)[5] = &a; // 正确,&a 的类型就是 int(*)[5]这里有一个很多新手容易踩的坑:int (*p)[5] = a;在部分编译环境下能通过,但会伴随类型不兼容的警告,逻辑上这是不对的。因为a作为表达式使用时,它的值是首元素的地址,类型是int *,指向的是单个 int;而p指向的是整个含 5 个 int 的数组。一个指向单元素,一个指向整个数组,两者步长完全不同。
1.2 数组指针的步长:整个数组的长度
数组指针最核心的特征是它的步长,也就是p + 1跨过的字节数等于整个数组的大小,而不是单个元素的大小:
#include <stdio.h> int main(void) { int a[5] = {1, 2, 3, 4, 5}; int (*p)[5] = &a; printf("p = %p\n", p); printf("p+1 = %p\n", p + 1); return 0; }在常见的 32 位 int 环境下,p和p+1的地址差是 20 字节(5 × 4)。你如果写成printf("p+1 = %p\n", (void *)(p + 1));打印出来,俩地址差就是 0x14。这个步长设计不是随意的,它保证了指针运算和内存布局的一致性——数组指针跳一次,正好跨过一整行。
1.3 数组指针真正的用武之地:处理二维及以上数组
一维数组的场景下,很少有人刻意去定义一个数组指针,因为a[i]已经足够直观了。数组指针真正发力的地方在于二维数组传参和逐行遍历。你后面写矩阵运算、图像处理、神经网络算子的代码时,经常会面对一个float(*)[out_channels]类型的形参,不认识数组指针,那些代码你根本读不懂。
2. 二维数组传参的本质:形参为什么必须带列数
二维数组传参这个问题,面试几乎必考,写代码也几乎必踩。核心问题就一句话:当int arr[3][4]作为实参传给函数时,函数形参到底该怎么写?
先说结论:下面这几种写法在编译器眼里是完全等价的:
void func(int arr[][4], int rows); // 写法一 void func(int (*arr)[4], int rows); // 写法二为什么int arr[][4]和int (*arr)[4]等价?因为数组作为函数参数时会退化(decay)为指向其首元素的指针。二维数组arr[3][4]的首元素是arr[0],也就是第一行,它是一个长度为 4 的一维数组。所以arr退化为指向"长度为 4 的 int 数组"的指针,即int (*)[4]。
2.1 为什么形参不能省略列数
经常有人写void func(int arr[][], int rows),然后编译报错。原因非常简单:函数里真正做下标运算时,编译器必须通过列数计算偏移量。
比如访问arr[i][j],编译器实际换算成的是:
*(*(arr + i) + j)而arr + i需要知道arr指向的类型大小,也就是4 * sizeof(int)。你告诉编译器"这有一行数组,但是一行几个元素?不知道",编译器直接摆烂。
这就好比你告诉别人"沿着这条街往前走 3 个门牌号",却不说每个门牌号间隔多少米——完全没法定位。
2.2 用数组指针在函数内遍历二维数组
一旦明白了形参退化的本质,函数内部怎么遍历就顺理成章了。来看完整代码:
#include <stdio.h> void print_matrix(int (*arr)[4], int rows) { int i, j; for (i = 0; i < rows; i++) { for (j = 0; j < 4; j++) { printf("%d ", arr[i][j]); } printf("\n"); } } int main(void) { int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(matrix, 3); return 0; }注意两点:第一,rows必须传,因为函数内部不知道这个二维数组有几行;第二,列数 4 在形参里写死了,如果你要处理不同列数的二维数组,要么把列数也作为参数传进去并用一维指针模拟,要么用变长数组(VLA),即 C99 支持但 C11 之后变为条件特性的int (*arr)[cols]形式。项目里到底用哪种,取决于你对标准兼容性的要求。
2.3 为什么不能把二维数组直接传给 int**
这是 C 语言指针进阶最经典的坑,却到现在都还有人在跳。
void func(int **arr) { ... } int matrix[3][4] = {0}; func(matrix); // 编译警告或错误原因在于类型不匹配。matrix退化为int (*)[4],而形参是int **,这两者是完全不同的类型。用int **去接收二维数组名,函数内部如果用arr[i][j]访问,编译器会按"指向指针的指针"来做解引用,这在运行时几乎必然导致非法内存访问或崩溃。
万幸的是,现代编译器会给出明确的警告。怕的是有些人视而不见,或者用强制类型转换硬压下去。正经项目里遇到这种情况,正确的处理路径只有两条:要么把形参改成int (*)[N],要么自己把二维数组手动"拉平"成一维数组传首地址,然后在函数里手动算偏移。
2.4 连续内存布局下的一维模拟方式
如果你确实需要写一个通用函数来处理"任意行列数的二维数组",更灵活的做法是把数组名强转为int *,同时把行列数都传进去:
void func(int *base, int rows, int cols) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { printf("%d ", base[i * cols + j]); } } } int matrix[3][4] = {0}; func(&matrix[0][0], 3, 4); // 或 (int *)matrix这种方法利用了二维数组在内存中连续存储的特性,代码通用性高。代价是写法不如arr[i][j]直观,运算量也大了一丁点。实际工程里做图像处理、矩阵库时,这种"手动计算偏移"的方式非常常见。
3. 函数指针:函数名就是一个地址
函数指针,说白了就是把函数当作数据进行传递。C 语言里函数不是一等公民,你没法在函数里面直接返回另一个函数,也没法把函数作为参数直接传,但你可以传一个指针——这个指针的值就是函数的入口地址。有了这层间接性,回调机制、多态雏形、表驱动这些高级玩法都能实现了。
3.1 函数指针的声明到底怎么读
函数指针声明和数组指针一样,容易在优先级上翻车。
int (*fp)(int, int); // fp 是指针,指向一个返回 int、参数为两个 int 的函数 int *fp(int, int); // fp 是函数,返回 int*第二个声明里,fp先和(int, int)结合,说明它是一个函数,返回类型是int *。所以当你要造一个函数指针时,必须用括号把*fp括起来,强行让fp先和*结合。
读更复杂的声明有个实用技巧:从变量名开始,先向右看,遇到)再向左看。比如int (*p[4])(int),从p向右看到[4],说明 p 是数组;向左看到*,说明数组元素是指针;再向右看到(int),说明这些指针指向带一个 int 参数、返回 int 的函数。合起来就是"4 个函数指针组成的数组"。
3.2 函数名和 &函数名
老生常谈的问题:fp = add;和fp = &add;有区别吗?
在绝大多数场合,二者完全等价。函数名的值就是函数入口地址,&add也得到同一个地址。不像数组名还有"首元素地址"和"整个数组地址"之分,函数名简单得多。唯一的模糊地带出现在sizeof上——C 语言标准没有强制规定sizeof(function_name)的行为,有些编译器允许并返回 1,有些直接报错。所以别去对一个函数名做sizeof,这属于自找麻烦。
用函数指针调用函数时,(*fp)(1, 2)和fp(1, 2)也等价。早年教材偏爱写(*fp)(1, 2),为了让读者意识到fp是一个指针;现代 C 代码里直接写fp(1, 2)更常见,看着干净。两种风格在团队里统一就行,功能上没有任何差异。
3.3 typedef 给函数指针起别名
复杂的函数指针声明读起来费劲、写起来更费劲,所以实际开发中几乎都会用typedef简化。
看一个常见做法:
typedef int (*operation_t)(int, int); int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } operation_t fp = add; // 比 int (*fp)(int,int) = add; 清爽很多这里的operation_t是一个类型别名,表示"指向返回 int、接收两个 int 参数的函数的指针"。写接口头文件时,这种 typedef 能让函数签名极其清晰,你的同事接手代码时也能一眼看出参数是什么类型。
还有一种和函数指针绑定的经典场景是qsort的比较函数:
#include <stdio.h> #include <stdlib.h> int cmp_int(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); } int main(void) { int arr[] = {9, 3, 7, 1, 5}; qsort(arr, 5, sizeof(int), cmp_int); return 0; }qsort的第四个参数要求的就是一个int (*)(const void *, const void *)类型的函数指针。你写的比较函数cmp_int满足这个签名,就可以直接传进去。
3.4 函数指针的实际应用:从回调到策略
函数指针最大的价值是解耦。比如你写一个数据采集模块,采集完数据后可能需要打印、写文件、发网络。你不用在采集模块里写死这些处理逻辑,只需要留一个回调函数指针,由上层决定收到数据后干什么。
typedef void (*data_handler_t)(const char *data, size_t len); void start_collecting(data_handler_t handler) { /* 模拟采集循环 */ char buf[64] = "sensor data"; handler(buf, sizeof(buf)); }上层代码传入不同的 handler,采集模块完全不用修改。这种设计模式在 RTOS、嵌入式协议栈、GUI 框架里无处不在。你理解了函数指针,读那些框架源码时会顺畅得多。
4. 函数指针数组与转移表:让代码从 switch 走向表驱动
如果单个函数指针是你的工具箱里的一把刀,那函数指针数组就是把一堆刀挂在一个刀架上——你只管按序号取用。
函数指针数组的声明看起来非常劝退,但拆开看很简单:
int (*ops[4])(int, int);从ops开始:向右看到[4],说明ops是一个含 4 个元素的数组;向左看到*,说明数组元素是指针;再向右看到(int, int),说明这些指针指向接收两个 int 参数、返回 int 的函数。
这就是一个函数指针数组,一共 4 个槽位,每个槽位可以放一个函数的地址。你可以把它想象成一个"函数抽屉柜"。
4.1 用函数指针数组实现计算器
经典的计算器需求,很多人用 switch 写:
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 divide(int a, int b) { return b != 0 ? a / b : 0; } int calc(int op, int x, int y) { switch (op) { case 0: return add(x, y); case 1: return sub(x, y); case 2: return mul(x, y); case 3: return divide(x, y); default: return 0; } }这个写法问题不大,但一旦操作符多了,case 分支会越来越长,新增一个操作就要改动 switch 分支,代码结构也会变得冗长。
用函数指针数组重构:
int (*ops[4])(int, int) = {add, sub, mul, divide}; int calc(int op, int x, int y) { if (op < 0 || op >= 4) { return 0; // 越界防护,比 switch 更直观 } return ops[op](x, y); }这种把操作序号直接映射到函数地址的表,就是转移表(dispatch table)。它本质上是拿一个数组当跳转表用,把"选择和调用"合为一体。
4.2 从 switch 到转移表:为什么说这是设计思路的转变
switch 当然不算坏代码,但转移表有几个明显的工程优势。
第一,新增操作不需要改动分发逻辑。你要加一个取模运算,只需要定义函数int mod(int, int),然后把它追加到数组里,改一个数组初始化的地方就行。如果操作多了,甚至可以把这张表做成配置文件或注册表,运行时动态注册,完全不动主流程代码。
第二,索引和操作之间的映射关系一目了然。ops[2]是什么?直接从表里看到是mul,去找函数体就行。而 switch 版本里,每个 case 都是一个独立的映射逻辑,读代码时要在多个 case 之间来回扫。
第三,便于做越界处理和扩展。switch 的 default 分支可以处理非法输入,转移表的数组下标也可以做范围检查,二者都能做到,但转移表的检查更简洁,一行 if 就完了。
当然,switch 也有它适用的地方:分支逻辑简单、操作类型不统一(函数签名差异大)、编译器优化已经做得很好时,switch 可读性更好。但当你发现自己在写 8 个长得几乎一样的 case 时,就该考虑转移表了。
4.3 转移表的进阶玩法:命令分发
假设你要写一个串口命令行解析器,接收到的字符串可能是"led on"、"led off"、"motor speed 100"、"help"……如果用一堆strcmp去判断,代码会膨胀得很难看。这时候可以用一张"命令字符串 + 函数指针"的结构体数组:
#include <stdio.h> #include <string.h> void cmd_led_on(void) { printf("LED ON\n"); } void cmd_led_off(void) { printf("LED OFF\n"); } void cmd_help(void) { printf("Available commands...\n"); } struct cmd_entry { const char *name; void (*handler)(void); }; struct cmd_entry commands[] = { {"led_on", cmd_led_on}, {"led_off", cmd_led_off}, {"help", cmd_help}, }; int run_command(const char *cmd) { for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { if (strcmp(cmd, commands[i].name) == 0) { commands[i].handler(); return 0; } } return -1; // command not found }这样每加一条命令,只需要在commands数组里加一行"字符串 + 函数"的映射,不需要再增加任何条件分支。很多嵌入式设备、游戏引擎里的命令系统,就是按这个思路设计的,只是规模更大、加上了参数解析和优先级。
4.4 函数指针数组的边界防护:为什么不能省掉范围检查
转移表本质上是数组,所以它也继承了数组的所有缺点:越界访问没有天生保护。在calc里,如果op传了一个负数或者大于等于操作总数的数,ops[op]就会访问一块毫无意义的内存,然后大概率是段错误,更惨的是可能跳到一个随机地址执行,直接跑飞。
所以用转移表必须在入口处做范围检查。上面代码里已经演示了:
if (op < 0 || op >= 4) return 0;这种检查看起来多写一行,在实际项目里能救你一条命。另一个经验是:别在函数指针数组里留空洞。如果你一共定义了 6 个操作,资料上只写了 5 个,请把第 6 个槽位初始化成一个错误处理函数,比如int invalid_op(int, int) { return -1; },而不是留一个 NULL 指针。这样即使某条代码路径绕过了范围检查,跳进去的也是一个"善意的"兜底函数,而不是空指针崩溃。
5. 指针进阶路上躲不开的五个误区
讲完数组指针、二维数组传参、函数指针、转移表这些主干内容,最后再把指针进阶路上高频出现的几个认知误区集中梳理一遍。这些坑我在带新人、code review 和面试候选人的时候反复遇到,全部都是真实场景里的问题。
5.1 数组名和 & 数组名的区别
int a[5]中,a的值和&a的数值在大多数平台上是一样的(都是数组起始地址),但它们的类型不同,分别退化和属于int *和int (*)[5]。数值相同不代表类型语义相同,a + 1跳过一个 int,&a + 1跳过整个数组。这个问题在字节面试题是热门考点,实际写代码时也容易栽在指针运算的类型不匹配上。
5.2 把二维数组强行传给 int** 的翻车现场
这个前面详细讲过了,这里再强调一个实操点:如果代码里出现了从int (*)[N]到int **的强制类型转换,几乎立刻就能判断是做了某种"权宜之计"。除非你非常清楚底层内存布局,否则这种转换会掩盖问题而不是解决问题。
5.3 const 与指针组合的赋值规则
热词里有人问"顶层指针和底层指针可以相互赋值吗",这其实是 C++ 里const术语的延伸,但 C 语言里同样适用这套概念。简单说:
int * const p:p 本身不可变(顶层 const),但这种指针可以赋给int *或const int *。const int *p:p 指向的目标不可通过 p 修改(底层 const),它不能赋给int *,否则就绕过了 const 约束。
底层 const 的赋值规则一句话总结:权限可以收窄,不能放大。int *可以赋给const int *;反过来,const int *不能赋给int *。违反这条规则编译会有警告或错误,在项目里这就意味着你可能在某处意外修改了不该改的数据。
5.4 函数指针是否一定要用 & 取址
前面说过,函数名和&函数名等价,fp = add;和fp = &add;都可以。但注意fp = add;中add不会触发"数组退化"那样的问题,因为函数类型的衰减规则是单向的、统一的。
然而有一个细节:有些静态代码分析工具、或者 MISRA C 之类的编码规范,会明确要求"取函数地址必须显式写&",理由是"让读者清楚知道这里取的是地址"。这属于风格层面的要求,不是语法规则。如果你在大型团队或安全关键领域写代码,请遵循项目的编码规范。
5.5 一个综合判断小题目
最后放一道自测题,判断自己是否真正掌握了上面这些概念:
#include <stdio.h> int a[3][4] = {0}; int (*p)[4] = a; int *q = &a[0][0]; printf("%d\n", *(*(p + 1) + 2)); // 访问哪个元素? printf("%d\n", *(q + 1 * 4 + 2)); // 访问哪个元素?第一个表达式里,p + 1跳过一行(4 个 int),再解引用得到第二行的地址,+ 2后解引用,访问的就是a[1][2]。第二个表达式用一维方式模拟二维访问,q + 6对应第 7 个元素,也就是a[1][2]。两个表达式结果相同,但一个用的是数组指针,一个用的是普通指针加手动偏移——这正好印证了数组指针步长设计的价值。
我个人的体会是,C 语言指针这块内容,光看书永远觉得懂了,真正上手写两行带()嵌套的声明、调试一次段错误之后,才算把概念焊死在脑子里。别怕崩溃,崩溃本身就是最好的老师。