news 2026/9/30 4:41:10

数组指针与指针数组:C语言声明语法、内存模型与实战应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数组指针与指针数组:C语言声明语法、内存模型与实战应用

先问一个问题:int *p[5]和int (*p)[5],这两个声明放在你面前,你能在一秒钟内说出它们各自是什么吗?

如果你需要犹豫,那这篇文章就是为你写的。很多人在 C/C++ 的指针这条路上走了很远,能玩转链表、能写出各种内存管理技巧,但一碰到这两个声明还是会卡壳。甚至不少工作了三五年的开发者,被面试官问起“数组指针和指针数组的区别”,回答依然是“一个是数组,一个是指针”这种非常模糊的话。说实话,这个知识点确实坑人,因为它们的名字长得像孪生兄弟,但本质是两个完全不同的东西。

我当年学到这里的时候也绕了很久,后来发现只要抓住一个核心——[]的优先级高于*——再配合一套正确的读法,这两个概念就再也忘不掉了。这篇文章我会从语法、内存布局、初始化、应用场景到高频踩坑,完整拆解这两个概念,最后再结合最近讨论度很高的“有序数组原地去重”场景,看看指针操作数组在实际算法题里是怎么发挥作用的。不管是准备面试还是补底层基础,都值得认真看一遍。

1. 一句话先分清:谁是指针,谁是数组

先别急着看复杂的代码,我们用一个最简单的方式建立直觉。

  • 指针数组:本质是数组,只是数组里的每个元素都是指针。换句话说,它是一个“装着多个地址的容器”。
  • 数组指针:本质是指针,只是这个指针指向了一个数组。换句话说,它是一个“存着某个数组首地址的变量”。

注意这两个定义的差别:一个本身是“容器”,一个本身是“指向容器的工具”。我见过很多人用“数组的指针”和“指针的数组”来记,但我个人更喜欢这个说法——“手里有一张写满了地址的清单”和“手里只有一把能打开某个仓库大门的钥匙”,前者是清单,后者是钥匙。这么说虽然不是特别严格,但对于初学者建立第一印象很有帮助。

再来看标准的声明写法:

int *p[5]; // 指针数组:p 是数组,有 5 个元素,每个元素都是 int* 类型 int (*p)[5]; // 数组指针:p 是指针,指向一个含有 5 个 int 的数组

差异就藏在那一对括号里。但为什么一对括号就能改变整个声明的含义?这要回到 C 语言运算符优先级上去看。

2. 语法优先级:为什么括号决定了命运

C 语言里,[](下标运算符)的优先级高于*(解引用/指针声明符)。所以当你写int *p[5]时,p会先和[5]结合,变成“一个有 5 个元素的数组”,然后数组的元素类型才是int*。

而当你在p外加了括号,写成int (*p)[5],括号的优先级最高,p先和*结合,于是p首先是一个指针,然后这个指针指向的东西才是int [5]类型的数组。

很多教材会教“右左法则”,就是从变量名开始,先向右再向左逐层读声明。以int (*p)[5]为例:

p // p (*p) // 是一个指针 (*p)[5] // 指向一个含有 5 个元素的数组 int (*p)[5] // 数组的元素类型是 int

读出来就是:p 是一个指向“int 数组(长度 5)”的指针。再看int *p[5]:

p // p p[5] // 是一个含有 5 个元素的数组 *p[5] // 元素类型是指针 int *p[5] // 指针指向的类型是 int

读出来就是:p 是一个数组,它有 5 个元素,每个元素都是指向 int 的指针。

如果你想在代码里减少这种可读性灾难,可以用typedef重新包装一下。比如想定义一个数组类型,再定义它的指针:

typedef int IntArray5[5]; // IntArray5 就是一个 int[5] 类型 IntArray5 arr = {1, 2, 3, 4, 5}; IntArray5 *p = &arr; // 这就是数组指针,等价于 int (*p)[5]

再比如想定义指针类型,再用它组成数组:

typedef int *IntPtr; // IntPtr 就是 int* IntPtr p[5]; // 这就是指针数组,等价于 int *p[5]

用 typedef 包装之后,本质一目了然。我推荐所有初学者在刚接触这两个概念时,都先用 typedef 拆一遍,等熟练了再直接看原始声明。这个过程能帮你把“声明语法”翻译成“真实类型”,远比死记硬背“带括号的是数组指针”要有用得多。

3. 内存模型:两者在内存里到底长什么样

理解了语法,接下来要看内存布局。因为这两者的内存形态差别可以说是天壤之别。

