ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

彻底分清C语言数组指针与指针数组:声明、内存、应用与避坑

彻底分清C语言数组指针与指针数组:声明、内存、应用与避坑 数组指针和指针数组绝对是C语言里最经典的一对“双胞胎恶魔”。名字几乎一样含义却完全相反面试八股题爱考日常编程里也经常因为用错而酿成内存访问事故。我做C/C开发这些年见过不少刚入行的同事在这俩概念上摔跟头包括我自己早期也被绕晕过。这篇就把它们彻底拆开从声明语法、内存布局、初始化遍历、函数传参到典型误区一层层讲透配合可以直接跑的示例代码。不管你是在校学生、准备面试还是工作中需要写底层代码看完这篇应该都能彻底分清不再靠死记硬背。1. 先别急着背结论把两个概念的本质拆开很多人学这俩概念时老师会直接甩出“数组指针是指向数组的指针指针数组是存放指针的数组”这种结论。结论没错但如果只背结论不理解背后的声明语法和内存模型换个写法照样懵。所以第一步先把这两个词的字面意思和C语言语法彻底对应上。1.1 指针数组本质是一个数组元素是指针“指针数组”四个字主词是“数组”修饰词是“指针”。所以它的本质是一个数组只不过这个数组里装的不是int、char这些普通数值而是地址值。比如int *arr[5];根据C语言的运算符优先级下标运算符[]的优先级高于解引用运算符*所以arr会先和[5]结合变成“一个有5个元素的数组”。那这个数组的每个元素是什么类型呢去掉数组部分看剩下的声明int *也就是每个元素都是一个int *类型的指针。到这里可以下一个结论int *arr[5]声明了一个数组数组名为arr它占用的内存是5个指针变量连续排列的空间。每个元素都能单独存放一个int型变量的地址也能单独被赋值、被修改。你可以把它理解成一个“地址抽屉柜”柜子本身是固定的有5个抽屉每个抽屉里放的是一个地址。1.2 数组指针本质是一个指针指向的对象是数组“数组指针”四个字主词是“指针”修饰词是“数组”。所以它的本质是一个指针只不过这个指针指向的不是单个变量而是一整块连续的同类型数组。关键写法是int (*p)[5];。这里为什么必须加括号因为如果不加括号就变成了int *p[5]也就是上一节说的指针数组。加了括号之后*先和p结合说明p首先是一个指针变量指针指向的对象类型由剩下的int [5]决定也就是“一个内含5个int元素的数组”。所以int (*p)[5]的意思是定义一个指针p这个指针只能指向“数组类型为 int[5]”这样的对象。如果你让它指向一个长度为5的int数组p就指向该数组的第一个元素如果你对p执行p1指针会直接跳过一整段长度为5个int的内存区域而不是只跳过一个int。1.3 声明语法里的优先级规则是分水岭很多人分不清这两个概念根子就在运算符优先级上。C语言里[]和()的优先级高于*。这条规则直接导致写法结合顺序本质含义int *arr[5]arr先和[5]结合数组数组里存了5个int*指针int (*p)[5]p先和*结合指针指针指向含5个int的数组一个从右往左看声明的技巧找到标识符变量名然后先向右看遇到[或(再向左看。int *arr[5]从arr向右看先看到[5]说明它是数组int (*p)[5]从p先向右看到)遇到右括号就得先回头向左看看到*说明它是指针。这套“右左法则”后面还会用到是阅读一切复杂C声明的基础。这里提前说一个参悟方法写代码时如果想偷懒验证自己的理解可以用sizeof直接看大小。sizeof(arr)在64位系统上是5乘以8字节等于40字节每个指针占8字节而sizeof(p)只是8字节一个指针变量的大小。一个占40字节一个占8字节两者内存模型完全不同。2. 从内存布局看懂数组指针和指针数组的本质差异语法层面的理解只是第一步。真要彻底掌握这俩概念必须切换到内存视角看看它们到底在内存里长什么样。这也是调试指针相关bug时最关键的能力——脑子里能浮现出内存布局图。2.1 指针数组的内存结构一排连续的指针变量先看指针数组。假设有以下代码int a 100, b 200, c 300; int *arr[3]; arr[0] a; arr[1] b; arr[2] c;这时的内存布局是这样的数组arr自己占一块连续的内存长度是3个指针每个指针8字节64位系统总共24字节。每个指针里存的分别是局部变量a、b、c的地址。也就是说arr数组里的三个元素各自指向内存中三个分散的int变量。如果用文字画个示意图arr 数组连续24字节 ------------------------ | a | b | c | ------------------------ a、b、c 三个int变量在内存中可能相隔很远要访问b的值需要二级寻址先到arr[1]取出地址再到那个地址取出整数。用代码写就是*(arr[1])或者直接用arr[1]这个指针加解引用语法*arr[1]。实际上a、b、c这些变量在内存里的位置可以完全不连续因为数组里保存的只是地址地址之间无需关联。2.2 数组指针的内存结构一个指向连续数据块的指针再看数组指针。典型场景是让它指向一个二维数组的某一行或整个二维数组的首行。int matrix[3][5] {0}; int (*p)[5] matrix;这时的内存布局完全不一样。p只是一个单独的指针变量占8字节。matrix是一个二维数组在内存中是15个int连续排列总共60字节假设int为4字节。p指向的其实是matrix的第一个元素也就是matrix[0]那一行而matrix[0]本身又是一个包含5个int的一维数组。关键差异在于步长。如果定义一个普通的int *q matrix[0]那么q1只跳过4字节也就是一个int。但如果用数组指针pp1会跳过一整行也就是5个int20字节。这个步长信息是编译器根据int (*p)[5]声明中的[5]推断出来的。所以数组指针的“数组”二字不是摆设指针指向的是一个整体对象而这个对象的大小在声明时已经确定。后面用p[i][j]访问二维数组元素时编译器实际做的事情是*( *(p i) j )。先让p跳i行得到第i行的地址再解引用得到一维数组名再在这个一维数组里偏移j个元素。2.3 用代码验证类型和内存大小建议你亲自跑一下这段验证代码印象会深得多#include stdio.h int main(void) { int a 100, b 200, c 300; int *arr[3] {a, b, c}; int matrix[3][5] {0}; int (*p)[5] matrix; printf(sizeof(arr) %zu\n, sizeof(arr)); // 24 printf(sizeof(p) %zu\n, sizeof(p)); // 8 printf(arr[0] %p\n, (void*)arr[0]); printf(arr[1] %p\n, (void*)arr[1]); printf(matrix %p\n, (void*)matrix); printf(matrix1 %p\n, (void*)(matrix 1)); printf(p %p\n, (void*)p); printf(p1 %p\n, (void*)(p 1)); return 0; }运行后你会发现两件事第一arr数组元素的地址是连续且等距的中间隔了8字节第二matrix与matrix1的地址差是20字节正好是一行5个int的大小而p1与p的差值同样是20字节因为p的类型就是“指向含5个int数组的指针”。注意这里的%zu是sizeof返回值的正确格式说明符。很多人习惯用%d打印sizeof的结果在部分平台会出警告虽然大多数时候能跑但不严谨。3. 实操环节初始化、遍历、取值一套流程跑通概念说得再玄落到代码上无非就是“怎么声明、怎么初始化、怎么访问元素”。下面从最常用的操作出发把两种指针的典型用法完整走一遍。3.1 指针数组的初始化与遍历指针数组最常见的初始化方式有三种直接初始化、逐元素赋值、循环赋值。直接初始化通常用在已知目标地址的场景int a 10, b 20, c 30; int *arr[3] {a, b, c}; for (int i 0; i 3; i) { printf(arr[%d] %p, *arr[%d] %d\n, i, (void*)arr[i], i, *arr[i]); }这里有个细节值得注意arr[i]是第i个元素的地址值*arr[i]才是该地址指向的int变量。很多初学者会写错成*arr[i]和arr[i]分不清其实只要记住arr[i]本身是一个指针要取目标值必须再加一个星号解引用。如果元素是指向动态分配内存的指针通常是先分配再逐元素赋值int *arr[3]; for (int i 0; i 3; i) { arr[i] malloc(sizeof(int)); if (arr[i] ! NULL) { *arr[i] i * 100; } }这种写法在C语言里很常见相当于用一个指针数组管理了多个独立分配的内存块。用完记得逐个free先free数组里的指针再用free整个数组变量如果数组本身是动态分配的。3.2 数组指针绑定二维数组并逐行访问数组指针最常见的用途就是配合二维数组使用。绑定过程很简单二维数组名本身就带“数组指针”语义int matrix[3][5]; int (*p)[5] matrix; for (int i 0; i 3; i) { for (int j 0; j 5; j) { p[i][j] i * 5 j; } }为什么不直接用matrix而要再定义一个p当然可以直接用matrix但p的存在说明我们能把“二维数组的行”当作一个独立类型来传递和操作。比如封装一个专门打印矩阵的函数参数类型就写成数组指针传入的是任意一个“有5列的二维数组”的首地址。这种情况下数组指针就是函数与数据之间的桥梁。访问元素时p[i][j]和matrix[i][j]完全等价因为p和matrix的地址值相同、类型兼容。底层运算逻辑是p指向一个含5个int的数组pi指向第i行这个数组*(pi)得到第i行这个数组的数组名首元素地址再*(pi)j得到第i行第j个元素的地址最后再解引用。3.3 重点场景指针数组存放字符串这是实战里最常见的指针数组用法也是相关热词里反复出现的主题。在C语言里字符串本质上就是字符数组而用指针数组存放多个字符串是一种极其经典的数据组织方式。char *fruits[] {apple, banana, cherry, durian};这个声明咋一看可能容易懵fruits是一个指针数组每个元素是char *指向一个字符串字面量。字符串字面量在内存中是一个只读字符数组fruits[0]存的是字符串apple的首字符地址。遍历时直接用%s打印for (int i 0; i 4; i) { printf(%s\n, fruits[i]); }那为什么不用二维字符数组来存呢比如char arr[4][20] {...}。两者区别主要体现在内存利用率和操作灵活性上。第一用二维数组时每一行长度固定为20字节无论字符串实际多短都会占满20字节空间浪费严重用指针数组时每个指针只占8字节字符串数据以实际长度存放在只读区紧凑得多。第二对字符串排序时指针数组只需交换指针变量的值即8字节的数据而二维数组需要整行整行地交换字符内容代价完全不是一个量级。第三字符串长度差异越大指针数组的优势越明显。对比维度char arr[4][20]char *fruits[]内存占用每行固定20字节共80字节4个指针共32字节字符串数据另算修改字符串内容可修改对应字符字符串字面量通常只读重赋值需改指针交换两个字符串逐字符拷贝代价高交换指针即可代价极低适用场景需要修改字符串内容时只需要读取和重排字符串时我平时做命令行工具时命令表和字符串菜单就喜欢用指针数组。比如把错误信息集中放到一个char *err_msgs[]里通过枚举下标访问改文案时只需要改一个字符串字面量不用动业务逻辑代码维护起来非常舒服。4. 实战场景函数参数、二维数组与算法里的指针思想理解概念只是地基真正考验功力的是“什么时候用哪个”。这一章从实际开发中最常见的几个场景出发讲清楚选择哪种方式的依据。4.1 函数形参里的数组指针为什么不能省列数写过二维数组传参的人应该见过这种代码void printMatrix(int (*m)[5], int rows) { for (int i 0; i rows; i) { for (int j 0; j 5; j) { printf(%4d, m[i][j]); } printf(\n); } }调用时传入二维数组名即可int matrix[3][5] {0}; printMatrix(matrix, 3);注意int (*m)[5]这里的5绝对不能省略。为什么因为数组指针的步长依赖这个“5”。编译器在计算m[i][j]的地址时需要知道每一行跨越多少个int才能算出第i行的起始位置。如果把函数声明成void printMatrix(int **m, int rows)就会出大问题——matrix的类型是int (*)[5]int **完全不是一个类型虽然地址值一样但解引用和步长计算规则全错了。引用我之前踩过的一个坑把二维数组直接传给int **形参编译报警或运行崩溃。刚接触时以为二维数组名就是“指针的指针”其实在C语言中二维数组名是“数组的数组”降级后变成“指向数组的指针”而不是“指向指针的指针”。两者在内存布局上就不一样int **指向一个存放指针的内存单元而int (*)[5]指向一个存放整型数组的内存块。4.2 指针数组在命令行参数与配置表里的经典应用指针数组一个广为人知的应用场景就是main函数的参数int main(int argc, char *argv[])这里的argv就是典型的指针数组每个元素指向一个命令行参数字符串。argv[0]是程序名argv[1]是第一个参数以此类推。为什么用指针数组而不是二维字符数组因为命令行参数个数不固定每个参数字符串长度也不固定指针数组天然适合“数量动态、内容只读”的场景。类似的思路在很多嵌入式项目里也常见。比如定义一张命令表static const char *commands[] { start, stop, reset, status };再配合一个函数映射表就能实现简单的命令行分派。这种写法可读性好新增一个命令只需要往表格里加一行不需要修改冗长的if-else链。指针数组在这里的价值就是“把一组异构字符串组织成统一可遍历的结构”。4.3 快慢指针指针思想在算法中的延伸最近看到有关“JS快慢指针有序数组原地去重”的讨论很多初学者误以为这个“快慢指针”和“数组指针”有什么关系。严格来说后者是C语言类型系统里的概念前者是算法思想里的技巧但两者内核相通都是用一个“移动游标”去访问和控制数据结构里的位置。快慢指针在有序数组原地去重时核心逻辑是用一个慢索引指向当前已去重区域的尾部一个快索引扫描整个数组遇到不重复元素就搬到慢索引的位置。这套思路和C语言中指针偏移数组元素、控制写入位置的思维方式如出一辙。比如用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; }这里的slow和fast虽然不是指针类型但本质上就是在用“指针思维”操作数组下标。用好这套思维回头看数组指针和指针数组的差别也会更从容——说到底指针是你手里的一根“遥控器”关键是搞清遥控器指向的对象是什么类型以及按下“加一”键时它跳多远。5. 常见误区与排查技巧实录概念看懂了代码写起来还是会遇到各种奇怪的报错和运行崩溃。这一章专门整理我在实际调试中反复遇到的典型误区以及排查方法。建议把这部分当成“避坑手册”收藏遇到类似问题时回来翻翻。5.1 把二维数组名当int **传参这是最高频的误区。有些编译器甚至会直接报不兼容警告但很多人看到警告不重视程序一跑就段错误。原因前面说过了matrix的类型是int (*)[5]而int **要求指针指向的位置存放一个int *类型的值。matrix指向的位置存放的是一个 int解引用后拿到的根本不是一个合法的地址。排查方法很简单看编译警告如果提示“incompatible pointer type”别忽略。另外可以用typeof或者读代码的方式自查int **m要能用m[i][j]访问元素要求m指向的是一段“指针数组”的起始地址而普通二维数组在内存里从头到尾全是int数据没有指针数组这层结构。5.2 数组指针步长理解错误另一个常见问题是以为p1就跳到下一个元素。前面反复强调数组指针的对象是整个数组p1跳过的是整个数组的长度。如果写成int arr[5] {1, 2, 3, 4, 5}; int (*p)[5] arr; printf(%d\n, *(p1)); // 错误越界访问这里*(p1)访问的是arr之后一整段内存已经越界行为未定义。正确访问第i个元素是(*p)[i]或者更直观地利用数组指针与二维数组的配合p指向的是“整个长度为5的数组”(*p)才是那个数组的名字(*p)[i]就是数组的第i个元素。如果需要遍历arr直接用for (int i 0; i 5; i) (*p)[i]。在调试这类问题时打印地址差是最直接的验证方式。写几行测试代码用%p打印p和p1的地址看差值是否等于数组的总字节数。这一步能直观验证你对步长的理解。5.3 使用“右左法则”快速读懂复杂声明遇到int (*(*p)(int))(int)之类的函数指针声明时很多人直接崩溃。其实只要掌握“右左法则”这类问题都能拆解先找到标识符然后从标识符开始向右看遇到)再向左看依此类推。右左法则具体步骤找到变量名比如p。从p开始向右看遇到什么就先说什么。遇到右括号)后回头向左看遇到*说明当前层次是指针。一层一层剥开直到整个声明分析完。拿int (*p)[5]举例从p向右看遇到)回头向左看遇到*说明p是指针。继续向右看遇到[5]说明指针指向的对象是一个长度为5的数组。数组元素类型是int所以整体是“指向含5个int数组的指针”。再看int *arr[5]从arr向右看遇到[5]说明arr是数组再向左看遇到int *说明数组元素是int*。清清楚楚。5.4 一段可自测的完整示例我习惯在给团队培训时用它当作随堂测试题读者也可以把它粘贴到本地编译运行#include stdio.h int main(void) { // 指针数组arr 是一个数组存放了 3 个 char* 指针 char *arr[3] {C, Java, Python}; // 数组指针p 是指针指向包含 2 个 int 的数组 int row1[2] {10, 20}; int row2[2] {30, 40}; int matrix[2][2] {{10, 20}, {30, 40}}; int (*p)[2] matrix; // 验证指针数组 printf(arr[0] %s\n, arr[0]); printf(arr[1] %s\n, arr[1]); // 验证数组指针访问二维数组 for (int i 0; i 2; i) { for (int j 0; j 2; j) { printf(p[%d][%d] %d\n, i, j, p[i][j]); } } // 打印步长 printf(matrix %p\n, (void*)matrix); printf(matrix1 %p\n, (void*)(matrix 1)); printf(p %p\n, (void*)p); printf(p1 %p\n, (void*)(p 1)); return 0; }运行后重点观察两组输出第一组是arr[0]、arr[1]能直接打印字符串这是指针数组存字符串的直观体现第二组是matrix和matrix1的地址差以及p和p1的地址差二者完全一致这意味着p的步长就是一行二维数组的长度。这个小实验做完这两个概念基本不会再混。提示如果使用C编译器编译建议把字符串字面量赋值给char *时加上const以免编译器报字符串常量转类型警告。纯C环境下一般没这个问题。个人经验方面我最后再分享一个小习惯学习指针相关概念时永远用“类型 内存 步长”三个维度去分析一个声明。遇到任何一个指针变量先问三个问题——它是什么类型它指向什么对象它加一之后跳多少字节这三个问题回答清楚了不管是数组指针、指针数组、函数指针还是二级指针都不会绕晕。实际开发中如果还拿不准就写个小demo用printf打印地址和步长验证一下比自己对着代码干想高效得多。这套方法我带过很多人都说管用建议你也试试。
返回列表