ARTICLE DETAIL

资讯详情

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

C语言指针进阶:const修饰、野指针与传值传址全解析

C语言指针进阶:const修饰、野指针与传值传址全解析 1. 开篇指针到底难在哪又是怎么绕进去的在C语言的学习路线上指针永远是一道分水岭。能把指针玩明白的人看内存就像看自己的房间——哪儿放了什么一目了然玩不明白的人每次调试都在跟段错误搏斗。我见过不少写了三五年业务代码的开发者一说起指针还是心里发虚尤其碰到const修饰、函数传参这类场景全靠试错来蒙答案。这篇文章我不打算搞教科书式的逐条罗列而是站在实际工程的角度把指针最容易让人栽跟头的三个核心问题拆开揉碎讲清楚const到底修饰的是谁、野指针是怎么产生的以及如何根上规避、传值调用和传址调用的本质区别到底在哪。这三块知识看似基础但它们构成了C语言内存模型的骨架。搞懂了它们你再回头看scanf为什么必须传地址、为什么会有人写出int* const p这种声明、为什么结构体指针在链表操作里不可或缺都会有一种豁然开朗的感觉。文章会穿插大量实际代码和踩坑记录适配刚入门的新手也适合想补上体系短板的开发老手。为了保证阅读连贯全文以C语言为主线顺带提一下C里智能指针与const的呼应关系——毕竟现在很多项目名义上写C实际上编译的是C。2. 指针变量的本质一个装着地址的普通变量很多初学者把指针想得太玄乎一听到“指针的指针”就头皮发麻。其实指针就是一个变量只不过这个变量里存的是另一个变量的地址。int a 10; int* p a;这里的p是一个int*类型的变量它的值是a的内存地址不是10更不是“10的指针”。理解这一点是破除指针恐惧的第一步。既然是变量就遵循变量的所有规则有类型、有大小、有作用域、有生命周期。在64位系统上任何类型的指针变量本身都占8字节这个大小跟它指向的数据类型无关只跟系统的地址总线宽度有关。你在32位系统上跑老代码时会发现指针占4字节就是这个原因。辨别一个声明里的变量到底是不是指针有个实用小技巧看变量名旁边有没有*。int* p名字是p类型是int**是类型描述符的一部分而*p 10里的*是解引用操作符意思是“访问p里存的地址所指向的位置”。前者出现在声明语句里后者出现在表达式里同一个符号的两种身份——这算得上C语言里最容易混淆的一点了。再进一步说指针变量本身也有地址。p是一个变量它存储在栈上或全局区等那么p就得到一个指向指针的指针类型写作int**。这就是“指针的指针”这个说法的来源。如果你觉得层层套娃不好理解可以类比成通讯录p是“小张的电话号码”p是“记着小张电话号码的备忘录在哪一页”*p是“拨打小张电话”而**(p)就是“拨打小张电话”的另一种写法。C语言允许指针无限套层int***p也合法但工程上超过两层的嵌套基本就是维护灾难了。在面试题里int**最常见的用途有两个一是修改主调函数里的指针变量本身后面讲传址调用会提到二是动态分配二维数组时的模拟。比较自然的场景如果搜索引擎收录了“指针数组存放字符串”这类高频搜索词那背后的逻辑就是在说char* arr[3]——数组里的每个元素是一个char*每个指针可以指向不同的字符串。3. const修饰的多种姿势它到底限定了谁const是C语言里最容易被低估的关键字。简单地把const当成“只读”两个字来记迟早会栽跟头。它出现在不同类型的位置锁定的对象完全不同。理解它的核心逻辑之前先记住一条铁律const修饰的是它后面紧跟着的那个东西。只要抓住“紧跟其后修饰对象”这个规则任何声明你都能拆解出来。3.1 顶层constint* const p锁的是指针本身先看最常见的两个写法。int* const p a;这行声明里const在*和p之间。按照“紧跟其后”的规则const修饰的是p也就是说p这个变量本身不能改变指向——它只能一直指向a不能再去指向b。但是a的值可以改即*p 20完全合法。我见过团队里有新人写这样的代码int* const p a; p b; // 编译错误这个报错是对的因为p是int* const类型自身不能重新赋值。用一个好记的话来说int* const p是“指针常量”指针不能变指向的数据可变。而反过来看const int* p a;这里const紧跟的是int写成const int和int const在C语言里是等价的这就意味着a的值视为只读不能通过p去修改它但p本身可以重新指向别的变量。这儿有个特别有意思的细节const int* p并不能百分百保证数据不可变。比如int a 10; const int* p a; *p 20;会报错但如果换成a 20实际上是能改的——因为a自身并不是const类型直接修改a的值不需要经过p。也就是说const int* p只是不给“用p去改写”这条路并不阻止从其他路修改数据。3.2 三层含义对照给死记硬背者的速查表我把组合形式整理成一张速查表实际开发中对照着看就够了声明写法指针本身可否改指向指向的数据可否通过指针修改记忆口诀int* p可以可以完全自由const int* p可以不可以数据只读指针可动int* const p不可以可以指针锁定数据可动const int* const p不可以不可以两者全部锁定实际工作中最常见的用法是const int* p也就是“通过这个指针去读数据但不允许改写”。比如一个打印函数void print(const int* p)它的意图就是告诉调用者我这个函数只读你传进来的数据绝不会修改它。这既是一种保护也是一种文档化声明。3.3 函数形参与const的化学反应const修饰函数形参时价值会更直接地体现出来。以C标准库里的scanf为例int scanf(const char *format, ...);第一个参数的完整类型是const char*这表示scanf通过这个指针读取格式字符串时只会读这个字符串的内容不会去修改它。为什么这个设计很关键因为编译器拿到这个信息后可以做一些合法性的检查更重要的是如果一个开发者不小心在scanf内部尝试修改形参指向的字符串编译器立刻报错把潜在的问题在编译期就拦截掉了。天天调scanf的人其实都摸过一个坑scanf(%d, n)必须传入的是n的地址不是n的值。因为scanf要往n的内存里写入数据必须知道确切的内存地址。你要是写成scanf(%d, n)编译器可能给个警告但不报错运行起来程序直接崩溃或者行为诡异——这就是传值和传址在调用层面的典型区别。提醒一下对于printf这类只读函数来说传值和传址的区别主要体现在传递成本上。3.4 C里的const延伸const成员函数与智能指针如果项目是在C环境下编译const的使用场景还会再扩展一层。最常见的const成员函数语法是void print() const;这个末尾的const修饰的是this指针表示这个成员函数不会修改对象内部的状态。为什么const要放在函数末尾而不像普通变量那样放在前面因为this指针在执行前就已经隐含在参数列表里了C编译器通过后面的const来标记this的常量性。再延伸到现代C的智能指针std::shared_ptrconst T表示指向的对象不可通过此智能指针修改但可以重新指向别的对象const std::shared_ptrT则相反表示智能指针本身的引用计数和裸指针不能变但所指对象可以改。这和C语言里const int*与int* const的逻辑是遥相呼应的理解了基础的const语义迁移到C就顺理成章。C面试里高频出现的“智能指针使用方法与陷阱”那一类问题本质上就是在考“所有权”和“常量性”的交叉理解。std::unique_ptrT不允许拷贝只能移动这和const的语义配合后能衍生出不少精致的设计方案不过具体细节不在今天这篇文章的范畴内点到为止。4. 野指针的形成路径与规避体系野指针不是一个技术名词而是一类问题的集合。它指的是一段代码里某个指针变量保存了一个“不该存在或者内容已失效”的地址一旦对它解引用轻则读到垃圾数据重则直接段错误甚至悄悄破坏堆内存里的其他数据——后者的危害在线上环境里最容易造成“灵异bug”。要彻底规避野指针首先得知道它从哪儿来。我总结了三条主要的产生路径分别对应三种不同的疏忽。4.1 路径一未初始化的指针变量int* p; // 没有初始化p的值是随机的 *p 100; // 往一个随机地址写入100这段代码是典型的“定时炸弹”。未初始化的指针变量里存的是一个不确定的值可能指向内核空间可能指向只读区可能恰好指向你自己的栈帧。往里写数据的行为是未定义的能不能跑起来完全看当时的运气。规避方案很朴素的声明指针变量时立刻初始化。实在没有合法指向的对象就直接赋NULL。C语言里形如int* p NULL;的空指针是“明确的无效值”可以判断、可以比较但一旦解引用了空指针编译器和运行时至少有据可查绝大多数情况下会直接崩溃而不是静默破坏数据。崩溃虽然烦人但它比“数据被悄然写坏”要容易排查得多。4.2 路径二栈地址失效与悬空指针这是一条很容易被初学者忽略的路径。函数里定义的局部变量在函数返回后就销毁了如果函数外部还拿着指向这个局部变量的指针那这个指针就变成了“悬空指针”。典型场景int* getPointer() { int local 100; return local; // 错误返回了一个即将失效的栈变量地址 }函数返回后local的栈内存被释放但getPointer依然返回了那个地址。调用方如果再用这个地址去读值读到的东西随缘——可能还是100可能是别的函数的局部变量也可能已经完全不可访问。规避方法有两个。一是在函数内部用malloc把数据放到堆上返回堆地址由调用方负责释放二是修改函数签名让调用方提供一块由它自己管理的缓冲区比如void getValue(int* out)。实际工程里我更推荐后者因为堆内存的分配和释放跨越了函数边界很容易出现“分配了忘释放”或者“释放了还继续用”的双向事故。说到“释放后未置空”这算是野指针的第三条路径。free(p)之后p仍然保存着原地址虽然内存已经归还系统。此时再去解引用它就属于对已释放内存的非法访问。规范要求是free(p)后立刻补一句p NULL;形成习惯。有人觉得这行代码多余但恰恰是这么简单的一行省去了大量排查悬空地雷的功夫。4.3 路径三越界访问导致的“间接野指针”还有一种野指针的产生路径很隐蔽指针本身的指向是合法的但通过它进行的遍历越过了合法边界。最经典的例子就是数组越界char buf[4]; char* p buf; for (int i 0; i 4; i) { *(p i) a; // 越界一次 }当i等于4时这个写入已经跑到了buf之外。表面上代码的操作对象是p这个指针但本质上它已经变成了“合法的指针正在做非法的事”。解决越界问题的核心在于边界条件的验证尤其是涉及循环遍历、缓冲区读写时统统要确认上下界。静态代码扫描工具如cppcheck、编译器的-fsanitizeaddress选项都能帮我提前捕获这类错误。我个人的经验是写代码的当下就要假设自己是“最粗心的那个人”把边界值全部列出来推演一遍不要指望测试能覆盖到每一个越界分支。最终形成一套属于自己的野指针规避体系时建议按以下规则办事指针声明时要么初始化要么置NULL。函数返回值若是指针都得明确说明返回的指针归谁所有、谁负责释放。释放内存后立即置NULL。涉及指针算术的地方写清楚起始地址、元素大小和合法步数。提交代码前过一遍静态分析工具从机器层面帮忙兜底。5. 传值调用与传址调用函数参数传递的底层逻辑栈上函数调用时参数是怎么传过去的这是整篇文章里最关键的实践性话题。很多新手容易混淆“传址调用”和“传引用调用”其实C语言里没有真正的引用传参只有传值和传址两种方式。所谓传址本质上传的还是值——只不过这个值是地址。5.1 传值调用形参是实参的副本void swap(int a, int b) { int tmp a; a b; b tmp; // 只交换了形参不影响主调函数 }调用swap(x, y)时编译器会为形参a和b分配新的栈空间然后把x和y的数值拷贝过去。swap内部交换的完全是副本函数返回后x和y原封不动。要解释为什么不能直接写成swap(x, y)来交换两个变量原因就在这个“副本”机制里——函数拿到的只是值的复印件原件上的修改根本不会发生。传值调用最大的优点是安全函数无论如何折腾形参都不会改到主调方的变量。它的代价则是大体积类型的拷贝开销。比如传一个大型结构体按值传递要把整个结构体压栈拷贝时间、空间成本都比较高。这时候工程师通常选择传指针传结构体的地址从而避免拷贝。5.2 传址调用传的依然是一个值void swap(int* a, int* b) { int tmp *a; *a *b; *b tmp; }调用方写swap(x, y)传递的其实是x和y的地址。这两个地址以参数的形式拷贝进函数形参a和b分别保存这两个地址的值。*a和*b则是解引用操作访问到的是主调函数里的x和y本人所以修改生效。从这个角度看“传址调用”依然符合“值传递”的底层机制——拷贝的依然是字节序列只不过字节序列解释为地址。正是因为这个本质很多C语言教材干脆把参数传递统一归纳为“一切皆传值传地址是传了一种特殊的值”。如果调用的是指针变量本身需要被修改比如链表头插法里需要在函数内给头指针赋一个新节点那就要用到指针的指针即void insert(Node** head, Node* newNode)。参数类型是Node**传递的是“头指针变量”的地址函数内部通过解引用一次拿到头指针*head再直接修改头指针本身的指向。要是只传Node* head进去函数里改的是副本调用方的头指针永远不会更新——这个坑我见过无数人踩基本都是做链表反向或者插入时发现的。5.3 scanf为什么必须传地址理解了传址调用的原理scanf的行为就非常清晰了。scanf(%d, n)需要往n对应的内存地址里写入用户输入的数据所以它必须知道n的地址。如果把丢掉scanf拿到的是n的值比如10然后把输入的数据往地址10上写——这个地址大概率不可写运行时立刻崩溃。顺带说一句字符串数组名在表达式里会自动退化为指向首元素的指针。所以scanf(%s, buf)里不需要写bufbuf本身就可以当成一个char*来用。这个特性和“数组名在表达式中等价于指针”的规则挂钩是另一个高频考点。5.4 传值传址的工程权衡与C引用从工程角度看传址调用带来的灵活性也带来了风险调用方无法直接从函数签名上判断函数是否修改了它传入的数据。为了明确描述意图就把const引进来——void func(const int* p)就是声明“我只读不改”这比传值更省拷贝又比直接传可变指针更安全。C里提供了引用int a作为新语法本质上是“带约束的指针”编译器会在底层按地址处理但在语义上不允许重新绑定、不允许为空。很多从C转C的人一开始觉得引用没什么用但其实现代C代码里大量用const T作为参数类型就是为了在零拷贝的同时保证只读效果和C语言的const T*是一致的只是写法上更友好些。工程选型的优先顺序我一般是这样定的只需要读数据不修改形态是小体积类型如int直接传值。只需要读数据但体积较大如结构体、字符串用const T*或C的const T。需要修改数据用普通指针T*或C的T。需要修改指针变量本身用T**。这套规则几乎可以覆盖90%日常开发里的函数接口设计。6. 快慢指针、双指针法和函数指针数组高频热点串讲既然热搜词里反复出现“快慢指针原理”“双指针法”“函数指针数组”这里我花一个完整章节来串讲。它们不是孤立的奇技淫巧而是建立在前面所有基础知识点上的常用工程技巧。6.1 快慢指针链表判环的经典方案快慢指针本质上是两个速度不同的指针同时遍历链表。常见做法是慢指针每次走一步快指针每次走两步。如果链表里有环快指针最终会“追上”慢指针二者在环内相遇如果没有环快指针会先到终点以NULL结尾。用C语言写出来核心逻辑大约是这样int hasCycle(struct ListNode *head) { if (head NULL) return 0; struct ListNode *slow head; struct ListNode *fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; if (slow fast) return 1; } return 0; }为什么快指针一次走两步而不是三步因为当步长为2时只要存在环两者距离每次缩小1必定相遇步长如果大于2理论上也能相遇但需要多绕几圈且在某些环长度和步长不互质的情况下可能错过工程上不推荐。快慢指针还有一个典型应用是“寻找链表中间节点”快指针速度是慢指针的两倍当快指针走到尾部时慢指针刚好处于中间位置。这个思路用来处理需要中位信息的场景非常高效省去先遍历一次链表统计长度、再从头走一半的两遍遍历成本。6.2 双指针法数组和字符串题的常用套路双指针法常见于有序数组、字符串和滑动窗口类问题尤其在算法面试里几乎必考。它的核心思想是用两个指针分别维护不同的信息避免反复扫描。比如经典的两数之和问题先把数组排序然后左指针指向头部右指针指向尾部根据两数和与目标的比较不断调整其中一个指针的位置整个扫描过程从O(n²)降到O(n)。这类题目的关键点仍然是指针移动的边界条件。每轮循环都要保证两个指针不越界、不交错否则就会访问到非法地址。这个场景非常能检验前文讲的“越界访问”防线是否牢固。6.3 函数指针数组用函数地址组织跳转表函数指针是“指向代码段地址的指针”类型上表现为返回值和参数列表的组合。比如int (*fp)(int, int)声明了一个函数指针可以指向任何int f(int, int)签名的函数。而函数指针数组就是这些指针组成的数组常用于实现跳转表替代冗长的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[4])(int, int) { add, sub, mul, div }; int result ops[op_index](10, 2);这里的int (*ops[4])(int, int)读法比较绕但解析思路和const的“看紧跟谁”一致——ops[4]是数组前面*表示数组元素是指针再往前int (...)(int, int)表示指向的函数的签名。数组下标能映射到函数代码的可读性和扩展性都比一长串的if-else强修改时只需要往数组里增加函数指针即可。函数指针还有一个重要场景是回调函数机制。系统库或框架接受一个函数指针在特定事件发生时调用它。C标准库的qsort排序就要求传入一个比较函数指针void qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*));这种通用API的设计让函数指针的重要性无处遁形。7. 日常开发中常见指针问题排查与实录接下来我把实际调试中最常遇到的几类指针问题汇总成一个速查手册方便当成排查清单直接参考。这些内容全是实践中反复撞过的墙提前看一眼能省不少时间。7.1 空指针解引用最普遍也最好修现象程序崩溃报告segmentation fault堆栈定位到某一行的解引用操作。原因那个指针是NULL或接近NULL的无效地址。排查思路在解引用之前打印指针值或者用断言assert(p ! NULL)。修法追查为什么指针是空的多数情况是调用方没对返回值做空检查。7.2 段错误隐藏的野指针修改现象某块结构体数据突然变成乱码但并未立即崩溃运行一段时间后才崩。原因有一个野指针悄悄写入了相邻内存区域。排查思路用-fsanitizeaddress重新编译AddressSanitizer能在写入越界时立刻报出非法地址的访问现场。这个工具是我近十年调试生涯里最可靠的帮手强烈建议每个人都学会。修法定位到写入点修正越界逻辑。7.3 传指针还是传二级指针的混乱现象在函数内部修改指针返回主调函数后指针仍然没变。原因传的是Node* head函数内改的是副本。排查思路看函数签名如果需要对指针本身赋值参数类型必须带两个星号。修法void func(Node** head)函数内通过*head newValue来修改。7.4 const与指针混用的编译报错现象编译报错discards const qualifier或conversion loses qualifiers。原因把const int*传给int*参数或者反过来在赋值中丢失了const限定。排查思路看声明的const修饰对象是否匹配。修法要么参数改成const int*要么在必不得已时做显式强转这种强转应当极少出现出现一次就得写清楚注释。7.5 已释放内存仍然被访问现象数据偶发性错误或崩溃位置在free之后很久的地方。原因free后没置NULL代码在某个分支上继续使用了旧指针。排查思路开启内存检测工具检查free后的指针是否被访问。修法free后立刻置NULL同时检查所有引用该指针的路径。7.6 fopen/fclose与文件指针的配合陷阱再提一个容易被忽略的点文件指针FILE*在fclose(fp)之后同样应该置NULL。很多人在堆内存上记得置空文件指针上反倒忘了。fclose之后继续调用fread或fprintf造成的错误非常难缠因为有时不会立刻崩溃只是在测试环境里偶尔出现奇怪的结果。养成文件操作三件套习惯FILE* fp fopen(...); ...; if(fp) { fclose(fp); fp NULL; }能省很多排查时间。8. 写在后面的私房建议这篇文章啰啰嗦嗦写了不少最后分享几个我个人的习惯。第一我写任何指针相关的新代码前都会先在纸上画一遍内存模型把变量的地址、指针的值、指向的目标都标出来画完再动手。很多看起来玄妙的bug一画就现原形。第二const约束不要只在笔试时背真正开发时每写一个参数就问自己一句这个参数应该允许被修改吗如果答案是否定的立刻加上const。这种习惯一形成函数接口的健壮性会上一个台阶。第三别迷信“能跑就行”。指针代码里“能跑”和“正确”完全是两码事。今天能跑的野指针代码换个编译器、换个优化级别、换个数据规模就崩给你看。该初始化就初始化该置空就置空该检查边界就检查边界这些不是洁癖而是工程底线。最后给你一个可以长期使用的调试口诀先问值从哪来再问内存归谁最后问生命周期够不够长。所有指针问题都能在这三问里找到答案。下次再碰到段错误或者奇怪的内存覆写不妨从这三句话入手排查路径会清晰很多。
返回列表