3.1 数组指针的内存视角

数组指针就是一个指针变量,它本身只占据一个指针大小的空间(64 位系统下是 8 个字节)。这个指针变量里存放的是某个数组的首地址。它指向的“目标”是一整块连续的内存区域,也就是那个数组的全部元素。

int arr[5] = {10, 20, 30, 40, 50}; int (*p)[5] = &arr; // p 指向 arr 这个长度 5 的数组

此时在内存里,p 是一个独立的指针变量,里面存的是 arr 的首地址。p + 1会跳过一整个数组的大小,也就是5 * sizeof(int) = 20个字节。这就是我们常说的“步长”,数组指针的步长是整个数组的长度,不是单个元素。这一点非常关键,很多人搞混数组指针和普通指针,就是在步长上栽了跟头。

注意,这里要区分arr和&arr。arr在大多数表达式中会退化为指向首元素的指针,类型是int*,对它 +1 会跳过一个 int。而&arr的类型就是int (*)[5],对它 +1 会跳过 5 个 int。同一个地址,因为类型不同,加减运算的跨度完全不同。这也是“数组指针”概念的根基。

3.2 指针数组的内存视角

指针数组本身是一块连续的内存区域,其中存储的是多个指针变量。它的总大小是“元素个数 × 单个指针大小”。每个元素都是一个独立的指针,可以分别指向不同的内存地址。

int a = 1, b = 2, c = 3; int *p[3] = {&a, &b, &c};

此时 p 数组里存了三个地址,分别指向 a、b、c。p本身是数组名,它的类型是int*[3],在表达式中退化为int**。p + 1跳过一个int*,也就是 8 个字节,指向数组的第二个元素。

如果你的数组在栈上,它的地址是连续的,但它指向的对象(a、b、c)不能保证连续。这是指针数组最容易理解的地方——它是一个“指针集合”,至于每个指针指向哪里,完全由你控制。

3.3 用 sizeof 和步长把两者彻底分开

我建议你自己写一段测试代码,把下面的结果打印出来,会非常直观:

#include <stdio.h> int main() { int arr[5] = {1, 2, 3, 4, 5}; int *pArr[5]; // 指针数组 int (*pToArr)[5]; // 数组指针 printf("sizeof(int *pArr[5]) = %zu\n", sizeof(pArr)); // 64位系统上通常是 40 printf("sizeof(int (*pToArr)[5]) = %zu\n", sizeof(pToArr)); // 通常是 8 pToArr = &arr; printf("pToArr = %p\n", (void*)pToArr); printf("pToArr + 1 = %p\n", (void*)(pToArr + 1)); // 地址相差 20 字节 return 0; }
对比维度数组指针int (*p)[5]指针数组int *p[5]
本质指针数组
自身大小8 字节(64 位系统)40 字节(5 × 8)
p + 1的步长跳过一整个数组(20 字节)跳过一个指针(8 字节)
常见声明类型int (*)[5]int**(退化后)
访问方式(*p)[i]或p[0][i]*p[i]

我第一次把这段代码跑出来的时候,看到地址的差值是 20 而不是 4,瞬间就明白“步长”这个概念了。建议你也亲手跑一遍,印象绝对比读十遍博客深刻。

4. 初始化与访问:从代码层面彻底打通

概念和内存模型清楚了,接下来落实到具体写法。很多人的痛苦在于:就算知道了定义,也不知道怎么用。

4.1 数组指针的初始化与访问

数组指针最常见的玩法,是用于二维数组的行指针。考虑一个 3×4 的二维数组:

int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*row)[4] = matrix; // matrix 退化为 int(*)[4],指向第一行

这里row就是一个数组指针,指向“长度为 4 的 int 数组”。row + 1会跳到第二行,也就是指向 matrix[1]。访问某个元素可以写:

printf("%d\n", (*row)[2]); // 第一行的第三个元素,输出 3 printf("%d\n", row[1][2]); // 第二行的第三个元素,输出 7

很多人会觉得row[1][2]这种写法不像指针。但在 C 语言里,row[1]本质上是*(row + 1),它表示第二行数组整体,再[2]就是取第二行下标为 2 的元素。如果你把row理解成一个“行指针”,这个写法的逻辑其实非常自然。

注意,一维数组用数组指针时,访问要稍微小心。比如:

int arr[5] = {1, 2, 3, 4, 5}; int (*p)[5] = &arr; printf("%d\n", (*p)[2]); // 输出 3,等价于 arr[2] printf("%d\n", p[0][2]); // 也输出 3

