
在C++的面试和实际编码中,指针数组和数组指针经常被混为一谈。它们的中文名称只差一个字的顺序,但指向的对象、内存占用以及参与运算时的步长都截然不同。很多开发者写出类似int *p[5]或int (*p)[5]的声明后,并不能立刻说出p到底是什么类型。这篇文章从运算符优先级和内存模型两个层面把这件事彻底讲透,并给出一个通用的复杂声明记忆口诀,以后遇到再怪异的指针声明都能迎刃而解。
运算符优先级决定声明本质
要理解指针数组和数组指针的区别,必须先回到C++声明语句的解析规则。在C++中,方括号[]的优先级高于解引用运算符*,而圆括号()拥有最高优先级,可以改变默认的结合顺序。这个优先级规则是区分int *p[5]和int (*p)[5]的根源。
对于int *p[5],由于[]优先级高于*,p先和[5]结合,说明p是一个包含5个元素的数组。然后数组元素的类型是int *,也就是指向int的指针。所以p是一个数组,数组中存放的是指针,这就是指针数组。每个元素都是一个独立的指针,可以指向不同的int变量,也可以指向动态分配的内存块。sizeof(p)的结果是5个指针的大小,在64位系统上是5乘8字节等于40字节。
而对于int (*p)[5],圆括号强制p先和*结合,意味着p首先是一个指针。然后这个指针指向的类型是int [5],即一个包含5个int元素的数组。所以p是一个指针,它指向一个数组,这就是数组指针。在64位系统上,sizeof(p)只等于一个指针的大小,通常是8字节。p本身不包含数组内容,它只保存数组首地址,通过p可以访问整个数组。理解了这一点,后续的指针运算规则就顺理成章了。
内存布局与指针运算步长差异
指针数组和数组指针在内存中的组织方式完全不同。对于指针数组int *p[5],编译器会在栈上或静态存储区分配连续5个指针变量的空间。这5个指针各自独立,它们所指向的int对象可以分布在内存的任何位置,彼此之间没有连续性要求。例如可以让p[0]指向一个局部变量,让p[1]指向堆上new出来的int,让p[2]指向全局数组的第3个元素。改变其中一个指针的值不会影响其他指针。
而数组指针int (*p)[5]是一个单独的指针变量,它指向的是一整块连续的内存,这块内存恰好容纳5个int。p本身只占一个指针的大小,但它所指向的数组是连续存储的。对p执行加1操作时,指针的偏移量不是1个字节,也不是1个int的大小,而是整个数组的大小,即5 * sizeof(int)。这正是数组指针在多维数组传参时体现出巨大价值的原因。下面用一个完整的代码示例验证这两种类型的运算差异。
#include <iostream>
using namespace std;
int main() {
// 指针数组:5个独立的int指针
int a = 10, b = 20, c = 30, d = 40, e = 50;
int *parr[5] = {&a, &b, &c, &d, &e};
cout << "指针数组 sizeof: " << sizeof(parr) << endl;
cout << "parr[0] 指向的值: " << *parr[0] << endl;
// 数组指针:一个指向含5个int的数组的指针
int arr[5] = {1, 2, 3, 4, 5};
int (*parr2)[5] = &arr;
cout << "数组指针 sizeof: " << sizeof(parr2) << endl;
cout << "parr2 指向数组的第一个元素: " << (*parr2)[0] << endl;
// 验证步长
cout << "parr2 地址: " << parr2 << endl;
cout << "parr2+1 地址: " << parr2 + 1 << endl;
cout << "步长差值(字节): "
<< (char*)(parr2 + 1) - (char*)parr2 << endl;
return 0;
}
运行这段代码会看到,sizeof(parr)输出40(64位系统),sizeof(parr2)输出8。最后的步长差值会输出20,因为5个int共20字节,证明数组指针的步长确实是一整个数组的长度。这个特性在传递二维数组时特别关键,比如函数参数写成void func(int (*p)[5], int rows)就能正确处理每一行长度为5的二维数组。
复杂指针声明的通用阅读口诀
C++中还有大量比指针数组和数组指针更复杂的声明,例如int **p[6]、int (*p)(int, int)、int *(*p[3])(char*)等。盲目背诵每个具体形式不现实,必须掌握一套从声明本身推导出类型的系统方法。最常用的口诀是“从内向外读,从变量名出发,先看圆括号内,再看右边括号,最后看左边类型”,也可以结合“右手螺旋法则”:从变量名开始,按顺时针方向螺旋向外解读,遇到数组括号说“数组”,遇到指针星号说“指针指向”。
以int (*p)[5]为例,从变量名p出发,先看到括号内的*,所以p是一个指针;然后看括号外的[5],说明指针指向的是一个包含5个元素的数组;最后看左边的int,确定数组元素类型为int。最终解读为“p是一个指向含5个int元素数组的指针”。再比如int *p[5],从p出发先看到右边的[5](因为优先级高),p是一个数组;再看左边的*,数组的元素是指针;最后看int,指针指向int。解读为“p是一个有5个元素的数组,每个元素是int指针”。
对于更复杂的函数指针,比如int (*p)(int, int),从p出发,括号内星号表示p是指针,然后看右边圆括号(int, int),p指向一个函数,这个函数接受两个int参数,最后看左边int,函数返回int。所以p是函数指针。这套口诀不需要死记硬背,只要在每次看到复杂声明时按固定顺序走一遍,熟练后两三秒就能判断类型。建议把下面这个综合例子抄写下来,对照口诀逐层拆解,以后遇到嵌套声明就不会发怵。
// 复杂声明的拆解练习 int *(*pfunc[3])(char *); // 拆解步骤: // 1. pfunc 右边是[3],pfunc是一个包含3个元素的数组 // 2. 数组的元素是(*)即指针 // 3. 该指针指向右侧的(char *),即一个函数,参数为char * // 4. 左侧int *表示函数返回int指针 // 最终:pfunc是一个有3个元素的数组, // 每个元素是函数指针,指向“接受char*返回int*”的函数
在实际编码中,可以用typedef把复杂声明拆成多个易读的别名。例如上面的int *(*pfunc[3])(char *)可以先用typedef定义函数指针类型typedef int* (*FuncPtr)(char*);,然后声明FuncPtr pfunc[3];,可读性会大幅提升。但typedef不能替代对原始声明语法的理解,因为很多第三方库和遗留代码仍然直接写复杂声明,阅读时口诀依然是最可靠的武器。
指针数组常用于存储一组相互独立的指针,比如多个字符串字面量的指针数组const char *names[] = {"tom", "jerry", "spike"};。数组指针则几乎总是和二维数组或多维数组传递绑定在一起,例如把一个固定列数的二维数组传给函数。明确两者的适用场景,再配合从内向外读声明的口诀,这一组容易混淆的概念就彻底清晰了。