指针(二):指针数组与数组指针、多级指针、函数指针、回调函数、void指针
大家好,欢迎来到指针的第二讲。上一章我们聊了指针的基础,说白了就是「指针存地址」这个核心概念。但实际项目中,指针从来不会单独出现——它总是和数组、函数、结构体混在一起用。今天这一讲,我们就把指针的「进阶玩法」一次性讲透。
我个人习惯把指针分为三个层次:第一层是基础指针(指向变量),第二层是指针与数组的纠缠(指针数组、数组指针),第三层是指针指向函数(函数指针、回调)。今天我们就从第二层开始。
核心一句话:指针本身也是一种数据类型,它可以指向数组、指向指针、甚至指向函数。理解这一点,后面的所有内容都是顺理成章的。
一、指针数组 vs 数组指针——别被名字绕晕
这两个概念,我敢说80%的C语言初学者都搞混过。我自己当年也栽过跟头。其实区分方法很简单:看最后两个字。
- 指针数组:最后是「数组」,所以它是一个数组,里面存的是指针。
- 数组指针:最后是「指针」,所以它是一个指针,指向一个数组。
你想想看,中文的修饰语是从前往后读的,「指针数组」就是「指针的数组」,「数组指针」就是「数组的指针」。嗯,就这么简单。
1.1 指针数组
最常见的场景是什么?处理多个字符串。比如你要存一组名字,用二维数组当然可以,但每个名字长度不一样,浪费空间。指针数组就派上用场了:
#include <stdio.h>
int main() {
// 指针数组:每个元素都是 char* 类型
char *names[] = {"Alice", "Bob", "Charlie", "David"};
for (int i = 0; i < 4; i++) {
printf("names[%d] = %s\n", i, names[i]);
}
return 0;
}
这里 names 是一个数组,每个元素是一个 char* 指针,指向字符串常量。我在项目中处理配置文件时,经常用这种结构来存储键值对列表,非常灵活。
1.2 数组指针
数组指针指向的是整个数组,而不是数组的第一个元素。看声明:
int arr[5] = {1, 2, 3, 4, 5};
int (*p)[5] = &arr; // 数组指针,指向长度为5的int数组
注意括号!int (*p)[5] 和 int *p[5] 完全是两码事。没有括号的话,int *p[5] 就是指针数组了。
我的记忆口诀:「括号优先,指针优先;无括号,数组优先。」——看到 *p 被括号包着,它就是指针;否则先结合成数组。
数组指针在二维数组传参时特别有用。我曾经写过一个图像处理函数,需要传入一个固定宽度的像素矩阵,用数组指针做参数,代码清晰很多:
void process_image(int (*pixels)[1024], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 1024; j++) {
pixels[i][j] = pixels[i][j] > 128 ? 255 : 0;
}
}
}
二、多级指针——指针的指针
多级指针,说白了就是「存地址的地址」。最常见的二级指针,在需要修改指针本身的值时使用。
举个例子,你写了一个函数要分配内存,并且要把分配到的地址传回给调用者。这时候一级指针不够用,得用二级指针:
void alloc_memory(int **pp, int size) {
*pp = (int *)malloc(size * sizeof(int));
if (*pp == NULL) {
printf("内存分配失败!\n");
}
}
int main() {
int *p = NULL;
alloc_memory(&p, 10); // 传指针的地址
// 现在 p 指向了堆上分配的内存
free(p);
return 0;
}
我曾经在调试一个链表库时,发现插入函数总是无效。查了半天,发现参数传的是 Node * 而不是 Node **——函数内部修改了指针,但外部根本不知道。嗯,这就是典型的「值传递陷阱」。
注意:三级指针、四级指针虽然语法上合法,但实际项目中极少使用。如果你发现需要三级指针,多半是设计有问题。我见过有人写 char ****p,那代码基本没法维护。
三、函数指针——把函数当变量用
函数指针,就是把函数的入口地址存起来。C语言里,函数名本身就是地址,所以取函数指针不需要 & 符号。
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main() {
// 声明一个函数指针
int (*op)(int, int);
op = add; // 指向 add 函数
printf("add: %d\n", op(10, 5));
op = sub; // 指向 sub 函数
printf("sub: %d\n", op(10, 5));
return 0;
}
函数指针的声明语法有点绕:返回类型 (*指针名)(参数列表)。记住括号不能少,少了就变成返回指针的函数了。
我在做嵌入式驱动开发时,经常用函数指针数组来实现状态机。每个状态对应一个处理函数,状态切换时直接查表调用,比 switch-case 优雅得多。
四、回调函数——把函数当参数传
回调函数,说白了就是「你写一个函数,我帮你调用」。标准库里的 qsort 就是最经典的例子:
#include <stdio.h>
#include <stdlib.h>
// 比较函数,由调用者提供
int compare_int(const void *a, const void *b) {
return *(int *)a - *(int *)b;
}
int main() {
int arr[] = {5, 2, 9, 1, 5, 6};
int n = sizeof(arr) / sizeof(arr[0]);
qsort(arr, n, sizeof(int), compare_int);
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
你想想看,qsort 不知道你要排序整数还是浮点数,也不知道你是升序还是降序。它把「比较」这个动作抽象出来,让你自己实现。这就是回调的精髓——把控制权交还给调用者。
实战经验:我在写一个事件驱动框架时,把所有事件处理函数注册到一个回调表中。事件发生时,框架遍历回调表依次调用。这样新增功能时,只需要注册一个新回调,不用改框架代码。这就是「开闭原则」在C语言中的体现。
五、void 指针——万能指针
void * 可以指向任何类型的数据。但它有一个限制:不能直接解引用。因为编译器不知道它指向的是什么类型,也就不知道要取几个字节。
int a = 100;
void *p = &a;
// printf("%d\n", *p); // 错误!不能解引用 void 指针
printf("%d\n", *(int *)p); // 正确,先转成 int*
void * 最常见的用途就是写通用函数。比如上面 qsort 的比较函数参数就是 const void *,这样任何类型的数据都能传进来。
我自己写内存池时,也大量使用 void *。分配函数返回 void *,使用者自己决定怎么解释这块内存。这种设计让接口非常通用。
小技巧:用 void * 做参数或返回值时,一定要在文档里写清楚「实际类型是什么」。否则别人看你的代码,就像看天书一样。我曾经接手过一个项目,函数返回 void *,但文档里根本没写是什么类型……那感觉,就像拆盲盒。
六、避坑指南——我踩过的那些坑
讲到这里,我把这些年遇到的指针相关的问题总结一下,希望能帮你少走弯路。
- 数组名不是指针:数组名是常量,不能做
arr++操作。但数组名可以隐式转换成指针。这个区别很微妙,但很重要。 - 函数指针的类型匹配:声明函数指针时,返回类型和参数列表必须完全一致。我曾经因为参数类型不匹配(比如
int和long),导致回调函数拿到错误的值,查了一下午。 - void * 的算术运算:标准C不允许对
void *做加减法,因为不知道步长。GCC 扩展允许,但为了可移植性,建议先转成char *再运算。 - 多级指针的初始化:二级指针在使用前,一定要确保它指向的地址是有效的。否则
*pp = xxx会写到一个非法地址,直接段错误。
好了,指针的第二讲就到这里。这些概念看起来多,但核心就一句话:指针也是一种变量,它存的是地址。至于这个地址指向什么——数组、函数、还是另一个指针——都由你说了算。