这里用p[1]就会越界,因为p + 1已经跳过整个数组了。所以数组指针更适合用在“二维及以上的数组”场景;如果只是指向一维数组,直接int *p = arr更合适,别为了炫技用数组指针。

4.2 指针数组的初始化与访问

指针数组最常见的场景是存储多个字符串(或者说存储多个字符串的首地址):

const char *week[] = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};

这个week就是一个指针数组,每个元素是const char*,分别指向字符串常量的首字符。访问方式:

printf("%s\n", week[0]); // Monday printf("%c\n", week[0][0]); // M

因为week[0]是字符串首地址,week[0][0]就是首字符。这种写法的好处是每个字符串可以长度不同,内存利用灵活。

如果你用char week[7][10]这种二维字符数组存储,虽然也能实现类似效果,但每一行必须预留足够空间,比如最长的 “Wednesday” 是 9 个字符外加结尾符,就需要 10 字节,其他行多出来的空间就浪费了。指针数组则完全不浪费,每个指针指向各自的字符串常量,缺点是字符串常量通常不可修改。想修改的话,要换成char week[][10]这种数组,或者给每个字符串分配可写的内存。

另一个经典场景是命令行参数:

int main(int argc, char *argv[])

argv就是一个char*数组,每个元素指向一个命令行参数字符串。argv[0]通常是程序名,argv[1]是第一个参数,依此类推。这是指针数组在系统层面的标准应用,几乎每个 C 程序都会接触。

4.3 二维数组传参的写法

数组指针的一个重要实战价值,是作为二维数组传参时的形参类型。

假如你写了一个函数,要接收一个 3 行 4 列的二维数组,形参应该怎么写?正确写法有两种等价形式:

void process(int (*p)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", p[i][j]); } printf("\n"); } } // 调用 process(matrix, 3);

或者写成:

void process(int p[][4], int rows)

注意,int p[][4]在编译器眼里会自动调整为int (*p)[4]。这两种写法完全等价。很多人把二维数组形参写成int **p,这是错的。原因很简单:二维数组在内存里是连续的一整块,而int**指的是“指向指针的指针”,它期望的目标内存里存的是指针,不是普通整数。强行用int**接收二维数组后,你的访问会直接变成“把数组里的数值当作地址去解引用”,轻则打印乱码,重则段错误。

所以在面试里,考官让你写一个二维数组遍历函数,你直接写出int (*p)[4]或int p[][4],这本身就是加分项。

5. 典型应用场景:你在真实项目里怎么用它们

概念讲完了,来看看两组概念的真正用武之地。说实话,不是为了炫技,而是实际项目里真的绕不开。

5.1 指针数组的典型应用

指针数组最大的价值,在于将“数据实体”和“访问顺序”解耦。当你有一批类型相同的数据,但它们不连续存放,或者你希望通过索引的方式来统一管理它们时,指针数组就派上用场了。

最常见的例子是错误信息表或命令分发表。比如写一个简单的 FTP 服务器,需要把错误码映射到错误描述:

const char *err_msg[] = { [0] = "Success", [1] = "Invalid command", [2] = "Access denied", [3] = "File not found", };

这样err_msg[error_code]就能直接拿到对应的字符串,不用写一大串 switch-case。这就是指针数组存放字符串的经典用法。

再来看函数指针数组。在 C 语言里,函数名本身是个地址,所以也能放进数组。比如实现一个简单的计算器:

int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int (*op[])(int, int) = {add, sub, mul}; // 调用 int result = op[0](10, 5); // add(10, 5) = 15

这个op数组的元素类型是函数指针int (*)(int, int),它本质上是“指针数组”这个大家族里的一员,只不过元素不是数据指针,而是函数指针。很多状态机、命令解释器、插件系统都是这么干的——用一个表格把“操作码”映射到“函数”。

5.2 数组指针的典型应用

数组指针的核心价值,在于以“行”为单位操作多维数组。写图像处理、矩阵运算、卷积算法的时候,你拿到一个二维数组,最干净的遍历方式就是通过数组指针。

比如实现一个 3×3 矩阵的转置:

void transpose(int (*src)[3], int (*dst)[3], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 3; j++) { dst[j][i] = src[i][j]; } } }

调用时:

int a[3][3] = {{1,2,3},{4,5,6},{7,8,9}}; int b[3][3]; transpose(a, b, 3);

