指针数组和数组指针,这俩概念我当年学C/C++的时候也绕了很久。明明字面上就是“指针”和“数组”四个字换了个顺序,意思却天差地别。不少人在笔试面试里栽跟头,就是因为没把这两个东西的底层逻辑理清楚。这篇东西我打算一次性把它们拆开揉碎讲明白,从内存模型到语法识别,从函数传参到笔试题实战,看完要是还分不清,你来打我。
1. 指针数组与数组指针:先搞懂这俩到底是个啥
1.1 从“运算符优先级”破解定义
很多教材喜欢用“指针数组是指针还是数组”这种绕口令来考人。我的经验是:先别看名字,直接看运算符优先级。
指针数组的写法是int *p[3],数组指针的写法是int (*p)[3]。区别就在那个括号上。
先回忆一下C语言运算符优先级:[](下标运算符)的优先级比*(解引用符)高。所以在int *p[3]里,p先跟[3]结合,说明p首先是一个数组,数组里有3个元素,每个元素是int *类型,也就是存放 int 指针的数组,所以叫“指针数组”。
而在int (*p)[3]里,括号把*p先绑在一起,说明p首先是一个指针,这个指针指向什么?指向一个“有3个int元素的数组”,所以叫“数组指针”。
为了帮助记忆,我在教学的时候经常用“谁先结婚”来打比方。[]是“和数组结婚”,*是“和指针结婚”。谁跟p的关系更近(优先级更高),p就先跟谁结合。int *p[3]里,p先和[3]结合了,所以p本质是数组;int (*p)[3]里,括号强行让p先和*结合,所以p本质是指针。这个例子虽然不太严谨,但记起来特别容易。
另外一个常见的写法是int *(p[3]),因为括号里只有p,这个等价于int *p[3],还是指针数组,括号没有改变任何优先级关系。
1.2 用sizeof一眼看穿本质
有一个非常实用的验证技巧——用sizeof看类型大小。
#include <stdio.h> int main() { // 指针数组:3个int*元素 int *pArr[3]; // 数组指针:指向含3个int的数组 int (*pToArr)[3]; printf("sizeof(pArr) = %zu\n", sizeof(pArr)); // 64位系统:24字节 printf("sizeof(pToArr) = %zu\n", sizeof(pToArr)); // 64位系统:8字节 return 0; }在64位系统上,指针通常占8字节。sizeof(pArr)计算的是整个数组的大小:3个指针 × 8字节 = 24字节。sizeof(pToArr)计算的是指针本身的大小:8字节。
这是一个快速判断变量本质的通用方法:数组用sizeof测得元素总大小,指针用sizeof测得的永远是地址空间的大小(32位系统固定4字节,64位系统固定8字节)。如果你不确定自己写的声明到底是个啥类型,拿sizeof一量就清楚了。
2. 内存模型:从底层彻底理解两种结构
2.1 指针数组的内存布局
指针数组本质上是一个“数组”,只不过元素从 int 变成了 int*。在内存中,它就是一段连续的内存空间,里面依次存放着3个地址。
我画一个文字描述的内存布局图,大家感受一下:
pArr 数组起始地址(假设 0x1000) +--------+--------+--------+ | 0x2000 | 0x3000 | 0x4000 | <- 存放的是地址 +--------+--------+--------+ pArr[0] pArr[1] pArr[2] | | | v v v +----+ +----+ +----+ | 10 | | 20 | | 30 | <- int 变量 +----+ +----+ +----+每个元素pArr[i]都是一个独立的指针,可以指向不同的地方。这也是指针数组最常见的用法——存放字符串,下面这段代码就是热搜词里提到的场景:
#include <stdio.h> int main() { // 指针数组存放字符串 const char *week[] = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday"}; for (int i = 0; i < 5; i++) { printf("week[%d] = %s\n", i, week[i]); } return 0; }注意这里到底发生了什么:week是一个数组,有5个元素,每个元素是const char *类型的指针,指向各个字符串常量在只读数据段中的起始地址。week数组本身占 5 × 8 = 40 字节(64位系统),字符串内容不在week里面,而是散落在内存的其他位置。
这是指针数组和二维字符数组的核心区别。你要是写成char week[5][10],那每一行是固定10字节的 char 数组,不管你存 “Monday” 还是 “Thursday”,每行都占10字节,5行总共50字节,存的内容就嵌在这块连续内存里。而指针数组只存地址,字符串实际在哪不关心,所以更灵活,但要注意字符串常量的生命周期和可修改性。
2.2 数组指针的内存模型
数组指针是一个指针,它指向一个完整的数组。在内存中,pToArr这个变量本身占8字节,存放的是“整个一维数组”的首地址。
#include <stdio.h> int main() { int arr[3] = {10, 20, 30}; int (*pToArr)[3] = &arr; // 注意:是 &arr 而不是 arr printf("arr[0] = %d\n", (*pToArr)[0]); // 10 printf("arr[1] = %d\n", (*pToArr)[1]); // 20 printf("(*pToArr)[2] = %d\n", (*pToArr)[2]); // 30 // 也可以不写括号,但要理解 pToArr[i] 和 arr[i] 的关系 printf("*(*pToArr + 1) = %d\n", *(*pToArr + 1)); // 20 return 0; }这里有一个关键点:arr和&arr在数值上是相同的(都指向数组的起始地址),但类型完全不同。arr退化后是int *类型,指向单个 int;而&arr是int (*)[3]类型,指向整个数组。
数组指针做+1操作时,步长是一个完整数组的大小(3个int = 12字节)。这跟普通指针做+1只跳过一个元素(4字节)有本质区别。指针运算的“步长”取决于指针指向的类型大小,这是理解数组指针的核心。
再看一个二维数组的场景,这两种类型的差异会更明显:
#include <stdio.h> int main() { int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}}; int (*pRow)[3] = matrix; // 指向第一行数组 // 用数组指针遍历二维数组 for (int i = 0; i < 2; i++) { for (int j = 0; j < 3; j++) { printf("%d ", pRow[i][j]); } printf("\n"); } return 0; }matrix在表达式中会退化成指向其第一个元素(即第一行数组)的指针,类型正好是int (*)[3]。所以int (*pRow)[3] = matrix;是合法的。这时候pRow[i][j]和matrix[i][j]的访问效果完全一样。
3. 指针数组与数组指针的核心应用场景
3.1 指针数组应用:命令行参数、字符串表与状态机
指针数组最常见的用途之一就是处理main函数的参数。你看任何 C 程序的入口:
int main(int argc, char *argv[])这里的argv就是一个标准的指针数组,每个元素指向一个命令行参数字符串。argv[0]是程序名,argv[1]到argv[argc-1]是传递给程序的参数。
#include <stdio.h> int main(int argc, char *argv[]) { // 这里 argv 就是一个指针数组 for (int i = 0; i < argc; i++) { printf("argv[%d] = %s\n", i, argv[i]); } return 0; }运行./test hello world时,输出:
argv[0] = ./test argv[1] = hello argv[2] = world除了命令行参数,指针数组还是实现字符串查找表、命令解析器、状态机跳转表等场景的利器。举一个命令解析的案例:
#include <stdio.h> #include <string.h> void cmd_help() { printf("Available commands: help, version, exit\n"); } void cmd_version() { printf("Version 1.0.0\n"); } void cmd_exit() { printf("Bye!\n"); } int main() { // 命令表:指针数组存字符串 const char *cmd_names[] = {"help", "version", "exit"}; // 命令处理函数表:也是一个指针数组(函数指针数组) void (*cmd_handlers[])() = {cmd_help, cmd_version, cmd_exit}; char input[64]; while (1) { printf("> "); if (scanf("%63s", input) != 1) break; int i; for (i = 0; i < 3; i++) { if (strcmp(input, cmd_names[i]) == 0) { cmd_handlers[i](); break; } } if (i == 3) { printf("Unknown command.\n"); } } return 0; }这个例子里用了两个指针数组,一个存字符串命令名,一个存函数指针。用“名称表+处理函数表”一一对应,新增命令时只需在两个数组中各加一项即可,代码不需要大规模改动。这种模式在实际项目中非常常见,比如嵌入式设备串口命令解析、网络协议解析器、Redis 或其他服务器程序里都能看到类似设计。
3.2 数组指针应用:二维数组作为函数参数
如果说指针数组是“处理多个字符串/地址”的利器,那数组指针的主要用武之地就是承接二维数组的函数传参。
以前很多初学者喜欢这么写函数:
// 这种写法接收不了 int matrix[2][3],编译直接报错 void print_matrix(int arr[][], int rows) { ... }问题是int arr[][]这种写法根本编译不过,因为二维数组作为参数时,除了第一维可以省略,第二维必须明确指定。如果要让函数能接收任意行数的二维数组,靠的就是数组指针。
#include <stdio.h> // 形参 int (*arr)[3] 表示:arr指向一个含有3个int的数组 // row 表示有多少行 void print_matrix(int (*arr)[3], int row) { for (int i = 0; i < row; i++) { for (int j = 0; j < 3; j++) { printf("%d ", arr[i][j]); } printf("\n"); } } int main() { int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}}; print_matrix(matrix, 2); // 数组名退化为数组指针 return 0; }关键点是matrix作为参数传给函数时,自动退化为指向其第一个元素的指针,第一个元素是int[3]数组,所以退化的结果就是int (*)[3],跟形参类型完全匹配。
如果你希望函数更通用,能处理任意列数的二维数组,就需要把列数和数组指针一起传进去。C90 里像int (*arr)[cols]这种变长数组参数是 C99 才支持的,有些编译器默认不开这个特性,所以实际项目里经常看到用int *arr加手动下标计算的方式来模拟二维数组:
#include <stdio.h> // 用 int* 接收二维数组首地址,手工算下标 void print_matrix_flat(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]); } printf("\n"); } } int main() { int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}}; // 将数组指针转成 int* 再传入 print_matrix_flat(&matrix[0][0], 2, 3); return 0; }这种写法打破了“第二维必须固定”的限制,代价是失去了编译器的类型检查,用起来要格外小心。工程实践上,二维定长数组用数组指针,只有在列数动态变化时才考虑手算下标的方案。
3.3 多维数组与指针的关联
很多人在学习二维数组时对a[i][j]这个表达式的背后机制感到困惑:为什么a[i][j]可以取出值?让我从数组指针的角度来解释。
对于int a[2][3],分析一下a[i][j]的推导过程:
a是二维数组名,在表达式中退化为指向第一行(int[3]类型)的指针,即int (*)[3]a[i]等价于*(a + i),指针后移 i 个数组,解引用得到第 i 行的数组名,这个数组名又退化为int *类型的指针,指向该行第0个元素a[i][j]等价于*(*(a + i) + j),在第 i 行的int*基础上再移动 j 个 int,解引用得到值
所以a[i][j]的完全展开形式是*(*(a + i) + j)。把这个公式记住,几乎所有二维数组指针相关的笔试题都能迎刃而解。
接下来看几个容易搞混的表达式:
| 表达式 | 含义 |
|---|---|
a | 类型int (*)[3],指向第一行的数组指针 |
*a | 等价于a[0],类型int *,指向第一行第0个元素 |
**a | 取值,等于a[0][0] |
a+1 | 指向第二行(跳过3个int) |
*(a+1) | 等价于a[1],类型int *,指向第二行第0个元素 |
*(a+1)+2 | 指向第二行第2个元素的int* |
*(*(a+1)+2) | 取到a[1][2] |
这个表格我建议收藏,笔试前看一眼能救急。多维数组的本质就是“数组的数组”,底层仍然是连续内存,只是通过多层指针访问来模拟多维逻辑结构。
4. 实战演练:数组与指针笔试题型实录
4.1 sizeof 与 strlen 陷阱题
这是最经典的笔试题目,想真正理解指针和数组必须过这一关。
#include <stdio.h> #include <string.h> int main() { char str[] = "hello"; char *p = str; printf("sizeof(str) = %zu\n", sizeof(str)); // 6:hello + '\0' printf("strlen(str) = %zu\n", strlen(str)); // 5:不包含 '\0' printf("sizeof(p) = %zu\n", sizeof(p)); // 8:指针大小(64位系统) printf("strlen(p) = %zu\n", strlen(p)); // 5:strlen只看内容 int arr[5] = {1, 2, 3, 4, 5}; int *pArr = arr; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20:5 * 4字节 printf("sizeof(pArr) = %zu\n", sizeof(pArr)); // 8:指针大小 return 0; }要点总结:sizeof(数组名)返回整个数组占用的字节数,sizeof(指针)返回指针本身的字节数。strlen永远只关心字符串内容,从指针指向的位置开始数到'\0'为止,跟这个指针类型无关。
另一种常见的变体是 sizeof 在函数参数中的表现:
#include <stdio.h> void func(int arr[]) { // 这里 arr 其实已经是 int*,不是数组了! printf("sizeof(arr) = %zu\n", sizeof(arr)); // 8,不是 20 } int main() { int arr[5] = {1, 2, 3, 4, 5}; func(arr); return 0; }数组作为函数参数时会退化为指针,所以在函数内部用sizeof永远得不到数组的真实大小。这也是面试题特别爱考的坑。正确的做法是传数组的同时把元素个数也传进去,或者用宏定义长度。
4.2 指针加减运算的步长陷阱
#include <stdio.h> int main() { int arr[5] = {1, 2, 3, 4, 5}; int *p = arr; printf("*p = %d\n", *p); // 1 printf("*(p + 1) = %d\n", *(p + 1)); // 2 printf("*(p + 4) = %d\n", *(p + 4)); // 5 // 关键:数组指针的步长 int (*pa)[5] = &arr; printf("pa = %p\n", (void*)pa); printf("pa + 1 = %p\n", (void*)(pa + 1)); // 比 pa 大 20 字节 return 0; }pa指向整个数组,pa + 1跳过了20个字节(5个 int),这就是“数组指针”和“普通指针”在步长上的本质区别。
在这类题目中,还有一个经典考点就是&arr + 1和arr + 1的区别:
#include <stdio.h> int main() { int arr[5] = {1, 2, 3, 4, 5}; int *p1 = (int *)(&arr + 1); // 跳过整个数组 int *p2 = arr + 1; // 跳过一个元素 printf("p1 - arr = %td\n", p1 - arr); // 5(指针差值为5个int的距离) printf("p2 - arr = %td\n", p2 - arr); // 1 // 经典坑:*(&arr + 1) - 1 指向数组最后一个元素 printf("*(&arr + 1) - 1 = %d\n", *((int *)(&arr + 1) - 1)); // 5 return 0; }这里&arr + 1指向数组后面的位置,减1再解引用就拿到最后一个元素。这种写法在遍历归并、栈操作等场景中会用到,笔试也常考。
4.3 二维数组与指针综合题
我出一道综合考题,可以自测一下是否真正理解:
#include <stdio.h> int main() { int a[2][3] = {{1, 2, 3}, {4, 5, 6}}; int *p1 = a[0]; // 指向第0行第0个元素 int *p2 = *a; // 等价于 a[0] int (*p3)[3] = a; // 指向第0行数组 printf("%d\n", *(p1 + 1)); // 2 printf("%d\n", *(p2 + 3)); // 4(p2是int*,+3跳过3个int) printf("%d\n", p3[0][2]); // 3 printf("%d\n", *(*(p3 + 1) + 1)); // 5 // 这个是最容易错的: printf("%p %p %p\n", (void*)a, (void*)*a, (void*)&a[0][0]); // 三个值打印出来一模一样!但类型不同。 return 0; }三个地址值在数值上相同,但类型分别是int (*)[3]、int *、int *。正是这种“值相同、类型不同”造成的混淆,让无数人在指针运算上翻车。笔试中最常考的就是基于这个差异的加减法。
5. 常见误区与排查技巧实录
5.1 误区1:int *p[3]和int (*p)[3]写法搞混
这个问题没有什么好办法,只能靠多写、多编译、多报错来形成肌肉记忆。我自己的习惯是看到一个复杂声明时,先找变量名,然后看变量名左边离得最近的符号是*还是[。
举个复杂一点的例子:
char *names[10]; // names是数组:10个char*类型元素 char (*str)[20]; // str是指针:指向含20个char的数组如果声明再复杂一些,可以用cdecl工具来解读,或者用在线工具。实际工龄超过3年的C开发基本不需要这类工具,但面试可以熟悉一下这种解读思路。
5.2 误区2:把“数组名”和“指向数组的指针”混为一谈
数组名在绝大多数表达式中会退化为指针,但有两个例外:sizeof(数组名)和&数组名。sizeof上面已经说了,&数组名的类型是数组指针而非元素指针。
我用一个表把常见场景列清楚:
| 表达式 | 类型 | 值 |
|---|---|---|
arr | int * | 数组首元素地址 |
&arr | int (*)[N] | 整个数组起始地址(数值同arr) |
arr + 1 | int * | 首元素地址+4字节 |
&arr + 1 | int (*)[N] | 首地址+N*4字节 |
*arr | int | 首元素值 |
*(&arr) | int[N](退化为int*) | 首元素地址 |
5.3 误区3:函数参数里写数组就万事大吉
void bad_func(int arr[100]) { // 这里的100没有任何实际意义! // 编译器会忽略它,arr依然是int* }很多人以为在参数里写int arr[100]就能限制“只接收100个元素的数组”,实际上没有任何作用。数组参数本质就是指针参数,这个100只是注释性质的存在。正确的做法是:
void good_func(int *arr, int size)记住一句话:函数参数里的数组永远是指针,长度信息不会被传递。这也是为什么 C 程序员会约定俗成地传入“首地址 + 长度”两个参数。
5.4 实测排查技巧:用编译器和调试器辅助判断类型
如果你实在不确定一个复杂声明是什么类型,有两个非常实用的排查手段:
第一,故意写错触发编译器报错,从报错信息里反推类型。例如:
int (*p)[5]; p = 10; // 故意赋int值编译时GCC会报类似warning: assignment to 'int (*)[5]' from 'int' makes pointer from integer without a cast的信息。看报错里带的类型名,一目了然。
第二,用 GDB 调试时ptype命令打印类型:
(gdb) ptype p type = int (*)[5]这比猜靠谱得多。
5.5 常见面试题速查
整理一份高频考题类型以及应对思路:
| 题目方向 | 考察点 | 应对思路 |
|---|---|---|
sizeof(数组) vs sizeof(指针) | 数组是指针吗 | 记住数组名在sizeof里是数组,在传参后是指针 |
&arr + 1指向哪 | 数组指针步长 | 跳过整个数组长度 |
a[i][j]的等价表达式 | 二维数组指针推导 | 展开为*(*(a+i)+j) |
int *p[3]类型是什么 | 优先级判定 | 先看左边最近的符号是*还是[ |
char *argv[]参数 | 指针数组应用 | 命令行参数的典型结构 |
const char *pvschar *const p | 指针常量和常量指针 | 先看const修饰的是谁 |
| 空指针、野指针、悬垂指针 | 指针安全 | 永远初始化指针,用完置NULL |
6. 一些进阶用法和工程建议
6.1 用 typedef 简化复杂声明
工程代码里我强烈建议用typedef给数组指针起一个别名,避免每次写声明都考验耐心:
typedef int (*ArrayPtr)[3]; typedef char *StringArray[10]; // 指针数组也建议起别名 void printArray(ArrayPtr arr, int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 3; j++) { printf("%d ", arr[i][j]); } printf("\n"); } }在代码审查的时候,清晰的类型别名能大幅减少“看声明猜类型”的时间。团队协作时这一点尤其重要。
6.2 动态二维数组的分配:指针数组的另一个高频用法
使用指针数组实现动态二维数组是面试和手写代码题中的常客:
#include <stdio.h> #include <stdlib.h> int main() { int rows = 3, cols = 4; // 用指针数组模拟二维数组 int **matrix = (int **)malloc(rows * sizeof(int *)); if (!matrix) return 1; for (int i = 0; i < rows; i++) { matrix[i] = (int *)malloc(cols * sizeof(int)); if (!matrix[i]) { // 释放前面已分配的空间 for (int j = 0; j < i; j++) free(matrix[j]); free(matrix); return 1; } } // 赋值和遍历 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { matrix[i][j] = i * cols + j; } } // 释放 for (int i = 0; i < rows; i++) { free(matrix[i]); } free(matrix); return 0; }注意int **是一个“指向指针的指针”,不是数组指针。matrix指向一个指针数组(每行分配出的连续 int 空间的首地址),这就是“指针数组”思想在动态内存管理中的直接应用。
6.3 代码规范经验
从经验角度提几点建议:
第一,数组指针类型一定要和实际的列数匹配。int (*p)[4]不能再指向int[3]的数组,否则类型编译器会警告,但强制转换也可以绕过,结果就是越界访问,排查起来非常头痛。
第二,回调函数里经常出现函数指针数组。比如各种定时器管理器、事件分发器,函数指针数组配合枚举作为下标,代码非常紧凑,这本质上是“指针数组”的思路,只是元素换成了函数指针。
第三,使用-Wall -Werror编译选项。新手写指针和数组相关的代码,最好开满警告选项,让编译器帮忙抓住类型不匹配、指针运算可疑的问题。很多野指针和越界问题在编译期是可以提前暴露的。
最后再分享一个实操小技巧
如果你在工作或面试中遇到一个特别复杂的指针声明,不要慌,有一个“顺时针螺旋法则”(Clockwise/Spiral Rule):从变量名开始,顺时针螺旋移动,依次读取修饰符。碰到*读“指向...的指针”,碰到[n]读“含有n个元素的数组”,碰到(f)读“参数为...返回...的函数”。遇到括号就先跳到括号里的部分处理。
比如int (*p[3])[5],从p开始:向右碰到[3],先读“数组”,然后跳回括号外,向左碰到*,读“指向”,再向右碰到[5],读“含5个int的数组”,最后读“int”。合起来就是:p 是一个含有3个元素的数组,每个元素是指向含5个int数组的指针。虽然这个工具描述的是声明语法,但多用来训练解读能力,真正上手后你会发现所有指针数组、数组指针、函数指针复杂声明都能一眼看穿。
以上就是我关于指针数组和数组指针的全部心得体会。在 C/C++ 的日常开发里,这一块的基础扎实程度,基本决定了你看别人源码的速度和写底层代码的信心。多写多练,遇到不懂的声明就动手验证,别凭感觉猜,时间长了这些概念会彻底变成你自己的东西。