很多人在学 C 语言的时候,都会在指针这里摔一跤。前面几章写循环、算阶乘、做简单计算器都挺顺利,一到指针就开始发懵:数组名到底是不是指针?为什么*(p+3)就能拿到第四个元素?函数怎么还能当参数传?这些问题如果没人给你揉碎了讲,靠自己查很容易在第四章第二节这个位置卡上两三个星期。
这篇要说的 4-2,就是大部分主流 C 语言教材第四章第二节的经典内容:指针和数组、函数的纠缠关系。我把这块内容拆成六个递进的小节,从数组名的本质讲到函数指针的实战,最后带着你手写一个用指针实现的排序和字符串逆序。适合已经学完基本语法(变量、循环、分支、函数、一维数组)但是还没完全搞懂指针的读者;如果你已经工作了,想回头把指针这块补扎实,这篇同样可以当一份查漏补缺的提纲。
学这个内容不需要装什么特殊环境,Windows 上用 VS Code 配好 C 编译器,Linux 或 mac 上直接终端里 gcc 就行。下面所有代码我都用标准 C 写,你复制到本地跑一遍,效果最直观。读之前先做一个小自测:你能不能准确说出int *p[5]和int (*p)[5]的区别?如果暂时说不出,没关系,这正是 4-2 要解决的问题。
1. 数组名的真正身份:指针学习的第一块基石
1.1 数组名到底是不是指针
C 语言教材里最常见的误导就是一句话:“数组名是指针常量。”我见过不少网课还这么讲。先直接给结论:数组名不是指针,它只是很多时候表现得很像指针。
怎么证明?用sizeof运算符:
#include <stdio.h> int main(void) { int arr[5] = {1, 2, 3, 4, 5}; printf("sizeof(arr) = %zu\n", sizeof(arr)); printf("sizeof(arr+0) = %zu\n", sizeof(arr + 0)); return 0; }在 64 位机器上运行,结果是:
sizeof(arr) = 20 sizeof(arr+0) = 8sizeof(arr)返回的是整个数组占据的内存字节数:5 个 int,每个 4 字节,正好 20。如果数组名是指针,sizeof(指针)在 64 位平台上应该返回 8,但sizeof(arr)偏偏返回 20。这说明数组名本身携带了整个数组的长度信息,它不是指针。
那为什么arr又能直接赋值给int *p呢?因为数组名在绝大多数表达式里会被隐式转换为指向首元素的指针,这个行为叫作“数组退化”。只有两种场合数组名不退化:一是sizeof,二是&取地址。抓住这两个例外,你就抓住了数组名的本质。
1.2 arr、&arr[0]、&arr 的差异
接下来这个问题几乎能把初学者绕晕:arr、&arr[0]、&arr打印出来地址值一样吗?答案是:地址数值相同,但类型完全不同。
#include <stdio.h> int main(void) { int arr[5] = {1, 2, 3, 4, 5}; printf("arr = %p\n", (void *)arr); printf("&arr[0] = %p\n", (void *)&arr[0]); printf("&arr = %p\n", (void *)&arr); printf("arr + 1 = %p\n", (void *)(arr + 1)); printf("&arr + 1 = %p\n", (void *)(&arr + 1)); return 0; }运行结果里arr + 1比arr多 4 个字节,因为偏移了一个 int;&arr + 1比&arr多 20 个字节,因为偏移的是整个数组的长度。&arr的类型是int (*)[5],也就是指向“含有 5 个 int 的数组”的指针。
这解释了为什么老手都说“数组名是数组名,指针是指针”。如果你写int *p = &arr;,编译器会直接报类型不匹配的错误;正确写法是int (*p)[5] = &arr;。这个知识点在写二维数组的函数参数时非常关键,因为第二维的长度编译器必须知道,否则没法计算行偏移。
2. 指针数组与数组指针:优先级决定谁包裹谁
2.1 用优先级法则拆解声明
int *p[5]和int (*p)[5]长得像两兄弟,实际是两个不同的东西。拆解起来只需要一条规则:看变量名先和谁结合。C 语言里,[]下标运算符的优先级高于*解引用运算符;
int *p[5]:p先和[5]结合,所以p是一个数组,数组里有 5 个元素,每个元素类型是int *。这叫作指针数组。int (*p)[5]:括号强制先和*结合,所以p是一个指针,它指向的类型是int [5],也就是一个含有 5 个 int 的数组。这叫作数组指针。
更通用的拆法叫“右左法则”:从变量名开始,先往右看,再往左看,遇到括号就跳到括号外,从内往外一层层剥。遇到看不懂的复杂声明就按这个规则画一棵括号树,画上几次就能在脑子里直接反应出来。
我见过很多同学靠背例句区分这两个概念,背了又忘。真正解决问题的方式是理解优先级关系,而不是死记硬背。下次再看到类似的声明,停一下,在纸上写下变量名后先看到什么,答案自然就出来了。
2.2 数组指针在二维数组参数传递中的应用
数组指针最典型的场景是二维数组作为函数参数。很多教材这样写:
void print_matrix(int mat[3][3], int rows);这种写法在调用端看起来没问题,但mat[3][3]只是“看起来像数组”的参数声明,编译器会把它自动调整为:
void print_matrix(int (*mat)[3], int rows);如果你写成void print_matrix(int mat[][5], int rows)也可以,但注意:第二维的 5 必须写死。原因是函数内部访问mat[i][j]时,编译器需要使用第二维长度来计算偏移量:*(*(mat + i) + j),本质就是i * 5 + j个元素的距离。所以第二维是硬性需求,第一维反而无所谓,这也是为什么int mat[][3]合法、int mat[3][]不合法。
实际开发里,更推荐的一种做法是把它当作“指向一维数组的指针”处理,再配合行列数一起传。不过很多老 C 代码直接传二维数组切片,理解int (*mat)[3]这种参数形式仍然是必备技能。
2.3 用指针数组管理字符串列表
指针数组更实用的场景是管理多个字符串。看这个例子:
const char *fruits[] = { "pear", "apple", "banana", "mango" };fruits本身是个数组,数组里存的是const char *指针,每个指针指向一个字符串字面量。这样四个长度不同的字符串,实际占用的是 4 个指针加上各自字面量的空间。如果你要交换两个字符串的位置,只需要交换两个指针,不需要逐个字符拷贝。
这个特性在字符串排序里非常吃香。用qsort排字符串元素时,交换的也只是指针,效率很高。指针数组和普通数组的区别不在“存了什么”,而在“每个元素本身是别人数据的入口”,意识到这一点,你对指针的价值理解就加深了一层。
3. 函数指针与指针函数:搞混这对概念的人比想象中多
3.1 一个歪理记住两者的区别
顺序一换,含义完全不同:
- 指针函数:返回指针的函数。比如
int *foo(void),foo是一个函数,调用后返回int *。 - 函数指针:指向函数的指针。比如
int (*fp)(void),fp是一个变量,里面存的是函数的地址。
记法也简单:看*和函数名谁先结合。int *foo(void)里foo先和(void)结合,所以它是函数;int (*fp)(void)里fp先和*结合,所以它是指针。
指针函数本身不难,但它引出了一个 C 语言里非常经典的错误——返回局部变量的地址:
int *bad_func(void) { int x = 42; return &x; // x 是栈上的局部变量,函数返回后这块内存就失效了 }这段代码在本地运行可能“碰巧”能打印出 42,但它是未定义行为,编译器通常也会给出警告。正确做法是把数据的生命周期从函数内部提升到调用方:要么传入一个调用方准备好的缓冲区,要么用malloc动态分配并让调用者负责释放。
3.2 回调函数:qsort 里的接口约定
函数指针最经典的用法是回调。C 标准库的qsort是一个绕不开的例子:
#include <stdio.h> #include <stdlib.h> int cmp_int_asc(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[] = {5, 2, 8, 1, 9, 3}; size_t n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int_asc); for (size_t i = 0; i < n; i++) { printf("%d ", arr[i]); } putchar('\n'); return 0; }cmp_int_asc的参数和返回值是被qsort的接口固定的:两个const void *,返回int。qsort不关心你排的是 int、double 还是结构体,它只按你提供的比较规则去交换元素,这就是“接口约定”的意义。
这里有个初学者容易踩的坑:写完比较函数忘了把const void *转成具体类型。*(const int *)a这行,从左到右是“先把 a 转成 const int*,再解引用”。类型不转换就直接*a是错误的,因为void *不能直接解引用。另一处容易写错的是return ia - ib;,这写法在ia=INT_MAX而ib为负数时可能整数溢出,稳妥的写法就是(ia > ib) - (ia < ib)。
3.3 函数指针表:一种极简分发写法
再往前一步,函数指针可以放进数组,构成函数指针表。比如一个简单的菜单程序:
void action_new(void) { printf("new operation\n"); } void action_open(void) { printf("open operation\n"); } void action_save(void) { printf("save operation\n"); } void (*actions[])(void) = {action_new, action_open, action_save}; // 调用时:actions[0]() 等价于 action_new();这种写法的好处是消除了一大堆if / else if / else。菜单项增加时,只需要往数组里加一个函数名,不用到处改分支。通讯录管理、简易游戏状态切换、命令行指令解析器,这类场景都非常适合用函数指针表来实现。
把函数存入数组这个思路,本质上是把“行为”变成“数据”。你可以在表里查一个下标、一个字符串对应的处理函数来调用,这和用大段 switch 分发结果截然不同。后面你学状态机、事件驱动模型的时候,会反复用到这种设计。
4. 字符串与指针:五个实战细节和三个高频坑
4.1 字符数组和字符指针的初始化差异
这两个写法看着差不多,运行起来天差地别:
char s1[] = "hello"; // 字符数组:在栈上分配 6 字节,内容可修改 char *s2 = "hello"; // 字符指针:指向字符串字面量,字面量通常在只读区char *s2 = "hello";这种写法,标准 C 规定尝试修改字面量是未定义行为。绝大多数平台上,字符串字面量放在只读数据段,所以s2[0] = 'H'大概率会让程序在运行时崩溃。而s1是字符数组,内容会拷贝到栈上,修改完全没问题。
这个坑我见得太多了:初学者喜欢用char *s = "abcdef";去练习改字符,一旦崩溃就一脸懵。排查半天才反应过来,原来不是代码逻辑的问题,是数据所在的存储区域本身不允许写。记一句话:凡是字符串字面量,都别去改它,除非你明确把它拷贝到可写的内存里。
4.2 用双指针实现字符串逆序
字符串逆序在各类练习里是常客。这里用指针方式写一个完整例子:
#include <stdio.h> #include <string.h> void reverse(char *s) { if (s == NULL) { return; } char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char tmp = *left; *left = *right; *right = tmp; left++; right--; } } int main(void) { char buf[] = "hello, C"; reverse(buf); puts(buf); return 0; }头尾双指针逐步向中间逼近,每次交换一对字符。注意两点:一是先判空,否则strlen(NULL)会直接崩;二是主函数里必须用可写的字符数组,不能把字符串字面量传进来。这个实现的时间复杂度是 O(n),空间复杂度 O(1),没有用额外的数组,这也是指针操作字符串的典型优势。
想再进阶一点,可以思考:如何只逆序单词顺序而不逆序每个字母?这类题目在面试里很常见,其实思路就是先整个字符串逆序,再按空格把每个单词逆序回来。用指针写起来会更直观。
4.3 strcpy 和 strstr 的边界问题
再聊两个库函数的坑。
strcpy(dst, src)会把 src 从起始到'\0'的所有字符全部拷到 dst,目标缓冲区多大它完全不管。这是缓冲区溢出的经典来源。更稳妥的方案是snprintf(dst, dst_size, "%s", src),它的长度参数显式限制了写入量。如果你非常习惯strcpy,至少记住一句话:只有当你能确定 src 一定放得进 dst 时才可以用它,否则一律换成有长度上限的版本。
strstr(haystack, needle)用于在字符串里查找子串,它内部靠'\0'判断字符串结束。所以如果你在一段二进制数据里查找某个字节序列,strstr是不能用的——二进制数据里可能包含大量的 0x00,strstr会把这些 0x00 当作字符串终止符,结果直接漏掉后面的数据。
正确做法是手写一个按长度遍历的字节搜索函数,或者在平台上使用memmem(GNU 扩展)这类面向内存块的接口。理解这一点,你就明白了:库函数是按“字符串语义”设计的,不是按“内存块语义”设计的。需要处理原始二进制时,优先想memcpy、memcmp、memchr这一族,而不是字符串函数那族。
5. 排序实战:用指针重写冒泡与快速排序
5.1 下标改指针,扫描逻辑完全一致
很多人的冒泡排序是这么写的:
void bubble_sort(int *arr, int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } }这个版本完全没错,但它是下标思维。把它改成指针扫描,能帮你理解两者的一致性:
void bubble_sort_ptr(int *arr, int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int *p = arr; p < arr + n - 1 - i; p++) { if (*p > *(p + 1)) { int tmp = *p; *p = *(p + 1); *(p + 1) = tmp; swapped = 1; } } if (!swapped) { break; } } }内层循环里,指针p从头开始,每次前进一个元素,*p是当前元素,*(p + 1)是下一个元素。这和arr[j]、arr[j + 1]完全等价,但写法上更接近“我在内存里走一遍”的直观感受。
如果你还理解不深,做一个小实验:在循环里打印p和p + 1的地址,观察它们相差多少个字节。看到每次加 4,你就会明白p++不只是地址加 1,而是加了一个sizeof(int)。这是指针运算和普通整数运算最本质的区别。
5.2 快速排序:经典分区过程的指针视角
快速排序的核心在分区:选一个基准值,把小于基准的放左边,大于基准的放右边,然后递归处理左右两边。我用指针写一版比较容易理解的:
#include <stdio.h> void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } void quick_sort(int *arr, int left, int right) { if (left >= right) { return; } int pivot = arr[(left + right) / 2]; int *i = arr + left; int *j = arr + right; while (i <= j) { while (*i < pivot) { i++; } while (*j > pivot) { j--; } if (i <= j) { swap(i, j); i++; j--; } } quick_sort(arr, left, (int)(j - arr)); quick_sort(arr, (int)(i - arr), right); } int main(void) { int arr[] = {9, 3, 7, 1, 6, 2, 8, 5}; int n = (int)(sizeof(arr) / sizeof(arr[0])); quick_sort(arr, 0, n - 1); for (int k = 0; k < n; k++) { printf("%d ", arr[k]); } putchar('\n'); return 0; }这里pivot取中间位置的值,而不是arr[left],能降低对接近有序数据的性能退化风险。while (*i < pivot)和while (*j > pivot)分别让左右指针跳过不需要交换的元素,直到找到需要交换的位置。
代码里的(int)(j - arr)利用了“指针相减得到两个指针之间相隔的元素个数”这个规则,结果类型是ptrdiff_t。演示代码里转成int只是为了递归参数方便;实际工程中建议改用ptrdiff_t或size_t,避免大数组下可能的类型转换问题。
5.3 用函数指针统一排序的比较逻辑
有了前面的基础,再看qsort的设计就更清楚了。qsort把“排序算法”和“比较规则”彻底分离:算法由库函数实现,规则由你提供的比较函数决定。这就是函数指针在实际标准库中的顶级应用。
比如你要给结构体数组排序:
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct { char name[32]; int score; } Student; int cmp_student(const void *a, const void *b) { const Student *sa = (const Student *)a; const Student *sb = (const Student *)b; return (sa->score > sb->score) - (sa->score < sb->score); }如果要按浮点数排序,比较函数里千万别写return (int)(x - y),因为浮点数转成 int 会截断,0.7 和 0.3 都会被截成 0,排序结果完全不对。正确做法:
int cmp_double(const void *a, const void *b) { double da = *(const double *)a; double db = *(const double *)b; return (da > db) - (da < db); }顺带补一个基础点:浮点数判断相等不能用a == b,要用fabs(a - b) < DBL_EPSILON或相对误差阈值。但在排序比较器里,你只需要知道谁大谁小,所以直接写(da > db) - (da < db)就够了,不需要考虑“相等”的精度问题。这个细节能帮你避免很多无谓的情绪波动。
6. 学完 4-2 之后,我建议你马上做的三个练习
6.1 练习一:把以前写过的所有数组代码改成指针版
找十个你以前用下标写过的数组练习题:求和、找最大值、统计频次、移动元素……全部改成用指针完成。改造过程中你一定会踩到两个问题:一是数组名在sizeof里不能当指针用;二是函数传参时数组退化成指针,导致长度信息丢失,必须额外传长度。
这两个问题踩过一次,之后就忘不掉了。改完之后顺手做一个实验:打印arr、arr + 1、&arr + 1的地址,看看它们相差多少字节。这一步能帮你把“指针偏移量与元素类型”的关系刻进脑子里。这个练习做完,你再回头看教材里的指针章节,会觉得顺畅很多。
6.2 练习二:用指针数组给一组字符串排序
参考这个框架,把五个水果名按字典序排序并打印出来:
const char *fruits[] = { "pear", "apple", "banana", "mango", "grape" }; size_t count = sizeof(fruits) / sizeof(fruits[0]);你可以直接用qsort加一个字符串比较函数,也可以手写一个冒泡排序的指针交换版本。手写时注意:交换的是fruits[i]这个指针,而不是字符串内容本身。这样排序的开销比反复strcpy小得多。
如果用qsort,比较函数必须处理双重指针:参数是const void *,但它指向的是指针数组里的某个元素,所以要先转成const char * const *,再解引用拿到const char *。这个类型转换第一次写容易卡住,正好可以检验你对指针类型匹配的理解。
6.3 练习三:把函数指针表接入一个菜单程序
写一个最简单的控制台菜单:显示“1. 添加 2. 删除 3. 显示 4. 退出”,每个菜单项对应一个void action(void)类型的函数,用一个函数指针数组组织起来,根据用户输入的数字直接调用actions[choice - 1]()。
这个练习做完,你会明白函数指针表就是一个天然的转发层:流程分发和具体实现被完全分开。后面学结构体里的函数指针、学命令分发器、学消息处理框架,思路都是相通的。如果你用的是 VS Code,建议顺手把 C 语言编译任务配置好,写完一键编译运行,别把时间浪费在环境捣鼓上。
我在实际看代码和帮人调试时发现,指针相关的问题九成都能归结到三类:类型不匹配、生命周期越界、没理解内存布局。4-2 这一节把数组名、指针数组、数组指针、函数指针这些概念全部串起来之后,你再遇到这类问题的排查速度会明显变快。把这个练习做完,再回头去翻那些曾经吓到你的复杂声明,你会发现自己已经能慢慢拆开它们了。