这里src和dst都是数组指针。试想,如果不用数组指针,你还能怎么写?用int *p = &a[0][0]然后手动算偏移,也可以,但代码可读性会差很多,而且行宽一变就得全部重写。数组指针把“行”这个语义直接固化在类型系统里,编译器能替你检查行宽是否匹配,这本身就是一种防护。

另外,当函数需要接收“指向数组的指针”时,数组指针也是唯一正确的写法,尤其是三维数组传参:

void process3d(int (*cube)[4][5], int depth) { // cube 是指向 int[4][5] 数组的指针 }

如果你直接在函数里用一堆arr[x][y][z]来访问三维数组,形参写成int cube[][4][5],编译器也是把它调整成int (*cube)[4][5]。掌握数组指针,本质上就是掌握了多维数组在 C 语言里的类型语言。

6. 快慢指针扩展:有序数组原地去重中的指针思想

最近有个很热的话题,“JS 快慢指针有序数组原地去重”,其实这个题目不只是 JavaScript 专属,它在 C 语言里也一样经典。它的核心不是“指针数组”或“数组指针”这种声明技巧,而是“用指针操作数组下标”的思想——这正好是我们前面所有分析的一个延伸场景。

题目是这样的:给你一个有序数组,要求原地删除重复出现的元素,使得每个元素只出现一次,并返回新的长度。不能使用额外的数组空间,意思是你必须修改原数组。

思路用两个指针,一快一慢:

  • 慢指针 slow 指向“已经处理好的不重复序列的最后一位”,初始为 0。
  • 快指针 fast 从 1 开始遍历整个数组,每次发现nums[fast] != nums[slow],就把 slow 向前移动一位,并把nums[fast]写到nums[slow]位置。

JS 版本:

function removeDuplicates(nums) { if (nums.length === 0) return 0; let slow = 0; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

C 语言版本:

int removeDuplicates(int* nums, int numsSize) { if (numsSize == 0) return 0; int slow = 0; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

来走一遍,假设数组是{0, 0, 1, 1, 1, 2, 2, 3}:

  • slow = 0,fast = 1,nums[1] == nums[0],跳过。
  • fast = 2,nums[2] = 1 != nums[0] = 0,slow 变为 1,nums[1] = 1。
  • fast = 3,nums[3] = 1 == nums[1] = 1,跳过。
  • fast = 4,nums[4] = 1 == nums[1] = 1,跳过。
  • fast = 5,nums[5] = 2 != nums[1] = 1,slow 变为 2,nums[2] = 2。
  • fast = 6,nums[6] = 2 == nums[2] = 2,跳过。
  • fast = 7,nums[7] = 3 != nums[2] = 2,slow 变为 3,nums[3] = 3。

最终数组变成{0, 1, 2, 3, 2, 2, 2, 3},前 4 个元素就是去重后的结果,返回长度 4。慢指针 slow 记录的是“新数组的写入位置”,快指针 fast 只负责往前探路,整个复杂度只遍历一遍数组,时间 O(n),额外空间 O(1)。

这个场景里,nums作为函数参数其实已经退化为指针了。所以你会发现,这道算法题的本质,就是在一个指针(数组首地址)之上,通过两个游标(下标)完成局部写入。它虽然没有直接使用“指针数组”或“数组指针”的声明,但它要求的底层能力,恰恰是理解“数组作为指针传递时,下标运算和解引用是同一件事”——nums[slow]本质上就是*(nums + slow)。先把数组指针那一套步长、退化规则搞明白,写这道题的时候你才会真正理解为什么slow++之后写入是生效的。

如果你的项目中想要更通用一点,把去重逻辑封装成“删除所有重复项”或者“保留指定次数”,模板化处理会更复杂一些,但核心仍然是快慢指针这个思路。而且这套思路在链表里也有对应版本,算是“双指针技巧”最基础的一种,非常值得熟练掌握。

7. 高频踩坑与自查清单

最后把这几年我在代码评审和面试里见到的错误集中过一遍。有些坑看着很低级,但实际项目里真的很常见。

7.1 常见错误速查表

错误写法问题所在正确理解
int *p[5]当作数组指针初始化p 实际上是指针数组,不是你要的指针要数组指针必须写int (*p)[5]
int (*p)[5] = arr;arr 退化为int*,类型不匹配应该写int (*p)[5] = &arr;
二维数组传参写int **p类型不兼容,访问会炸写int (*p)[N]或int p[][N]
p + 1期望跳过一个元素数组指针步长是整个数组先想清楚指针类型再算步长
修改字符串常量char *s = "abc"; s[0]='x';运行时崩溃用char s[] = "abc";再改
混淆arr和&arr两者值相同但类型不同,运算结果不同arr+1跳一个元素,&arr+1跳整个数组

7.2 面试实战对照

如果把这两个概念放到面试场景里,通常会被这样问:

  • int a[5];那么a和&a有什么区别?a的类型是int*(退化后),&a的类型是int (*)[5]。它们打印出来的地址可能相同,但a+1和&a+1的地址差值不一样。
  • int (*p)[5]和int *p[5]的sizeof分别是多少?在 64 位系统下前者是 8,后者是 40。
  • 写一个函数,接收任意行列的二维数组,遍历打印所有元素。那么你的形参必须是数组指针,或者行数、列数之一必须是常量。
  • char *argv[]到底是什么?它是字符指针数组,每个元素指向一个命令行参数。

7.3 理解技巧总结

最后分享一个我自己的记忆方法。每次看到一个复杂的指针声明,先找出变量名,然后看它优先跟[]结合还是跟*结合。跟[]结合,它就是数组;跟*结合,它就是指针。这个“谁离变量名近,谁就决定本质”的原则,可以适用到绝大多数指针声明。

另外,不要被名字骗了。“数组指针”的重点是“指针”,“指针数组”的重点是“数组”。中文靠“的”字位置区分,C 语言靠括号区分,本质是一个道理。

如果还觉得抽象,就写段代码打印地址和步长,用printf把每个表达式的地址差值打出来。我学这个知识点时,就是因为亲眼看到地址跳了 20 个字节,才彻底记住数组指针的步长是整个数组。实践出真知,这句话在 C 语言里永远成立。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:41:09

Kafka生产环境SASL_SSL认证与传输加密配置详解

Kafka跑上生产环境后&#xff0c;最逃不掉的一件事就是给客户端加上认证和传输加密。很多团队初期图方便&#xff0c;直接裸用PLAINTEXT&#xff0c;等安全审计或者多团队共用集群时&#xff0c;才开始补救。我最近正好把一套三节点的Kafka集群完整配置了SASL_SSL&#xff0c;从…

作者头像 李华
网站建设 2026/9/30 4:41:09

AI应用实战:从提升效率到构建工作流的完整指南

有人问我&#xff0c;这两年最值得花时间投资的能力是什么。我的答案一直很明确&#xff1a;把AI用明白。这句话不是贩卖焦虑&#xff0c;而是我见过太多真实的职场差距之后得出的结论。同样两份简历进来&#xff0c;三个月后一个已经能独立跑通数据分析流程&#xff0c;另一个…

作者头像 李华
网站建设 2026/9/30 4:41:09

论文初稿被批太水?资深导师力荐这几个AI论文写作软件

写论文总被说“内容空洞”“逻辑混乱”&#xff0c;是很多学生绕不开的难题。其实&#xff0c;只要用对AI工具、走对写作流程&#xff0c;就能大幅提升效率和质量。多位资深导师在教学中发现&#xff0c;合理利用AI辅助工具能有效解决选题模糊、结构松散、语言不规范等问题。我…

作者头像 李华
网站建设 2026/9/30 4:40:29

模型优化器实战:算子融合、量化与内存复用加速推理

1. 模型优化器到底在优化什么第一次看到 Model-Optimizer 这个词&#xff0c;很多人会下意识觉得它又是一个“调参工具”或者“训练加速库”。但真正在模型部署和推理这条链路上摸爬滚打过的人会明白&#xff0c;模型优化器解决的是一个非常具体且极其昂贵的问题&#xff1a;如…

作者头像 李华
网站建设 2026/9/30 4:39:13

金融运维管理体系建设指南:从可审计、可恢复到监控与应急实战

我刚从互联网运维转到金融行业时&#xff0c;leader只跟我说了一句话&#xff1a;在这里&#xff0c;出故障的代价不是扣绩效&#xff0c;而是可能把很多用户的资金和信任一起弄“挂”掉。后来我带团队做金融运维管理体系&#xff0c;越来越确认一件事&#xff1a;金融运维不是…

作者头像 李华
网站建设 2026/9/30 4:39:13

XXL-JOB 全解析:从原理到实战的分布式任务调度平台

微软的 Azure DevOps 前一阵内部刚推了统一调度平台&#xff0c;我去研究了一下它的实现方案&#xff0c;回头再看 XXL-JOB&#xff0c;反而觉得这个老牌开源项目值得重新聊一聊。很多人对 XXL-JOB 的印象停留在“定时任务框架”“用 Cron 表达式触发”这个层面&#xff0c;但真…

作者头像 李华