数组名和指针,这两个概念在C/C++里被反复提起,新人容易绕晕,老手偶尔也会在最基础的寻址运算上翻车。我见过不少写了几年代码的人,在面对二维数组传入函数、指针数组和数组指针的选择时依然会卡壳。这篇帖子就把“指向数组的指针”这件事彻底讲透,包括它和一维、二维数组的关系,以及底层寻址运算的本质,看完你就能在代码里自信地写int (*p)[N]而不是int *p[N]了。
1. 先搞清楚:数组名到底是什么
在讨论指向数组的指针之前,必须先把数组名这个东西的本质弄清楚。很多C语言教材会告诉你“数组名就是指针”,这句话严格来说是错误的,或者是极度不严谨的。数组名确实在很多场景下会隐式转换为指针,但数组名本身是一个独立的、不可修改的实体,它代表的是整个数组对象。
1.1 数组名的真实身份:不只是一个地址
定义一个数组int a[5] = {1,2,3,4,5};,这里的a是什么?从类型系统的角度看,a的类型是int [5],也就是“包含5个int元素的数组类型”。当你写出a这个表达式时,在绝大多数上下文中,它会隐式转换为一个int*类型的指针,指向数组的第一个元素a[0]。这就是所谓的“数组退化”。
但在某些场景下,数组不会发生这种隐式转换。最典型的就是sizeof(a),它求的是整个数组占用的字节数,即5 * sizeof(int),而不是一个指针的大小。另一个场景是&a,它取的是整个数组的地址,类型是int (*)[5],即指向数组的指针。这两个特性非常重要,它们是区分“数组”和“指向其首元素的指针”之间关系的关键证据。
我在实际调试中经常利用这一特性来快速确认一个问题:当我写int *p = a;时,p指向的是a[0]的地址,而&a和a打印出来的数值虽然是一样的(都是数组起始地址),但步长完全不同。p + 1会跳到下一个元素,(&a) + 1则会跳过一个完整的数组,也就是直接跳过20个字节(假设int为4字节)。这个差异看起来很小,如果不理解,写起代码来就是隐患。
1.2 数组名在表达式中的隐式转换规则
C标准规定,除非是sizeof的操作数、一元&的操作数,或者用于初始化字符数组的字符串字面量,否则类型为“数组”的表达式都会转换为“指向其首元素的指针”。理解这个规则有两个实操意义:
第一,当你调用函数时,如果函数签名是void func(int arr[]),实际上编译器会将其调整为void func(int *arr)。所以你在函数内部用sizeof(arr)得到的是指针大小,不是数组大小。这也是为什么很多规范要求数组传参时必须额外传入长度。
第二,当你进行指针运算时,a + i和&a[i]是完全等价的。运算符[]本质上就是*(a + i)的语法糖。a[i]会被编译成*(a + i),这也就是为什么i[a]也是合法的写法,因为它等价于*(i + a),只是正常人不会这样写。
可以看到,数组名作为一个实体,它本身就是数组的“名字”,它不是指针变量,不能执行a++操作。但是它在表达式中用得多了,其行为非常像一个不可变的指针。在这种认知基础上,再去看指向数组的指针,就不会觉得它是一个神秘的东西——本质上就是用一个指针变量去保存整个数组的地址,并且让编译器知道这个指针的类型是int (*)[N]。
2. 四种“指针加数组”的形态,别被名字带偏
在网上搜索“指针数组”和“数组指针”相关热词时,能看到大量程序员在问这两者的区别。再加上“函数指针”“二级指针”,这四种形态构成了C/C++指针学习中最容易混淆的部分。
2.1 指针数组:数组里装的是指针
“指针数组”指的是“装着指针的数组”。定义一个例子:int *arr[5];。这里arr是一个数组,它有5个元素,每个元素的类型是int *。你可以把它想象成一个存放地址的列表。
- 声明语法:
类型 *标识符[元素个数] - 本质:是一个数组,只是每个元素的值是地址。
- 常见用途:用于存储多个字符串,比如
const char *str_arr[3] = {"hello", "world", "!"};。这种场景下,每个元素是一个const char *,指向一个字符串字面量。
需要特别注意的是,int *arr[5]中的arr自身类型是“int*的数组”,即int* [5]。数组名arr在表达式中退化为指向第一个元素的指针,也就是一个指向指针的指针(int **)。当你要遍历这个数组时,可以用双重指针来指向它:
c复制int *arr[5];
int **p = arr; // p指向arr的第一个元素,该元素本身是int*类型
与之相对的,如果你定义一个真正“指向数组的指针”,即int (*arr)[5];,那么这个arr指向的是一个含有5个int的完整数组,而不是指向第0个元素。这两者虽然只差一个括号,但含义天差地别。
2.2 数组指针:指向整个数组的指针
数组指针的声明形如int (*p)[5];。这里的括号必不可少,因为[]的优先级高于*,如果不加括号,int *p[5]就会变成指针数组。加了括号之后,(*p)表示p是一个指针,这个指针指向的对象类型是int [5]——一个含5个int元素的数组。
数组指针最典型的使用场景是二维数组的函数传参。例如:
c复制void print_matrix(int (*mat)[4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%d ", mat[i][j]);
}
printf("\n");
}
}
int a[3][4];
print_matrix(a, 3);
在这里,mat就是一个指向“含4个int数组”的数组指针。函数内部的mat[i][j]实际上被拆解为*((*(mat + i)) + j),这个我们等会会详细拆解。
2.3 函数指针:指向可调用实体的指针
函数指针与数组指针没有直接关系,但热度也很高,而且解决的是类似的问题:如何将一个函数作为参数传递。定义一个函数指针需要明确函数的签名,比如int (*func_ptr)(int, int);表示func_ptr指向一个接收两个int参数并返回int的函数。它本身不涉及数组的寻址,但在处理“回调函数”“策略模式”时非常实用。
2.4 二级指针:指针的指针
二级指针int **pp;是另一个高频概念。它的用途通常是函数内部要修改外部指针变量的值,或者在主函数中处理char *argv[]这一类参数。二级指针和“指向数组的指针”容易混淆的地方在于,二维数组作为参数时,如果退化成指针,它退化成的是一级指针(指向数组的指针),而不是二级指针。真正要操作一个指针数组的指针时,才用二级指针,两者不能混用。
我把这四种形态整理成一张表:
| 写法 | 含义 | 典型场景 |
|---|---|---|
int *a[5] |
指针数组:数组,元素为指针 | 字符串集合、动态二维结构 |
int (*a)[5] |
数组指针:指针,指向含5个int的数组 | 二维数组传参、矩阵操作 |
int (*func)(int) |
函数指针:指向函数 | 回调、接口调度 |
int **pp |
二级指针:指向指针的指针 | 动态矩阵、修改指针值 |
理解了四种形态之后,再回到“指向数组的指针”这个概念本身,我们就可以去拆解其底层寻址运算了。这是本篇最核心的内容。
3. 寻址运算的核心:一维数组与二维数组的步长差异
寻址运算在C语言中是一个相对底层的机制,它依赖的是“数据的类型”。每个指针在编译器眼中不仅是一个地址数值,还携带了它所指向对象的类型信息。不同类型的指针加1,跳过的内存字节数是不同的。理解这一点是掌握指针运算的钥匙。
3.1 指针加法与步长的关系
假设有int a[4]和int *p = a;,表达式p + 1的结果不是p的地址加1,而是加sizeof(int),通常是4个字节。这是由编译器自动完成的。指针的步长决定了它加1时能够正确跳过一个同类型的对象。这种设计的意义在于,当你在数组上做遍历时,p + i可以直接得到第i个元素的地址,而不需要手动去乘以元素大小。
对于数组指针int (*q)[4];,q + 1的步长则是sizeof(int) * 4,也就是16字节。因为q指向的“对象”是含4个int的数组,所以每加1,就跳过整个数组。这个步长差异是“指向数组的指针”和“指向元素指针”之间最本质的区别。
3.2 一维数组寻址的逐步分解
来看一个简单例子。定义:
c复制int a[4] = {10, 20, 30, 40};
int *p = a; // 等价于 int *p = &a[0];
下面几种写法之间的关系要非常熟悉:
a的类型是int[4],在表达式里退化为int*,数值等于&a[0]。&a的类型是int(*)[4],数值上等于&a[0],但类型不同,步长不同。*a等价于a[0],值为10。*(a + 1)等价于a[1],值为20。
有意思的是a和&a数值一致但类型不同。实际编码中,如果你写int *p = &a;,现代编译器会给出类型不兼容的警告,因为&a的类型是int (*)[4],不能赋值给int *。但很多旧代码里会这样写,虽然能运行,但逻辑上是危险的。
3.3 二维数组寻址的层层拆解
二维数组的理解是所有指针相关话题中最绕的一个。定义一个三行四列的矩阵:
c复制int arr[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
arr的类型是int[3][4],也就是“含有3个元素,每个元素是一个含4个int的数组”的数组。当arr在表达式中出现时,它退化为指向其第一个元素的指针。第一个元素是什么?是arr[0],一个类型为int[4]的数组。因此,退化后的指针类型是int (*)[4],即“指向含4个int数组的指针”。这就是二维数组名和数组指针之间的关键桥梁。
接下来看各个关键表达式的含义:
arr:类型是int(*)[4],指向第0行的起始地址。arr + 1:类型仍是int(*)[4],指向第1行的起始地址。二者差值等于4 * sizeof(int)。*arr:对arr解引用,得到第0行数组,即一个int[4]类型的对象。在表达式中它会再一次退化为int*,指向arr[0][0]。*(arr + 1):得到第1行数组,退化为int*,指向arr[1][0]。*(arr + 1) + 2:这是一个int*加2,指向arr[1][2]。*(*(arr + 1) + 2):对上述地址解引用,得到元素arr[1][2]的值,即11。
所以,二维数组的下标访问arr[i][j]被编译器翻译成*(*(arr + i) + j)。先通过arr + i行定位,再解引用得到行数组的指针,再通过+ j列定位,最后解引用得到元素。
这里容易困惑的一点是:arr[0]明明是“一维数组”但不是指针,那你把它写成int* p = arr[0]时为什么可以?答案是arr[0]这个表达式作为值使用时,它本身是一个数组,会按规则退化为指向首元素的指针,也就是int*。但&arr[0]不会退化,它得到的是int(*)[4]类型。这些细节不要靠背,而是要通过实验去体会。
我推荐一个快速验证方法:直接打印各种表达式的类型和步长,用一些技巧让编译器报告类型。或者更简单,用sizeof:
c复制printf("sizeof(arr) = %zu\n", sizeof(arr)); // 48,3*4*4
printf("sizeof(arr[0]) = %zu\n", sizeof(arr[0])); // 16,一行4个int
printf("sizeof(&arr[0]) = %zu\n", sizeof(&arr[0])); // 8,指针大小
通过这个测试,你能直观感受到“数组”和“指向数组的指针”之间的层级差别。
4. 深入函数传参:如何正确传递二维数组
知道了寻址运算的原理,接下来要把这些知识落到函数传参这个高频场景中。二维数组传参是一个典型的雷区,很多人一上来就写void func(int **arr),但如果你传入的是一个逐行连续的二维数组int a[3][4],这样的传参是无法通过编译的,原因就在于类型不匹配。
4.1 二维数组传参的三种正确写法
第一种,使用数组指针:
c复制void func1(int (*arr)[4], int rows) { ... }
这里arr是“指向含4个int数组的指针”,它能够接收int a[3][4]。arr[i][j]直接可用,是最推荐的方式。
第二种,直接使用二维数组语法糖:
c复制void func2(int arr[][4], int rows) { ... }
编译器会把int arr[][4]调整为int (*arr)[4],所以和第一种本质上是一样的。这种写法可读性好,但要注意第一维可以不写,第二维必须写死。如果你要传入任意列数的矩阵,就需要方案三。
第三种,展平为一维指针:
c复制void func3(int *arr, int rows, int cols) { ... }
调用时写func3(&a[0][0], 3, 4),函数内部用arr[i * cols + j]取值。这种方式灵活性最高,适用于动态分配或列数不固定的矩阵。
4.2 为什么不能直接写 int**?
二维数组的内存布局是连续的,数组的各行首地址之间的间隔是固定的列数 * sizeof(int)字节。而int**表示的是一个指向“指针”的指针,它通常用于指向“指针数组”的第一个元素。当你从int**的角度去访问时,编译器会先取int*,再解引用。但如果你给它的是一个二维数组的起始地址,那个地址处的数据是int,而不是int*,取出来的“地址”根本就是乱来的,程序极易崩溃。
我建议在接口设计时,对于二维数组优先使用int (*arr)[N]这种形式,因为它明确表达了“每行有N列”这一约束,编译器还能帮助你做边界检查,避免很多坑。如果需要处理变长列数,则使用展平方案,并配套传入行列数。
4.3 函数内部如何正确遍历
假设采用第一种方案:
c复制void print_matrix(int (*mat)[4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%4d", mat[i][j]);
}
printf("\n");
}
}
这个循环里,mat[i][j]的寻址过程就是*(*(mat + i) + j)。因为mat是指向行的指针,步长是16字节,mat + i定位到第i行;第一次解引用得到行数组;再加j步进入具体元素;第二次解引用得到值。整个过程中编译器都知道了列数是4,所以可以准确换算地址。
同样的逻辑也可以移植到动态分配的多维数组。假如你使用malloc逐行分配内存,那每一行的地址是不连续的,你获得的是一个int**。这时只能使用int**风格访问,不能直接和int a[3][4]混用。很多人想用一个通用的函数去处理这两种矩阵,还是建议分成两个函数,或者用模板(C++)或泛型宏(C11)来区分。
5. 运算符优先级,这里最容易被忽略
指向数组的指针虽然在使用上很直接,但声明读起来很痛苦。不理解运算符优先级,你会被int *a[5]和int (*a)[5]的差异逼疯。
5.1 声明解析技巧
C语言的声明可以被拆成“你有声明标识符,从标识符开始,按照运算符优先级组合类型”。以int (*a)[5]为例:
- 从
a开始,先看括号里的*a,说明a是“指针”。 - 括号外面有
[5],说明这个指针指向一个含5个元素的数组。 - 数组元素类型是int。
在int *a[5]中,[]优先级高于*,所以先看a[5],说明a是数组,有5个元素,元素类型是int*。整个声明解析的过程其实非常有规律,建议遇到复杂声明时都这样拆解,不要在脑子里直接读。
另一个技巧是使用工具cdecl来解析复杂声明。很多在线工具和命令行工具可以把英文描述转换成C声明,也可以反向解析,极大减少脑力消耗。
5.2 常见错误写完就崩溃的代码
有两个常见写法是初学者特别容易搞混的:
错误一,把二维数组赋值给int**:
c复制int a[3][4];
int **p = a; // 编译器警告或错误
这行代码的问题在于类型不匹配。a退化成int (*)[4],不是int **。即使强行编译通过,运行p[i][j]时也会因地址解释错误而崩溃。
错误二,试图用int**去遍历一个连续的二维数组。
c复制int a[3][4] = {0};
int **p = (int**)a; // 强制转换,危险
p[0][0] = 1; // 可能崩溃或者写坏内存
这也是把“指针的指针”和“指向数组的指针”混淆。要记住,int**访问的内存模型是“指针数组”,而int(*)[4]访问的内存模型是“连续排布的多维数组”。两者的解释方式不同,切不可随意强转。
5.3 宏定义与数组清零的坑
热搜词里频繁出现“宏定义数组”“数组清零”相关话题,这里顺带提一下。如果你用宏定义数组时写上#define ARR_INIT {1,2,3},然后用来初始化数组是可以的。但如果你想把一个二维数组整体清零,好的做法是用memset(arr, 0, sizeof(arr));,前提是你传的是数组名,而不是已经退化成指针的变量。否则,sizeof就变成指针大小,memset根本不会清完所有数据。
另外,宏定义数组时,注意别在宏里面使用sizeof,除非你确认它应用于数组表达式。比如:
c复制#define CLEAR_ARRAY(a) memset(a, 0, sizeof(a))
int arr[5] = {1,2,3,4,5};
CLEAR_ARRAY(arr); // 正确,这里arr没退化,sizeof(arr)是20
但如果调用处传入的是int* p = arr; CLEAR_ARRAY(p);,那sizeof(p)就是8,清不全。这就是为什么数组在传递时尽量维护原始数组表达式的好处。
6. 经验之谈:别把指针级别搞混,先从类型开始
写代码这么多年,踩过不少和指针相关的坑。最大的体会是:如果你不确定某个表达式的类型,先把自己当成编译器,一步步地分析。不要试图背结论,因为数组和指针的组合方式太多,背不过来。
举一个常见的快速判断方法:看定义时,有没有括号。有括号的多半是“数组指针”,没括号的是“指针数组”。而在使用的时候,把二维数组和一级指针、二级指针分清楚,本质上就是搞清楚“我拿到的东西到底是指向数据还是指向指针”。
在项目里我有一条不成熟但有效的经验:如果你发现自己为了绕开类型不匹配而加了很多强制转换,那大概率是设计出了问题。正确的做法是让类型系统帮助你表达意图,而不是去对抗它。比如矩阵函数一律用“数组指针加行数”的形式,动态分配的矩阵则用两个独立的函数,或者统一用包装结构体。
多写多练仍然是掌握指针的唯一路径。我建议你可以在本地做这几个实验:
- 写一个一维数组,分别打印
a、&a[0]、&a的数值和sizeof结果,观察步长。 - 写一个二维数组,打印
arr、arr+1、&arr+1的差值。 - 写一个函数接收
int (*)[4],里面用arr[i][j]遍历,再用同一个函数接收int a[3][4]调用它。 - 尝试把
int**和int(*)[4]分别传给同一个期望参数的函数,体会编译器的反应。
这些实验做一遍,比看十篇博客都管用。真正理解了步长、类型、隐式转换,指向数组的指针就不再是拦路虎,而是一个非常顺手的工具。遇到复杂声明时,用cdecl工具辅助,能省掉大量时间。后面有机会我还可以再写一篇关于C++中如何用模板、std::array、std::vector绕开这类底层麻烦的话题,欢迎继续讨论。
