ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

数组指针与指针数组:内存布局、声明读法及函数传参详解

数组指针与指针数组:内存布局、声明读法及函数传参详解 数组指针和指针数组这两个名字放在一起几乎成了C/C初学者的一道鬼门关。我记得当年第一次在面试题里看到int *p[3]和int (*p)[3]的时候心里第一反应是“这俩不就是括号差了个位置吗能有多大区别”。结果真在编译器里跑起来一个是指针的数组一个是指向数组的指针内存布局完全不同用错了轻则编译警告重则直接段错误。更麻烦的是很多人死记硬背“带括号的是数组指针不带括号的是指针数组”但一到实际写代码还是分不清p[i]和*(pi)到底哪个对二维数组传参为什么老是类型不匹配。这篇文章我就用最直白的方式把这俩概念彻底拆开。从内存布局、声明读法、初始化访问、函数传参到面试高频考点把该踩的坑都替你踩一遍。你不需要有多年经验只要会最基本的C语言语法看完以后就能在代码里准确区分它们而且能解释清楚背后的原理不是靠死记硬背。1. 为什么这两个名字会让无数人栽跟头1.1 从中文语序看误解的来源中文术语“数组指针”和“指针数组”其实特别容易产生歧义。很多人会按字面理解“数组指针” 数组的指针 一个指向数组的指针“指针数组” 指针的数组 装着指针的数组。听起来挺合理但一到英文原意里就有点反直觉指针数组的英文是array of pointers意思是“指针构成的数组”本质是个数组每个元素都是指针。数组指针的英文是pointer to array意思是“指向数组的指针”本质是个指针它指向的目标是数组。所以中文里“指针数组”这个词按字面看像是“指针对数组”但实际意思是“装着指针的数组”。这就导致很多人在第一关就偏了。我见过不少人把“指针数组”理解成“可以当作数组用的指针”结果代码里写char *p[10]去存字符串然后试图p让它往前走这其实是不对的因为p是数组名不是指针变量。1.2 先跑一个最小的验证程序不管嘴上怎么解释都不如直接在编译器里看结果来得实在。下面这段代码是我当年用来强迫自己记住区别的“自测小实验”#include stdio.h int main(void) { int a 1, b 2, c 3; int *pa[3] {a, b, c}; // 指针数组装着3个int*指针 int arr[3] {10, 20, 30}; int (*pArr)[3] arr; // 数组指针指向含有3个int的数组 printf(sizeof(pa) %zu\n, sizeof(pa)); // 通常是 24 (3*8, 64位) printf(sizeof(pArr) %zu\n, sizeof(pArr)); // 通常是 8 (一个指针) printf(pa[1] %d\n, *pa[1]); // 输出 2 printf((*pArr)[1] %d\n, (*pArr)[1]); // 输出 20 return 0; }运行以后sizeof(pa)和sizeof(pArr)的差异会非常直观地打脸那些背概念的人。pa是数组所以sizeof得到的是整个数组占用的字节数pArr是指针sizeof得到的是指针本身的大小。这一步能让你立刻意识到两者根本不是一类东西。2. 内存布局拆解一个存地址数组一个指向数组2.1 指针数组数组里装的是指针先看指针数组。它的核心是“数组”只不过数组的每个元素类型是指针。说直白点你有一个连续的内存块里面划分成若干个格子每个格子里存放的是一个地址。int *ptrArr[3];这行代码告诉编译器我声明了一个数组名字叫ptrArr这个数组有 3 个元素每个元素都是int *类型的指针。内存布局大致是这样从栈上或全局区分配一块能容纳 3 个int *的连续空间。这个空间本身不存 int 数据只存 int 变量的地址。如果没初始化那里面是随机值绝对不能解引用。用图来表达的话可以想象成一排信箱每个信箱里放着一张写着门牌号的纸条。你拿着第 2 个信箱的纸条就能找到对应的房间int 变量。所以访问的时候通常是*(ptrArr[i])或*ptrArr[i]先拿到地址再解引用。指针数组最常见的应用之一是存放一组字符串const char *strs[] {hello, world, c language};这里strs是一个const char *数组数组里每个元素都是指向字符串字面量首字符的指针。注意strs[1]得到的是world字符串首字符w的地址而不是整个字符串的“值”。很多人以为strs[1]就是字符串严格来说是strs[1]这个指针指向了那个字符串的首字符打印时通过%s会自动从这个地址开始读直到\0。2.2 数组指针一个指针它盯着一整块数组数组指针就完全反过来了。它本质上是一个指针变量这个变量保存的是一个数组的地址。int (*pArr)[3];这里pArr是一个指针指向类型为int[3]的数组。也就是说pArr这个指针知道它指向的对象是一整块连续的 3 个int。如果pArr指向了某个int[3]数组的首地址那么*pArr就代表整个数组作为左值使用时代表数组名(*pArr)[0]、(*pArr)[1]、(*pArr)[2]才能访问数组中的元素pArr 1会跳过整整 3 个int的大小而不是一个int。内存布局上假设你有一个int arr[3] {10, 20, 30};那么arr本身占 12 字节32位 int 下64位也是 12 字节pArr这个指针单独占 4 或 8 字节。pArr里存的值是arr首元素的地址但它的类型告诉编译器“我指向的是长度为 3 的 int 数组”所以指针加减时的步长是3 * sizeof(int)。这里有个特别重要的细节arr和arr在数值上是一样的都是数组首地址但类型不同。arr的类型是int *退化为指向首元素的指针而arr的类型是int (*)[3]也就是数组指针。虽然二者打印出来的地址值相同但含义完全不同int arr[3] {10, 20, 30}; int *p1 arr; // OKp1 指向第一个元素 int (*p2)[3] arr; // OKp2 指向整个数组 printf(%p %p\n, (void *)p1, (void *)p2); // 地址打印通常一样 printf(%d\n, p1[1]); // 20按 int 步长走 printf(%d\n, (*p2)[1]); // 20先解引用得到数组再取元素2.3 用sizeof和取地址彻底看穿两者sizeof是区分两者最直接的工具没有之一。在 C 语言里sizeof是编译期运算符它会根据表达式的静态类型计算结果不会真正执行。所以对指针数组int *pa[3]sizeof(pa)得到的是3 * sizeof(int *)在 64 位平台就是 24。对数组指针int (*pArr)[3]sizeof(pArr)得到的是sizeof(int (*)[3])也就是一个指针的大小通常 8。对数组指针解引用后的*pArr其类型是int[3]所以sizeof(*pArr)得到的是3 * sizeof(int)即 12。还有一个经典操作用取地址看类型变化。arr得到的就是数组指针类型把它赋给int *会产生编译警告很多初学者不明所以其实本质就是类型不匹配。反过来如果把arr直接赋给int (*p)[3]同样编译警告因为arr退化成int *类型是元素指针不是“数组指针”。记住一个判定逻辑如果sizeof作用于一个表达式得到的是指针大小时那它本质是指针如果得到的是多元素连续内存大小时那它本质是数组。这比死记“括号里有括号就是数组指针”要可靠得多。3. 声明读法的核心武器右左法则3.1 从int *p[3]和int (*p)[3]说起C 语言的声明可以非常复杂但有一套系统化的阅读方法叫“右左法则”Right-Left Rule。核心步骤是先找到被声明的标识符变量名。从标识符开始先向右看遇到]或)就停下来处理对应结构。再向左看找到*或类型关键字。遇到括号()或[]就递归地应用规则。拿int *p[3]来举例变量名是p。向右看看到[3]说明p是一个数组有 3 个元素。再向左看看到*说明数组元素是指针。再向左看看到int说明指针指向的是int。所以连起来读p是一个拥有 3 个元素的数组每个元素是int *指针。没错这就是指针数组。再看int (*p)[3]变量名是p。向右看遇到)说明p先被括号括住了暂时不能向右看。向左看看到*说明p是一个指针。括号结束再向右看看到[3]说明p指向的是一个拥有 3 个元素的数组。再向左看看到int说明这个数组的元素类型是int。连起来读p是一个指针指向一个拥有 3 个int元素的数组。这就是数组指针。3.2 复杂声明逐步拆解右左法则不仅适用于这两个还能解所有复杂声明。来看一个经典的进阶声明int (*p[5])[3];按右左法则变量名是p。向右看看到[5]说明p首先是数组有 5 个元素。向左看看到*说明数组元素是指针。但这里有个括号(*p[5])括号内优先先处理完。现在p整体是“5 个元素的数组每个元素是指针”。括号外再向右看看到[3]说明这个“指针”指向的是一个有 3 个元素的数组。向左看看到int说明数组元素是int。所以int (*p[5])[3]的含义是p是一个含有 5 个指针的数组每个指针指向一个含有 3 个int的数组。这是一个“指针数组”数组元素是指向int[3]的指针。有没有懵很正常这种声明在实际项目里很少直接用但如果能读懂就说明你真正掌握了声明规则。另一个常见复杂声明是函数指针int (*fp)(int, int);fp先向左看到*是指针。向右看到(int, int)说明指向的是函数参数两个 int。向左看到int说明函数返回值是 int。这就是一个典型的函数指针。如果把括号去掉写int *fp(int, int)那就变成了一个函数声明fp是一个函数参数是两个 int返回值是int *。差一个括号从“指针指向函数”变成了“函数返回指针”这俩完全不是一个东西。3.3 typedef 让声明变成人话复杂的裸声明可读性非常差工程上一般用typedef给复杂类型起别名。针对数组指针和指针数组合理的做法是typedef int Array3[3]; // Array3 是 int[3] 类型 typedef int (*ArrayPtr)[3]; // ArrayPtr 是指向 int[3] 的指针类型 typedef int *IntPtr; // IntPtr 是 int* 类型然后声明变量就舒服多了Array3 arr {1, 2, 3}; ArrayPtr pArr arr; IntPtr pArr2[3]; // 指针数组等效于 int *pArr2[3]用typedef能有效降低理解成本特别是函数指针那种复杂场景typedef int (*Callback)(int, int); void register_callback(Callback cb) { // ... }不过在面试或笔试里面试官通常不允许你直接typedef去规避问题而是要你解释裸声明。所以规则本身还是得掌握typedef只是锦上添花。4. 实操对比定义、初始化与访问的完整代码4.1 指针数组的典型用法字符串表指针数组最经典的应用是存放一组字符串。因为字符串本质是字符数组C 语言里没有直接的 string 类型所以通常用char *指向字符串首字符。如果有一组字符串最自然的做法就是用一个数组把这组指针装起来#include stdio.h int main(void) { const char *cmds[] { help, version, exit, NULL // 用 NULL 做结束标记 }; for (int i 0; cmds[i] ! NULL; i) { printf(cmd[%d] %s\n, i, cmds[i]); } return 0; }注意这里的cmds数组元素类型是const char *因为字符串字面量是只读的如果用char *修改它在 C 标准中是未定义行为。很多编译器给了警告但有些老代码会忽略这其实很危险。指针数组也可以用于“以整数作为索引快速跳转到对应处理逻辑”的分发表比如状态机。void handle_create(void) { /* ... */ } void handle_delete(void) { /* ... */ } void handle_update(void) { /* ... */ } void (*handlers[])(void) {handle_create, handle_delete, handle_update};这个handlers是一个函数指针数组。要调用时直接handlers[cmd]()即可。注意它也是指针数组只是元素是函数指针。4.2 数组指针的典型用法二维数组的行指针数组指针最常见的场景是处理二维数组。C 语言里二维数组在内存中是按行连续存储的每一行实际上是一个一维数组。那么“指向一整行”的指针就是数组指针。假设有int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} };matrix的类型是int[3][4]。matrix作为右值时会退化成“指向第一行的指针”也就是int (*)[4]。所以int (*rowPtr)[4] matrix; // OKrowPtr 指向 matrix 的第一行 for (int i 0; i 3; i) { printf(row %d first element: %d\n, i, **rowPtr); rowPtr; // 指向下一行 }这里有一个特别重要的点rowPtr会让指针跳过4 * sizeof(int)个字节因为rowPtr的类型是“指向含 4 个 int 的数组的指针”它的步长是整个数组的长度。这和int *p matrix[0][0]; p只跳过一个 int 完全不同。用数组指针遍历二维数组的标准写法是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(%4d , mat[i][j]); } putchar(\n); } }注意函数形参里的int (*mat)[cols]这在可变长度数组VLA出现后是合法的但在 C89 里只能用固定常量。如果cols是常量那没问题如果想让函数支持任意列数二维数组C99 支持变长数组做形参但要注意编译器兼容性。4.3 函数传参时两者的区别与常见误区函数传参是重灾区。很多人写一个打印一维数组的函数用的是void f(int *a, int n)这没问题因为数组名退化成首元素指针。但二维数组传参时类型必须匹配。看下面这个错误示例void bad_print(int **arr, int rows, int cols) { // 错误 // 用 arr[i][j] 会崩溃或取错值 }如果把int matrix[3][4]直接传给int **arr编译器会报警告因为matrix退化的类型是int (*)[4]不是int **。int **是指向指针的指针它要求内存中先有一个指针数组然后每个指针再指向某个一维数组。而二维数组的内存是连续的若干 int并没有额外的指针数组。所以二者完全不是同一种布局。正确的传参方式有这样几种固定列数的数组指针void print_fixed(int (*mat)[4], int rows) { ... }变长数组void print_vla(int rows, int cols, int mat[rows][cols]) { ... }一维化传参void print_flat(int *mat, int rows, int cols) { for (int i 0; i rows; i) for (int j 0; j cols; j) printf(%d , mat[i * cols j]); }第三种方式虽然形参是int *但调用时传入matrix[0][0]也就是二维数组首元素的地址。这种做法的好处是不依赖行指针类型但坏处是破坏了“按行访问”的语义需要自己算下标。在回调函数、泛型指针场景下也会出现void *和数组指针的配合。比如你写一个通用的排序函数想让它支持排序任意类型的数组通常用void *base接收数组首地址再用size_t nmemb和size_t size描述元素个数和大小然后用memcpy交换元素。这种情况下传入的数组名会退化成void *也就不区分数组指针和指针数组了但前提是在函数内部通过字节操作来完成访问。5. 进阶二级指针、数组名退化与多维数组的恩恩怨怨5.1 数组名是常量指针吗很多人学过“数组名就是指针”但更准确地说数组名不是指针只是在大多数表达式中会“退化”为指向其首元素的指针。比如int arr[5]; int *p arr; // arr 退化为 int*但数组名本身不是变量不能执行arr或arr p。很多人把数组名称为“常量指针”这个类比有一定道理但严格说并不完全正确。arr没有地址可赋值它有类型int[5]而“常量指针”是int *const是一个指针类型的常量变量它本身占内存只是不能改指向。数组名根本不占指针的存储空间它只是编译器内部用来表示数组起始地址的符号。这也解释了为什么sizeof(arr)是整数组大小而不是指针大小为什么arr是数组指针类型而不是int **。数组名不是指针它只有在表达式值右值使用时才退化为指针。5.2 二维数组名与数组指针的亲密关系我们来看这个声明int a[3][4];a的类型是int[3][4]在表达式中它会退化为int (*)[4]也就是指向第一行的指针。因此下面的写法等价int (*p)[4] a; int (*p)[4] a[0];因为它们都指向同一块地址只是表达方式不同。要访问a[i][j]用行指针来理解很方便a[i]本身就是第 i 行的数组名退化为int *然后再取[j]就能访问到元素。如果直接用p来访问那么p[i]就是第 i 行的数组名类型int *p[i][j]等同于*(p[i] j)。所以p[i][j]和a[i][j]等价。这个等价关系就是二维数组和数组指针之间最直接的桥。很多人会混淆int **与int (*)[N]。用一个例子说明int *ptrs[3]; int **pp ptrs; // OK指针数组名退化成指向指针的指针 int arr[3][4]; int **pp2 arr; // 编译警告类型不兼容int **要求“指向指针的指针”也就是它指向的内存里存的是一个指针值。而二维数组arr的内存里存的全是 int并没有一串指针所以完全不能直接赋值。除非你用int *ptrs[3]把每一行的地址装进一个指针数组然后int **pp ptrs这样才能用两个解引用访问二维数据。5.3 用数组指针写一个通用的二维数组打印函数下面我们写两个打印函数对比一下不同实现方案#include stdio.h #define ROWS 3 #define COLS 4 void print_by_array_pointer(int (*mat)[COLS], int rows) { for (int i 0; i rows; i) { for (int j 0; j COLS; j) { printf(%4d , mat[i][j]); } putchar(\n); } } void print_by_flat(int *mat, int rows, int cols) { for (int i 0; i rows; i) { for (int j 0; j cols; j) { printf(%4d , mat[i * cols j]); } putchar(\n); } } int main(void) { int matrix[ROWS][COLS] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; puts(print_by_array_pointer:); print_by_array_pointer(matrix, ROWS); puts(print_by_flat:); print_by_flat(matrix[0][0], ROWS, COLS); return 0; }print_by_array_pointer的形参就是数组指针它保留了“每行有 COLS 个元素”这一信息所以函数内部可以直接用二维下标。print_by_flat把二维数组拍扁成一维来访问不依赖行信息但需要自行换算索引。两种方式都行但应用场景不同前者语义更清晰后者更灵活能处理任意宽度但调用时要小心首地址的类型转换。6. 实测踩坑我在这两个概念上交过的学费6.1 sizeof(指针)的陷阱有一次我写代码想统计一个指针数组的长度习惯性地写了const char *cmds[] {help, version, exit, NULL}; int count sizeof(cmds) / sizeof(cmds[0]);这个是对的因为cmds是数组。但后来有一次我把cmds传进函数后又想在函数内部算长度void show_commands(const char **cmds) { int count sizeof(cmds) / sizeof(cmds[0]); // 错误 // cout 会变成 1因为 sizeof(cmds) 是指针大小 }这就是典型的“数组名在函数参数中退化为指针”的坑。形参const char **cmds虽然长得像数组但它就是一个指针变量sizeof得到的是 864位平台sizeof(cmds[0])也是 8除下来是 1。这种错误不会报编译警告但结果完全不对。解决办法是显式传一个长度参数或者用结束标记比如 NULL。6.2 误用p1导致的段错误另一次我在用数组指针遍历二维数组时不小心写成了p 1却自以为是“行加一”但类型搞错了。我原本写的是int *p matrix[0][0]; for (int i 0; i ROWS; i) { // 想输出第i行的元素 for (int j 0; j COLS; j) { printf(%d , p[j]); } p p COLS; // 这样是对的 }但后来我为了“简洁”把p声明成了int (*p)[COLS]却继续用p COLS来跳行int (*p)[COLS] matrix; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { printf(%d , p[j]); // 这里直接越界了 } p p COLS; // 错误p 已经按整行步长再加 COLS 会跳非常多行 }这个 bug 的根源是没搞清p的类型。p是int (*)[4]p1已经跳 4 个 int 了再加COLS就是跳16 * sizeof(int)字节直接越界。排查了快半小时最后用printf(%ld, (long)(p1) - (long)p)打印步长才恍然大悟。所以一旦确定某种指针类型指针算术的步长就自动确定了千万别自己再乘一遍元素个数。6.3 关于指针数组存放字符串的特别提醒“指针数组存放字符串”这个说法在热搜里也出现了这里要提醒一点如果指针数组是存放字符串字面量那元素类型最好加const。因为字符串字面量存储在只读数据区修改它是未定义行为。但在很多老代码里人们习惯写char *cmds[]然后用strcpy去“修改”一个字符串字面量比如char *cmds[] {help, version, exit}; cmds[0][0] H; // 未定义行为可能崩溃或什么都不发生这种代码在 C 标准里属于修改字符串字面量某些编译器在栈上或只读段可能表现不同非常危险。正确的做法是如果字符串内容需要修改就自己申请可写内存比如利用二维字符数组char cmds[][16] {help, version, exit}; // 每个字符串最多15字符或者用动态分配char *cmds[3] {malloc(16), malloc(16), malloc(16)}; strcpy(cmds[0], help); // ...做完记得free。很多人把“指针数组存放字符串”和“二维字符数组存放字符串”混为一谈其实内存布局完全不一样。前者是一个指针数组加若干独立字符串字符串之间不一定连续分配后者是连续的一块字符块每个字符串定长。打印时两者都能用%s输出但修改、复制、排序时的行为差异很大。7. 面试高频考点与一套清晰的自检套路7.1 面试官最爱的几道题面试里这个知识点几乎必考而且通常以“写出下面声明的含义”或者“指出代码错误”的形式出现。下面是我见过的高频题你可以自测一下int (*ptr)[10]是什么int *ptr[10]是什么下面代码是否正确int a[10]; int *p a; int (*q)[10] a;然后p[5]和(*q)[5]分别代表什么如何用函数参数接收一个int a[3][4]二维数组char *argv[]和char **argv在main函数中有什么区别第 1、2 题其实就是考察括号优先级[]优先级高于*所以int *ptr[10]先结合ptr[10]是数组再结合*是指针数组而int (*ptr)[10]由于括号ptr先结合*是指针再指向长度为 10 的数组。第 3 题中p[5]是a[5]的值(*q)[5]也是a[5]的值。但类型上p是int *q是int (*)[10]。相同地址不同的步长语义。第 4 题正确答案是void func(int mat[3][4])、void func(int mat[][4])、void func(int (*mat)[4])三种写法等价。如果写成void func(int **mat)则是错的。第 5 题char *argv[]在形参列表里会被调整为char **argv两者完全等价。这是一个语言规则数组形参退化为指针。所以main(int argc, char *argv[])本质是main(int argc, char **argv)。7.2 区分它们的终极自检口诀我总结了一套自检流程每次不确定的时候在脑子里过一遍基本不会再错先看标识符右边有没有方括号[]。如果有说明核心是一个数组如果先看到括号里的*说明核心是一个指针。再看让标识符先结合的是什么。C 语法里[]和()优先于*所以int *p[3]中p先结合[3]是数组int (*p)[3]中p先被括号框住括号里是*所以先结合*是指针。用sizeof验证。对变量表达式取sizeof如果结果是指针大小就是指针如果是数组总大小就是数组。用指针步长验证。对变量做1如果跳过的是一个元素的长度那它大概率是元素指针如果跳过的是一整组元素的长度那很可能是数组指针。调试时打印类型信息。C 语言没有标准运行时类型查询但可以用_Generic或者编译器扩展比如 GCC 的__builtin_types_compatible_p不过日常用sizeof和地址打印就足够定位了。这套方法比单纯背口诀可靠因为它基于语言的语法和运行时行为换到 C 里同样适用C 里可以用decltype更精确判断但核心逻辑一样。最后再分享一个小技巧在 IDE 里写代码时变量名的自动补全类型提示有时会间接暴露类型但别依赖它。我常用的一个笨办法是在关键位置故意写一个类型不匹配的赋值让编译器报错提示正确的类型。比如int *p NULL; int (*q)[3] NULL; p q; // 故意写错编译器会告诉你从 int (*)[3] 不能转换为 int*通过看编译器的错误信息你就能立刻确认自己写的变量到底是什么类型。这个方法虽然有点“作弊”但在排查复杂声明时特别有效我至今还在用。
返回列表