数组名和指针,这两个概念在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", "!"};。这种场景下,每个元素是一个
