搞C/C++的人,早晚都要过指针这一关。尤其是标题里这串名词——指针数组、数组指针、指针函数、函数指针、函数指针数组——光看名字就让人头大,不少人在面试前背了又背,一到写代码还是分不清int *p[3]和int (*p)[3]到底谁是谁。这篇文章我就把这几个硬骨头串在一起讲清楚,从指针和变量、一维数组、二维数组开始,一路拆到函数指针数组,顺带把声明怎么读、传参怎么写、底层的寻址逻辑是什么一次说透。文章适合正在学C语言的同学,也适合学了一段时间依然对指针和数组的结合犯迷糊的人,只要跟着例子自己敲一遍,这几个概念基本就不会再混了。
1. 指针与变量:先搞清楚指针到底存了什么
1.1 指针的本质是一个普通变量,只不过存的是地址
很多初学者的误区是觉得指针很"玄",好像变量前面加个*就变成了某种特殊生物。其实指针就是一个普通的变量,它里面存的东西不是数值本身,而是另一个变量的地址。也就是说,指针变量和int、char变量没什么本质区别,都是先开辟一块内存,往里存数据,唯一的不同是它存的数据恰好是一个地址编号。
举个例子:
int a = 10; int *p = &a;这段代码里,a在内存中占4个字节,里面存的是数值10;p也占内存空间,里面存的是a的首字节地址。&是取地址操作符,它的作用就是把a在内存中的地址编号"取"出来,放进p。
那么*p又是什么?*在这里是间接访问操作符(解引用),意思是"根据p里面存的地址,去访问那块内存里的数据"。所以*p的值就是10,而且*p和a是同一块内存的两种叫法。你可以直接*p = 20;,然后打印a会发现它变成了20。
我在带新人时最爱打一个比方:变量名是人的名字,地址是门牌号,指针变量是一张写着别人门牌号的纸条。你拿着纸条就能找到那个人的家,还能进屋改东西。这跟"指针指向谁"是同一个意思。
1.2 指针的大小和步长:为什么int*加1跳4个字节
这是很多新手忽略的细节。一个指针变量本身占多大内存?答案是固定的,取决于平台:32位程序里所有指针占4字节,64位程序里占8字节,跟你指向的是int还是char没关系。你可以用printf("%zu\n", sizeof(int*));自己验证。
但指针的"步长"跟它指向的类型直接相关:
char *pc = NULL; int *pi = NULL; double *pd = NULL; pc + 1; // 地址加1个字节 pi + 1; // 地址加4个字节 pd + 1; // 地址加8个字节p + 1并不表示"地址数值加1",而是"跳到下一个同类型变量"。所以int *pi加1,实际上是地址值加sizeof(int),也就是4。这个特性是后续数组遍历的理论基础,也解释了为什么不同类型的指针不能随便互相赋值——步长定义完全不同。
常见一个低级错误是:
int a = 5; char *p = &a; // 编译会警告就算你用强制类型转换强行编译过去,*p也只能读到a低地址的1个字节,剩下的3字节被丢弃了。这属于类型不匹配,不是我这里说的步长问题,但原理是相通的:指针的类型决定了你通过它能"看到"多大范围的内存。
1.3 指针的初始化和空指针
用指针前必须初始化,否则它里面是垃圾值,解引用不知道会踩到哪块内存。两种规范写法:
int a = 10; int *p = &a; // 指向变量 int *q = NULL; // 或者暂时不指向任何对象NULL在C语言里就是(void*)0,代表地址0。地址0在绝大多数平台上是被系统保护的内存,不允许读写。好处是你可以用if (p != NULL)来判断指针是否有效。坏处是如果你对NULL解引用,程序会直接崩溃(段错误),不过崩溃总比乱写内存强——后者通常表现为数据莫名被篡改,排查起来痛苦得多。
2. 指针与一维数组:数组名为什么能"假装"是指针
2.1 数组名在大多数表达式中会退化成首元素地址
一维数组和指针结合,核心就一句话:数组名在绝大多数情况下会退化成指向首元素的指针。
int arr[5] = {1, 2, 3, 4, 5}; int *p = arr; // 这里arr退化为&arr[0]arr的类型是int[5],但在表达式里赋值给int*时,编译器会把arr退化成int*,也就是&arr[0]。所以p和arr指向同一个地址,但注意,它们不是同一种类型。arr是数组类型,长度为5的元素序列;p是指针类型,只存了一个地址。
这个退化规则有两个例外,一个是sizeof,一个是&:
int arr[5]; printf("%zu\n", sizeof(arr)); // 20,整个数组的大小,不是4或8 printf("%p %p\n", arr, &arr); // 数值相同,但类型不同sizeof(arr)算出的是数组总字节数(5 × 4 = 20),说明在这里arr保持了数组的身份。&arr的类型是int(*)[5],指向整个数组的指针,后面会细讲。
2.2 下标访问的本质:arr[i]就是*(arr+i)
C语言的数组下标[]只是一个语法糖,编译器的处理逻辑是:
arr[i] 等价于 *(arr + i)怎么理解?arr退化为首元素地址后,arr + i就是地址往后跳i个int的长度,然后*解引用取出那个位置的值。这个转换是双向的:arr[i]可以写成*(arr + i),也可以写成i[arr]。听起来匪夷所思,但编译器就是这么处理的,i[arr]会被翻译成*(i + arr),结果一模一样。
我遇到过有面试官喜欢考这个,其实它就是考你有没有真正理解下标的本质。
对于一个变量int x = 10; int *p = &x;来说,p[0]也合法,因为p[0]等价于*(p + 0),也就是*p。很多人在做链表或者动态数组时看到p[0]不理解为什么数组下标能用在指针上,这个转换就是答案。
2.3 遍历数组的几种写法对比
int arr[5] = {1, 2, 3, 4, 5}; // 写法1:下标 for (int i = 0; i < 5; i++) { printf("%d ", arr[i]); } // 写法2:指针遍历 for (int *p = arr; p < arr + 5; p++) { printf("%d ", *p); } // 写法3:直接操纵首地址 for (int i = 0; i < 5; i++) { printf("%d ", *(arr + i)); }三种写法输出一样。写法2在C语言底层很常见,比如操作字符串时,常用while (*s) s++;来走完整个字符串,因为在C里字符串就是char数组,末尾有个'\0'作为哨兵。
边界判断p < arr + 5是可行的,因为指针大小比较的前提是"指向同一个数组内部或末尾之后一个位置"。如果你乱指到别的内存去比较,那属于未定义行为。
3. 指针与二维数组:行指针才是理解的关键
3.1 二维数组在内存里其实是一维的
很多新手把二维数组想象成一个矩阵,其实内存里根本没有"二维"这个概念。C语言的二维数组是连续存放的线性内存,int a[3][4]就是先放第0行(4个int),紧接着放第1行(4个int),再放第2行。总字节数3 * 4 * 4 = 48字节,在内存里是连续的48个字节。
之所以用a[row][col]访问,是因为编译器帮你做了换算:a[row][col]等价于*(*(a + row) + col)。先理解这个换算逻辑:
a的类型是int (*)[4],指向一行(含4个int的数组)。a + row:按"行"为步长跳转,跳到第row行的首地址,类型依然是int (*)[4]。*(a + row):解引用这一行,得到第row行的首元素地址,类型是int*。*(a + row) + col:按"列"(int)为步长跳转,跳到第col个元素。- 再解引用
*(*(a + row) + col)得到具体的int值。
这就是为什么二维数组名不能直接赋值给int**,下面细说。
3.2 指向二维数组的指针怎么写
看一个非常容易混淆的声明对比表:
| 声明 | 类型含义 | 典型用途 |
|---|---|---|
int *p[4] | 指针数组:p先和[4]结合,是数组,数组每个元素的类型是int* | 存放多个字符串首地址 |
int (*p)[4] | 数组指针:p先和*结合,是指针,指向一个含4个int的数组 | 用来指向二维数组的某一行 |
int **pp | 二级指针:pp是指针,指向一个int* | 用于指针数组的头指针 |
用代码看数组指针怎么用:
int a[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4] = a; // p指向第0行 printf("%d\n", (*p)[2]); // 1,*p得到第0行首元素地址,[2]取第3列 printf("%d\n", *(*(p + 1) + 3)); // 8,第1行第3列p + 1为什么能跳到第1行?因为p的类型是int (*)[4],指针步长是"一个含4个int的数组"的大小,也就是16字节。这就是行指针的精髓:它把"行"作为移动单位。
3.3 二维数组当参数传递时的写法
写函数时,接收一个二维数组有好几种等价的声明方式:
void func1(int a[3][4]) {} // 第1种:直接写完整维度 void func2(int a[][4]) {} // 第2种:省略第一维,第二维必须写 void func3(int (*a)[4]) {} // 第3种:使用数组指针三种写法在编译器层面完全相同,int a[3][4]和int a[][4]都会被调整为int (*a)[4]。这也是一个理解要点:数组参数本质上是通过指针传递的,数组名退化为指针,所以函数内没法通过sizeof(a)获得数组大小,必须在参数里额外传行列数。
初学者常犯的错是把二维数组名a直接传给int**类型的形参:
void func(int **a); func(a); // 编译警告,类型不匹配原因就是前面分析的:a的类型是int (*)[4],它退化成指针后指向的是"含4个int的行数组",不是指向int*。int **要求先按int*的步长移动,再按int的步长移动,跟二维数组的实际内存布局对不上。
3.4 二级指针与二维数组:为什么它们不是一回事
二级指针int **pp指向的是一个int*类型的变量,常用于动态分配的指针数组,比如:
int row0[3] = {1, 2, 3}; int row1[3] = {4, 5, 6}; int *rows[2] = {row0, row1}; int **pp = rows; // 合法,rows是int*数组,首元素类型是int*这里rows是"数组里的元素本身是指针",每个元素指向一块独立的内存,这块内存之间不保证连续。而二维数组int a[3][2]是一整块连续内存。两者的内存布局有本质区别:
- 二维数组:一行紧挨一行,连续存储。
- 指针数组(或者二级指针访问的对象):每行可能分布在内存的不同角落。
所以访问方式都有差异:用int **pp访问时,第一跳取到rows[i](指向某行的指针),第二跳rows[i][j]才取到具体数值;而二维数组a[i][j]是纯索引换算。
4. 指针数组与数组指针:一字之差,方向相反
4.1 怎么读声明:优先级和结合性是突破口
区分int *p[3]和int (*p)[3]有一个非常实用的方法,叫"优先级读法",比死记硬背靠谱得多:
- 先看标识符(变量名)左边有没有
*,右边有没有[]或()。 []的优先级高于*,所以如果没有括号,p先和[3]结合,p是数组。- 如果加了括号让
*先和p结合,p就是指针。
具体来说:
int *p[3]; // p先和[3]结合 => p是一个数组,数组里有3个元素 // 元素类型是int* => "存放int指针的数组",即指针数组 int (*p)[3]; // 括号使p先和*结合 => p是指针 // 指向的类型是int[3] => "指向含3个int数组的指针",即数组指针面试时考的最多的就是这两种。你可以默念口诀:"没括号,数组优先;有括号,指针优先"。
4.2 指针数组的实际用途:字符串表、参数列表
指针数组最经典的应用是保存多个字符串的首地址:
char *str_arr[] = { "hello", "world", "C language" };这里有三个字符串字面量,它们存储在静态存储区,str_arr是一个数组,每个元素是一个char*,分别指向三个字符串的首字符。这种写法比char str[3][20]省内存,因为每行不用固定申请20字节,字符串多长就占多少空间。代价是指向的字符串字面量是只读的,不能通过str_arr[0][0] = 'H'修改。
程序入口main函数的参数其实也是这个结构:
int main(int argc, char *argv[])argv就是一个char*数组,argv[0]是程序路径,argv[1]及以后是命令行传入的参数。理解指针数组后,操作argv跟操作str_arr没区别,你会遍历它就能处理任意数量的命令行参数。
4.3 数组指针的实际用途:二维数组传参和矩阵运算
数组指针主要用来指向二维数组的某一行,或者干脆作为二维数组的"行指针"来操作整个矩阵。最常见的场景是编写矩阵运算函数:
void print_matrix(int rows, int cols, int (*mat)[cols]) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { printf("%5d ", mat[i][j]); } printf("\n"); } }这里int (*mat)[cols]是可变长数组的写法,C99支持,cols在运行时传入。如果编译器不支持可变长数组,那就老老实实写固定列数或者使用int**配合手动索引。
数组指针还有一个容易踩的坑:
int a[3][4]; int (*p)[4] = a; p++; // 跳过整整一行,地址加16字节 (*p)[0]; // 访问下一行的第一个元素很多新手拿p++当"移动一个元素"来理解,结果发现地址跳了4个int那么多,就是因为没意识到int (*)[4]的步长是"一个含4个int的数组",16字节。这是一个非常典型的指针步长问题。
5. 指针函数与函数指针:返回指针,还是用指针调函数?
5.1 指针函数:返回值是地址的函数
名字里带"函数"结尾的,本质上是个函数,只不过它的返回值是个指针。声明格式:
int *find_max(int *arr, int size) { int *max = arr; for (int i = 1; i < size; i++) { if (arr[i] > *max) { max = &arr[i]; } } return max; }int *find_max(...)的意思是:find_max是一个函数,它的返回值类型是int*。调用方式:
int nums[] = {3, 7, 2, 9, 5}; int *p = find_max(nums, 5); printf("%d\n", *p); // 9指针函数最常见的一个坑是返回局部变量的地址。局部变量在函数结束后内存就被回收了,你再拿着那个地址去解引用,读出来的是垃圾值。比如:
int *bad_func(void) { int x = 10; return &x; // 悬垂指针,x已经生命周期结束 }解决办法有三条路:返回静态局部变量的地址(static int x)、由调用者传入缓冲区地址、或者用动态分配内存malloc(记得释放)。这是面试常考点,也是实际开发中非常容易出bug的地方。
5.2 函数指针:把函数当成数据来传递
函数指针的声明语法是三者中最抽象的:
int (*fp)(int, int);读法是:fp先和*结合,说明fp是指针;往外一层,fp指向一个函数,这个函数有两个int参数,返回int。它最典型的应用是回调函数——把一个函数作为参数传给另一个函数,让框架在特定时机调用。
举个例子,自定义排序比较函数:
#include <stdio.h> #include <stdlib.h> int compare_int(const void *a, const void *b) { int ia = *(const int*)a; int ib = *(const int*)b; return ia - ib; } int main(void) { int nums[] = {5, 2, 8, 1, 9, 3}; qsort(nums, 6, sizeof(int), compare_int); for (int i = 0; i < 6; i++) { printf("%d ", nums[i]); } return 0; }这里qsort的第四个参数就是函数指针,类型是int (*)(const void*, const void*)。没有函数指针的话,你还得写几个不同的排序函数来应对不同的比较规则;有了它,排序算法本体只需要实现一次,比较逻辑由调用方随时注入。这就是"把变化的部分参数化"的设计思路。
5.3 区分记忆技巧:从名字位置和*配合来看
| 类别 | 声明示例 | 读法 | 本质 |
|---|---|---|---|
| 指针函数 | int *func(int a) | func是函数,返回int* | 一个返回指针的函数 |
| 函数指针 | int (*fp)(int a) | fp是指针,指向函数 | 一个指向函数的指针变量 |
这里又可以用优先级读法:*func和*fp的位置关系。
int *func(int a)中,func先和(int a)结合,说明func是函数,*只是修饰返回值。
int (*fp)(int a)中,括号让fp先和*结合,说明fp是指针,外面的(int a)说明它指向的是一个接受int参数、返回int的函数。
注意一个细节:fp = func;和fp = &func;两种写法在C语言里完全等价,因为函数名在表达式中会退化为指向函数的指针。调用时fp(10)和(*fp)(10)也都行,日常习惯写fp(10)就够了。
6. 函数指针数组:转移表与回调机制的基石
6.1 声明、初始化和调用
函数指针数组就是把多个函数指针放进一个数组。声明语法看着吓人,但一层层剥开并不难:
int (*fp_array[4])(int, int);按优先级读法拆解:fp_array先和[4]结合,所以它是数组;数组元素类型是int (*)(int, int),也就是每个元素是一个函数指针。整个声明的意思是:一个长度为4的数组,里面存了4个函数指针。
初始化并调用:
#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 divide(int a, int b) { return b == 0 ? 0 : a / b; } int main(void) { int (*oper[4])(int, int) = {add, sub, mul, divide}; for (int i = 0; i < 4; i++) { printf("%d\n", oper[i](10, 3)); } return 0; }这段代码定义了4个二元运算函数,把它们放进数组oper,然后用oper[i](10, 3)直接调用。这是函数指针数组最直白的用途:根据下标选函数,而不是根据条件写一长串if-else。
6.2 用函数指针数组替换switch-case:以计算器为例
很多教材讲计算器都会先写一版switch:
char opt; int a = 5, b = 3; switch (opt) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': result = a / b; break; }这种写法本身没错,但当你需要支持几十个操作时,switch会膨胀得很厉害。用函数指针数组可以把"操作符映射"和"运算逻辑"彻底分离:
#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 divide(int a, int b) { return b ? a / b : 0; } typedef int (*op_func)(int, int); int main(void) { op_func ops[128] = {0}; ops['+'] = add; ops['-'] = sub; ops['*'] = mul; ops['/'] = divide; char opt = '+'; int a = 9, b = 4; if (ops[(int)opt] != NULL) { printf("%d\n", ops[(int)opt](a, b)); // 13 } return 0; }这里使用了typedef给函数指针类型起别名op_func,可读性大大提高。数组大小为128是因为char的下标最大到127,直接用ASCII码做索引,查表代替分支判断。这种"表驱动"的设计在嵌入式菜单系统、命令解析器、图形界面事件分发中非常常见,本质就是一种轻量的回调机制。
6.3 从计算器到命令解析:实际的进阶应用
我在实际项目中用过一次这样的设计:一个简单的调试终端,要解析set、get、help、dump、reset这几种命令。如果每个命令写一个case分支,后期命令越来越多,每个case里还要处理参数解析、错误返回,函数会变得又长又难维护。
用函数指针数组之后,每个命令就是一个独立的函数,命令名到函数的映射单独成一张表,新增命令只需要加一个函数、加一行映射:
struct command { const char *name; void (*handler)(int argc, char *argv[]); }; struct command cmd_table[] = { {"help", help_handler }, {"set", set_handler }, {"get", get_handler }, {"dump", dump_handler }, {"reset", reset_handler}, }; void dispatch(const char *cmd, int argc, char *argv[]) { for (size_t i = 0; i < sizeof(cmd_table) / sizeof(cmd_table[0]); i++) { if (strcmp(cmd, cmd_table[i].name) == 0) { cmd_table[i].handler(argc, argv); return; } } printf("unknown command\n"); }这种写法在C语言世界里非常成熟,也是很多C++里"策略模式"的底层原型。值得一提的是,struct里面放函数指针,很多数据结构的教科书不会展开讲,但你在阅读开源C项目时会频繁遇到。
6.4 函数指针最难的点:类型太长了怎么办
很多人学到这里会崩溃,因为函数指针类型写起来太啰嗦,比如:
int (*fp)(int, int);如果你有一堆这种变量,每次都写完整类型非常痛苦。解决办法是typedef,除了给函数指针起别名,也可以给函数签名起别名:
typedef int (*binary_op)(int, int); typedef int (*unary_op)(int);然后声明就清爽多了:
binary_op ops[100];注意typedef和变量声明的区别:typedef把"一个新的类型名"放在了原来写"变量名"的位置。刚开始可能不习惯,多用几次就好。实际上,在很多C语言项目中,函数指针都是藏在typedef后面的,直接看到的都是简洁的别名。
6.5 使用函数指针数组时常见的坑
几个实战中容易踩的坑,提前提醒一下:
第一,数组下标越界。用操作符字符做下标(如ops['+'])没问题,但如果你用数字索引(比如ops[1000])就直接越界了。务必保证下标范围合法,或者用if (idx >= 0 && idx < size)做防护。
第二,空指针调用。如果数组里某些位置没初始化,或者初始化为NULL,调用时就会崩溃。像上面的例子,调用前先检查ops[(int)opt] != NULL。
第三,函数签名必须完全匹配。函数指针的类型严格限制了参数个数和返回值,不匹配会编译报错,比如int add(int, int)不能直接塞进int (*fp)(int)类型的数组里。解决办法是统一函数签名,让所有函数都用int func(int, int)或者带void*参数的形式,然后内部强转。
第四,别忘记数组名本身也是一个指针,这跟你前面学的数组和指针的关系一样。oper[i](a, b)本质上等价于(*(oper + i))(a, b)。这行代码能把前面所有知识串起来:oper + i是数组内第i个元素的地址,*(oper + i)取到那个函数指针,再对函数指针调用(a, b)。
7. 结合实战:把这几层关系放在一起写段能跑的代码
7.1 一个综合示例:用二维数组、数组指针、函数指针数组做一个迷你菜单程序
光讲概念容易飘,我写一个相对完整的小例子,尽量把前面所有知识点串在一起。代码不长,但覆盖了二维数组、数组指针、指针函数、函数指针数组这些概念。
#include <stdio.h> #include <string.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 divide(int a, int b) { return b == 0 ? 0 : a / b; } typedef int (*binary_op)(int, int); // 函数指针数组,下标0-3对应4种操作 binary_op ops[4] = {add, sub, mul, divide}; // 操作符表:二维数组,每行存一个操作符字符和名字 char op_names[4][8] = { "+", "-", "*", "/" }; // 指针函数:接收行指针,返回该行操作符字符 char get_op(char (*row)[8]) { return (*row)[0]; } int main(void) { // 用数组指针遍历操作符表 char (*row_ptr)[8] = op_names; for (int i = 0; i < 4; i++, row_ptr++) { printf("操作符: %c\n", get_op(row_ptr)); } int a = 12, b = 5; printf("12 + 5 = %d\n", ops[0](a, b)); printf("12 - 5 = %d\n", ops[1](a, b)); printf("12 * 5 = %d\n", ops[2](a, b)); printf("12 / 5 = %d\n", ops[3](a, b)); return 0; }这里组合了:
- 二维字符数组
op_names存放操作符信息; - 函数指针数组
ops存放运算函数; get_op是返回char的普通函数,形参用了数组指针char (*row)[8];main里用char (*row_ptr)[8]遍历二维数组的每一行。
这个例子虽然小,但把"数组指针、二维数组、函数指针数组"这三者放在同一个程序里,彼此的角色一对比就直观多了:数组指针负责"行"级别的移动,函数指针数组负责"操作"级别的分发。
7.2 建议的练习路径
学习这些概念没有捷径,但有一条我自己验证过还算高效的路径,按顺序来:
- 在纸上手写出
int *p[5]、int (*p)[5]、int *fp(int)、int (*fp)(int)四个声明,并用优先级读法解释含义,直到不看答案能写对。 - 自己写一个程序,用三种不同方式遍历一维数组(下标、指针、
*(arr+i)),打印结果确认一致。 - 写一个3行4列的二维数组,分别用
a[i][j]、*(*(a+i)+j)、*(a[i]+j)访问同一个元素,对比结果。 - 写一个指针数组存放3个字符串,再写一个数组指针指向一个3行4列的二维数组,分别遍历输出。
- 实现一个计算器程序,先写switch版本,再改用函数指针数组版本,对比两者的代码结构。
- 最后用
typedef给函数指针起别名,重构你的计算器。
这套练习做完,基本就能把指针和数组、函数的结合体系打通。我的建议是每个例子都自己敲一遍,别光看书,尤其是声明读法,手一写就出真知。
8. 个人体会与经常被问到的几个问题整理
8.1 为什么学了这么多年还是容易混
以我带过那么多新人的经验来看,最容易造成混淆的不是单个概念,而是概念之间容易互相暗示。比如int *p[3]和int (*p)[3],名字就差一个括号;int *function()和int (*fp)(),名字里都有function,位置不一样含义就完全不同。
我的建议是:不要在脑子里同时背好几个名字,只记住优先级读法和"右左法则"这一个工具就够了。拿到任何声明,先看标识符右边有没有[]或(),再看左边有没有*,括号改变结合顺序,每一步都按这个规则来,所有复杂声明都能拆开。语言本身就是一套规则系统,理解了规则就没必要死记硬背。
8.2 指针到底要不要用,学C++用得多吗
在很多开发场景里,现代C++推荐用引用和智能指针,但这不意味着C的原始指针不用学。理由很简单:第一,C++里指针依然无处不在,数组参数传递、回调、面向对象底层实现都离不开指针;第二,理解指针是理解内存布局最快的方式,而内存布局会影响你对性能、生命周期的判断。很多C++代码里的隐蔽bug追溯到底,都是"指针对内存的错误操作"在前。先把C的指针啃明白,后面学C++的引用、容器,思路会顺很多。
8.3 一道综合题型的思考过程
面试里最常考的类似题:char *p1[4]、char (*p2)[4]、char *p3()、char (*p4)(),问各是什么。
我的拆法:
p1:右边先遇到[4],是数组,元素类型char*,是指针数组。p2:括号让左边*先发挥作用,是指针,指向char[4],是数组指针。p3:右边先遇到(),是函数,返回值char*,是指针函数。p4:括号让*先发挥作用,是指针,指向一个返回char的函数,是函数指针。
整个过程完全不靠背名字,纯粹是语法解析。这也是真气人但又很有意思的C语言特色:声明本身就是一道编程题,破解它你会非常有成就感。
8.4 最后分享一个调试小技巧
如果程序崩溃但你怀疑是指针问题,有个土办法很好用:把指针打印出来,然后跟一个已知的数组地址对比。比如你怀疑p越界了,先打印p,再打印arr和arr+4,看看p是否落在这个区间之外。C语言没有运行时数组边界检查,养成"打印地址、确认范围"的习惯能帮你省下大量排查时间。我工作这么些年,遇到莫名其妙的段错误,第一反应永远是"把指针和数组边界打出来看",而不是翻代码逻辑。
搞懂指针和数组、函数的结合,其实不只是为了应付考试或者面试,它直接决定你能不能读懂开源项目里那些错综复杂的函数指针表、回调结构、插件系统。这条路没有捷径,但只要你把"优先级读法"和"指针步长"这两个基本功打磨扎实,再顺着本文的路线把几个概念逐个吃透,后续再看任何复杂的C代码,都会觉得顺眼很多。