
指针这个坎几乎每个学C的人都绕不开。前两篇我们聊了指针的基本概念、指针与变量的关系、动态内存分配这一篇直接挑最硬的部分来数组和指针的纠缠、函数指针、指针的指针还有const修饰词那些让人头疼的细节。我看过很多初学者在这里卡住明明概念都懂一写代码就错其实差别就在这些进阶玩法上。这篇文章不会去复述语法书上的定义我尽量用实际场景和踩过的坑来说清楚适合至少已经搞懂“指针指向变量”“通过指针改值”这类基础操作的读者。看完之后你能弄清楚指针数组和数组指针到底谁是谁能用函数指针写出表驱动代码也能理解为什么有时候需要“指向指针的指针”更能在const修饰指针时一眼看出编译器到底在报什么错。1. 数组和指针一对让人头疼的兄弟我见过太多人打赌说“数组名就是指针”这句话说对了一半但也正是这一半让后面无数代码出了问题。数组名在大多数场合会“退化”成指向首元素的指针但它本身并不是指针变量这个区别你必须刻在脑子里。1.1 数组名不等于指针区别就在这几个细节里在表达式里int a[5]中的a会被编译器当成int *来用所以写int *p a;没问题。但这个“退化”只在运行时体现数组名本身依然是一个常量地址不能自增、自减更不能给它重新赋值。你写a或者a p编译器直接一个错误甩过来因为数组名不是左值。还有一个最容易出错的点sizeof的行为。sizeof(a)计算的是整个数组占用的字节数对 5 个 int 的数组来说就是 20假设 int 占 4 字节而sizeof(p)计算结果是指针本身的大小32 位环境下是 464 位环境下是 8。这两个值经常被搞混尤其是在把数组作为函数参数传递之后。注意数组传给函数时实际上传递的是一个指针函数内部如果用sizeof(形参)拿到的一定是指针大小不是数组大小。这也是为什么我写函数处理数组时必须额外传入长度参数的原因。再来说说a和a的区别。a指向数组首元素类型是int *a指向整个数组类型是int (*)[5]。它们俩数值上相等但步长完全不同。你写a 1指针只会向后跳一个 int也就是 4 字节写(a) 1指针会向后跳整个数组的长度也就是 20 字节。这个特性在遍历二维数组时特别有用不过也容易让人晕我的建议是除非你实在需要整个数组级别的偏移否则平时统一用a而非a进行步进运算减少认知负担。1.2 指针数组和数组指针别让星星和括号的位置骗了你这两个概念几乎就是C语言面试的送分题也几乎是初学者翻车现场。一句话区分看运算符优先级。int *p[5]因为[]优先级高于*所以 p 先和[5]结合表示 p 是一个数组数组里有5个元素每个元素都是int *类型的指针。这叫指针数组主要用来存放同类型的指针最常见的场景就是存放字符串数组。比如char *names[] {Alice, Bob, Cindy}; for (int i 0; i 3; i) printf(%s\n, names[i]);这里names[i]是一个char *指向各个字符串常量。这样做的好处是无需手动管理每个字符串的内存因为字符串常量本身存放在静态区你只需要维护一个指针数组就行。再来看int (*p)[5]括号让p先和*结合所以 p 是一个指针它指向一个含有5个int元素的数组。这就叫数组指针。它通常用来操作二维数组的“行”。比如你有一个二维数组int matrix[3][5]那么matrix的类型就是int (*)[5]也就是说二维数组名本身就是一个数组指针指向第一行。对于二维数组传入函数形参必须声明成int (*matrix)[5]或int matrix[][5]不能只写int **matrix。因为int **是指向指针的指针它和“指向数组的指针”看似接近实际上内存布局完全不一样。想通过int **去访问matrix[1][2]是不行的除非你自己维护一个指针数组来模拟二维数组的间接访问。这个坑我在刚学二维数组那一阵栽过很多次后来养成习惯只要看到二维数组传参第一反应就是写数组指针形参。2. 函数指针让函数也能变成“变量”C语言里函数名本身是一个地址也就是函数体内的机器码在内存中的入口地址。函数指针本质上就是一个保存这个入口地址的指针变量。它最大的价值在于你可以把函数当作参数传递给另一个函数也可以把多个函数塞进一个数组里程序运行时按条件去选择调用谁。2.1 函数指针的声明、赋值与调用三步走先来看最简单的声明。定义一个加法函数int add(int x, int y) { return x y; }声明一个指向这种函数的指针需要严格匹配参数列表和返回值类型int (*func)(int, int); // 一个指向“返回int带两个int参数”函数的指针 func add; // 函数名add直接作为地址赋值注意这里的*必须被括号括住。如果写成int *func(int, int)那 func 就成了一个“返回 int *”的函数也就是指针函数两者完全不是一回事。指针函数是函数返回一个指针函数指针是指针指向一个函数。这个顺序我总是用一个小技巧记先看右边的括号里的形参列表再看左边的*和返回值谁被括号关在一起谁就是“主语”。调用方式更简单两种都支持int r1 func(2, 3); // 直接像函数一样调用 int r2 (*func)(2, 3); // 显式解引用调用我更喜欢第二种虽然冗余一点但一眼就能看出 func 是一个指针。实际工程里为了让代码可读性更高通常用 typedef 给它一个别名typedef int (*BinaryOp)(int, int); BinaryOp op add;这样后面声明函数指针变量时就跟声明普通变量一样简洁尤其在函数指针数组里typedef 能省掉一大坨多级括号的写法。2.2 函数指针数组一张“表”搞定命令分发既然函数指针是变量那自然可以放到数组里这就叫函数指针数组。它的典型应用就是“查表法”替代一大堆 if-else 或 switch-case。比如一个简单的四则运算计算器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 div(int a, int b) { return a / b; } int (*ops[])(int, int) {add, sub, mul, div}; char op_chars[] {, -, *, /}; int compute(char op, int a, int b) { for (int i 0; i 4; i) if (op op_chars[i]) return ops[i](a, b); return -1; }这样如果以后要扩展取模、幂运算你只需要增加一个函数并往ops数组里添加一个元素不需要去改动调用处的逻辑。我记得自己第一次用这个模式写了一个小型的串口命令解析器每条命令对应一个处理函数当时就觉得函数指针数组这个工具太顺手了。它把“数据和操作”绑在了一张表里代码逻辑一下子清晰很多。不过在写函数指针数组时你要特别注意类型必须完全一致包括返回值和参数列表。像int (*ops[])(int, int)这个声明读起来是ops 是一个数组数组元素是“指向返回 int、接受两个 int 参数的函数的指针”。如果有一个函数签名是void print(int x)哪怕只差返回类型编译器也会报类型不兼容千万别犯这种低级错误。3. 指针的指针再多一层间接的奥义“指向指针的指针”听起来像套娃确实也是套娃。但我们之所以需要第二层间接通常是因为要在一个函数里修改“指针本身的值”。还记得一句话吗C语言是值传递。你想修改一个 int 变量的值得传它的地址也就是传int *那你想修改一个int *指针变量本身的值就需要传它的地址也就是int **。3.1 为什么需要指向指针的指针最典型的内存分配场景最经典的例子是初始化一个指针。我们写一个函数用来分配内存并返回直接这样写void alloc(int *p) { p (int *)malloc(sizeof(int) * 10); // 这里p是形参赋值后只改了局部变量 }调用int *ptr NULL; alloc(ptr);之后ptr 仍然是 NULL。为什么因为alloc函数里的p只是 ptr 的一份拷贝我把这份拷贝改成新地址原来的 ptr 根本不知道。要解决这个问题就必须让函数接收到 ptr 本身的地址void alloc(int **p) { *p (int *)malloc(sizeof(int) * 10); // *p就是外部的ptr变量直接修改它的值 }调用方式改成alloc(ptr);这样函数通过*p ...就能真正改变外部的指针变量指向。这个写法在链表的头节点插入里极其常见。当你在链表头部插入一个新节点时如果头指针可能在插入后发生变化就得传入头指针的地址也就是struct Node **head。如果少了这层间接你会发现链表头部永远“插不进去”白白浪费半小时调试。3.2 二级指针的声明、解引用和实际使用声明起来很直接int a 42; int *p a; int **pp p;pp里存的是 p 的地址。要拿到a的值写**pp要拿到 p 的值a 的地址写*pp。大多数时候二级指针只在“需要修改指针形参”时出现不会到处乱用。我在代码审查时经常看到一些人为了炫技把明明一层指针就能解决的参数硬写成二层指针最后代码可读性很差还容易引起空指针崩溃。记住能用一级指针解决的事绝对不要用二级指针能用函数返回值解决的事也尽量不用输出形参。二级指针不是不能用而是要用在刀口上。说到返回值也有一个相反的写法直接在函数里返回新分配的指针比如int *new_array(int n) { int *p (int *)malloc(sizeof(int) * n); return p; }这种写法避免了双指针参数调用方写int *arr new_array(10);就行。但你要注意内存管理职责分明谁分配谁释放。用二级指针的方法时函数负责分配调用方负责free用返回值的方法同样如此。如果函数内部分配了内存但调用方忘记释放就会造成内存泄漏。所以我建议在写这类函数时注释里明确写清楚“返回值需要调用方free”或“第二个参数所指向的内存由调用方负责释放”。4. const 和指针三种修饰位置三种截然不同的语义const用在指针上是无数人初学时的噩梦。其实只要抓住一个核心const修饰的是它左边紧挨着的类型如果左边没有东西那就修饰它右边的东西。在这条规则下const和*的组合一共就三种情况。4.1 常量指针、指针常量、指向常量的常量指针一张表说透把常见的写法列成一张表看一眼就明白写法语义你能改什么你不能改什么const int *p指向常量的指针改 p 本身让它指向别处通过 p 修改它指向的值int const *p同上写法不同同上同上int *const p指针常量通过 p 修改指向的值改 p 本身不能让 p 指向别处const int *const p指向常量的常量指针两个都不能改两个都不能改const int *p和int const *p是同一个意思都表示“p 指向一个 const int不能通过 p 修改这个 int”。读法可以按从右往左读p is a pointer to const int也就是指向 int 的常量属性的指针。int *const p则是 p is a const pointer to int意思是这个指针变量本身是常量不能在初始化后重新指向其他地方但它指向的目标值可以改。为什么这个区别重要因为多线程或共享数据时你不希望某个函数通过普通指针偷偷改掉传入的只读数据所以用const int *约束一下。而int *const p通常用在嵌入式裸机环境的寄存器地址映射上比如某个寄存器的地址固定为0x40000000你就可以声明一个常量指针指向它防止自己不小心把指针改了。还有一个隐藏规则const int *类型的指针不能直接赋值给int *类型的变量但反过来可以。比如const int a 10; const int *p a; // ok int *q a; // 编译警告丢掉了const属性不建议这样写这个限制是在保护你不通过 q 去修改本该只读的数据。如果你强行要改用强制类型转换(int *)(a)但这样的代码基本属于自找麻烦不要轻易使用。4.2 const 指针的常见编译错误和正确写法初学者最常遇见的报错是“assignment of read-only location”或者“assignment to read-only parameter”。看到这类错误先看一眼是不是自己搞混了修饰位置。比如int x 1, y 2; const int *p x; p y; // 允许因为p本身可以变 *p 3; // 报错因为不能通过p修改指向的值反过来int *const q x; q y; // 报错q本身不能变 *q 3; // 允许遇到报错就在这两种情境里去套基本秒懂。我在实际代码中很少用指针常量除非真的需要固定地址映射。大多数情况下const都在修饰“指向的对象”也就是const int *或const char *。比如字符串处理函数就应该写成void foo(const char *str)这样可以告诉调用方这个函数不会修改传入的字符串。从接口设计角度来看const 是给调用方的承诺也是给自己留的安全边界。每一个函数形参都值得仔细琢磨一下这个参数是否应该传入const char *如果函数确实只读为什么不加 const 呢5. 指针实战中的典型问题与排查实录指针的问题线上翻车往往就出在那几个老地方野指针、越界、返回局部变量地址。这些坑不是靠背规则能避开的得靠在实际调试中积累无意识的条件反射。我把自己踩过的一些问题和排查思路整理出来大家可以直接当速查表用。5.1 野指针和悬空指针两种“迷路”的指针野指针是“没有初始化”的指针它指向一个随机的内存地址悬空指针是“指针还留着旧地址但所指内存已经被释放”的指针。两者的后果都是不可预测的崩溃或莫名其妙的垃圾数据。我可以给一个非常典型的野指针代码int *p; // 未初始化p的值是随机值 *p 10; // 危险不知道写到哪个内存去了解决方法简单粗暴定义时立即初始化要么赋 NULL要么指向一个合法对象。释放内存后的悬空指针也常见int *p (int *)malloc(sizeof(int)); free(p); *p 100; // 悬空指针内存已经还给系统这种代码不会立刻崩但一旦那块内存被系统重新分配给其他程序或变量你再去写就会损坏数据。我强烈建议在free之后立即将指针置为 NULL这样后续任何解引用操作都会立刻引发空指针崩溃而空指针崩溃比悬空指针的内存踩踏容易排查得多。这也是很多老手写代码的习惯。5.2 数组越界缓冲区问题最隐蔽的一颗雷数组越界其实是C语言里最经典的故障来源。问题在于C语言本身不检查数组边界写超了一个元素编译器不会提示运行时不一定会崩但可能已经破坏了相邻变量。我在调试一个排序程序时遇到过数组长度是 10循环从 0 到 10多循环了一次结果没报错只是最后把栈上紧邻数组的一个临时变量给改了程序跑到后面才莫名奇妙地输出负数。这种 bug 的特征是“运行结果依赖编译器优化开关”特别难查。排查数组越界我的经验是所有涉及下标的循环统一用 n而不是 n如果必须用回头看一下是不是忽略了元素个数和下标最大值的差一。动态数组也可以用“哨兵值”或增加一层越界断言来辅助排查比如在调试模式下用assert(i 0 i n)。如果程序崩溃在某个看似无关的函数里第一件事去查那个函数之前刚操作过的数组十有八九是越界污染。把数组当成一种受限的指针来理解就不会忘记它需要程序员自己守护边界。指针和数组是一个家族边界问题就是家族的祖传毛病。5.3 函数返回局部变量的地址一个经典错误我一直强调普通局部变量存放在栈上函数结束栈帧就销毁你还返回它的地址就是拿着一根指向已经拆迁掉的房子的门牌号。例如int *bad_func() { int local 42; return local; // 悬空指针 }返回后local的生命周期已经结束local指向栈上已经无效的内存。在大多数情况下你调用这个函数时会立即使用返回指针概率性崩溃就在这种情境下出现。要解决这个问题有三个安全途径用static int local把存储期改为静态但要注意这会破坏函数的可重入性多线程环境下不能这么干。调用方传入一个缓冲区指针让函数把结果写到那里。这是最推荐的方式既清晰又可重入。在函数内用malloc分配内存返回指针调用方用完负责 free。这个也可以用但要特别注意内存管理的归属性防止内存泄漏。在我自己的代码规范里我严格倾向于第二种“调用者管理缓冲区”的写法。比如字符串操作函数我总会设计成void str_xx(char *dst, const char *src)而不是char *str_xx()这样内存生命周期一眼就能看懂也不容易在团队合作时把释放责任搞混。如果这一篇的内容你都能消化说明你对指针的理解已经过了“语法关”进入“工程关”了。指针本身并不难难的是它把所有内存管理的责任都交给了你。这些责任说来说去就是三条初始化、边界、释放。只要每次用到指针都先问自己这三条是否都满足你已经比很多写了几年C的人更稳。