C语言函数参数传递详解:值传递与地址传递的本质区别

📅 发布时间:2026/10/20 18:34:21
C语言函数参数传递详解:值传递与地址传递的本质区别
1. C语言函数参数传递的基本盘只有值传递和地址传递1.1 先把结论拍在桌面上C语言的函数参数传递从教学和实用角度来分确实只有两条路值传递和地址传递。所谓的“引用传递”是C里的概念C语言从语法层面压根没有这个东西。我知道这个结论一出一定有朋友跳出来说“我平时写的void func(int *p)不就是引用传递吗传进去一个指针函数里改一改外面变量的值就变了这跟引用有什么区别”别急这篇文章就是来把这层窗户纸捅破的。传指针确实能产生“像引用一样的效果”但它内在的机制依然是值传递只不过这个“值”恰好是地址而已。用我的话来说C语言只有一种传参机制就是传值而“地址传递”是通过传值地址来实现的。教学中为了强调效果把这第二种常见用法单独称为“地址传递”这没问题但如果因此认为C支持了和C一模一样的引用传递那就大错特错了。这个知识点为什么值得划重点因为它几乎是C语言初学者的“第一个认知分水岭”。没搞懂它后面学指针、链表、二叉树、文件操作、多级指针全都会觉得别扭搞懂了它你看别人代码里的**、、*会通透很多排查“函数执行完外部变量没变”这类bug也会快很多。1.2 值传递在内存里到底发生了什么先说最基础的值传递。看这段代码void change(int a) { a 100; } int main() { int x 10; change(x); printf(%d\n, x); // 输出 10 return 0; }调用change(x)时系统把x里的值10复制了一份交给函数内部的形参a。此时内存里有两个独立的盒子一个叫x里面装10另一个叫a里面也装10。函数里执行a 100改的是形参a这个盒子x那个盒子的内容纹丝不动。很多新手会困惑“我不是把x传给函数了吗为什么x没变”因为传过去的不是那个盒子本身而是盒子里的“数字副本”。类比一下就是你把自己的茶杯照片发给朋友朋友在照片上用笔圈了一圈你的真茶杯不会因此多一道划痕。如果你在函数体内打印a和x会发现这两个地址完全不同这从侧面说明它们是两个独立的内存单元。1.3 地址传递多走了一步本质依然是拷贝再看这段代码void change(int *a) { *a 100; } int main() { int x 10; change(x); printf(%d\n, x); // 输出 100 return 0; }这次调用change(x)传进去的是x的地址。形参a接收到的是一个地址值比如0x7ffdabc。函数内*a 100意思是顺着这个地址找到x所在的内存单元往里面写入100。于是外部的x变成了100。关键点来了形参a本身依然是一个独立变量它也被复制了一份。只是它里面保存的“值”是地址而通过解引用*a我们能访问到地址背后的那个真实对象。这就是为什么中文教材里管这种用法叫“地址传递”——它传递的内容不再是普通数值而是地址这个特殊的值。注意这里要咬文嚼字一下从C标准的角度看int *a形参收到的依然是一个值地址值所以底层依然是“值传递”。但教学上为了区分“传数值”和“传地址”带来的不同效果把后者叫“地址传递”是完全合理的日常共识。本文标题说的“值传递和地址传递两种方式”正是采用这个教学口径而题目真正要划的重点是C不支持像C那样的“引用传递”语法实体。2. 传指针就是引用传递这是C语言最大的误解之一2.1 指针变量本身也是变量形参指针只是副本很多人潜意识里把“传指针”等同于“传引用”最直接的证据来自下面这个翻车现场void change_ptr(int *p) { p NULL; // 试图把外部指针改成NULL } int main() { int x 10; int *ptr x; change_ptr(ptr); if (ptr NULL) { printf(外部指针被改掉了\n); } else { printf(外部指针纹丝不动仍然指向 x\n); } return 0; }运行结果显然是“外部指针纹丝不动”。为什么因为在一个带指针参数int *p的函数里形参p是实参ptr的一份拷贝。你执行p NULL改的是拷贝指针自己的指向外部的ptr哪里会受影响。如果C真的支持引用传递那么change_ptr里对形参的修改会直接作用到外部实参上ptr就会变成NULL。这个例子就是区分“指针传参”和“引用传参”最干净利落的方式传指针时形参指针本身是可以被重新赋值的副本传引用时形参就是实参的别名。2.2 既然形参是拷贝为什么还能修改外部变量这里就会产生一个很自然的疑问既然形参指针是拷贝的那为什么*a 100能改变外部x核心在于形参a和实参x虽然存储在两个不同的内存单元但它们里面保存的“地址值”是同一个。也就是说a和x的地址指向同一个目标。所以*a和x操作的是同一个内存单元。用一个地图类比你手里有一张写着“小区3栋502”的纸条你朋友手里有一张复印的纸条上面的字一模一样。你朋友按纸条找到小区3栋502把门牌换成了“8888号”你回家发现自己家门牌确实变了但如果你朋友只是把手里那张纸条揉成一团扔了你家门牌不会有任何变化。所以结论是传指针能够改“指针指向的目标”但改不了“外部指针变量本身”。把这两个概念分开很多C语言问题立刻清晰起来。2.3 为什么传地址的效果容易让人误以为它是引用坦率地说从调用者的角度看void change(int *a)这种函数确实能在函数内修改外部变量看起来和C的引用void change(int a)效果很像。这也是误解最直接的来源效果相似就以为机制相同。再加上一堆语言里的“引用”概念满天飞Java、C#、Python里有各种“引用对象”“按引用传递”的说法很容易让刚学C的朋友产生联想既然C里传指针就能让函数改外部变量那它应该就是“按引用传递”吧但严谨地说C没有“引用”这个类型也没有“引用变量”这个语法实体所以“引用传递”在C语言中无从谈起。顶多说一句“传地址可以达到类似引用的效果”。这个概念切不可混。2.4 用Printf验证“形参是副本”这个事实百闻不如一验。写一个诊断程序把函数内外相关变量的地址都打印出来一眼就能看穿机制void inspect(int *p) { printf(函数内p 的值 %p\n, p); printf(函数内p %p\n, p); } int main() { int x 10; int *ptr x; printf(函数外ptr 的值 %p\n, ptr); printf(函数外ptr %p\n, ptr); inspect(ptr); return 0; }在我的机器上输出类似这样函数外ptr 的值 0x7ffedcba1234 函数外ptr 0x7ffedcba1298 函数内p 的值 0x7ffedcba1234 函数内p 0x7ffedcba1260看到没有ptr的值和p的值相同说明它们保存的地址一致但ptr和p不同说明p是独立存放的变量副本。这就直接证明了“传指针也是传值”。3. 想通过函数修改外部变量C语言到底应该怎么写3.1 交换两个变量的标准写法必须传地址面试里考烂了的交换函数本质上就是地址传递的经典应用void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 10, y 20; swap(x, y); printf(x%d, y%d\n, x, y); // 输出 x20, y10 return 0; }如果写成void swap(int a, int b)函数体内交换的是形参a和b这两个独立变量调用结束后外部x和y完全不换。为什么传地址就能换因为*a和*b分别解引用到外部x和y的内存单元交换*a和*b就是交换x和y的值。注意这个函数执行完形参a和b这两个指针变量本身的值并没有变它们依然分别指向外部x和y。改变的只是它们指向目标的内容。你要是试图在swap里交换a和b自己那才是永远翻车。3.2 连指针本身也想改那就得用“指针的指针”有一个高频场景想让函数给外部的指针变量分配内存或者让外部指针指向新对象。比如写一个“分配数组”的函数void alloc_array(int **pp) { *pp (int*)malloc(sizeof(int) * 10); if (*pp ! NULL) { (*pp)[0] 42; } } int main() { int *arr NULL; alloc_array(arr); if (arr ! NULL) { printf(%d\n, arr[0]); // 输出 42 free(arr); } return 0; }这里的套路是pp是一个二级指针保存的是外部指针变量arr的地址。*pp解引用之后操作的就是外部指针变量arr本身所以可以修改它让它指向新分配的内存。这和前面“形参拷贝”并不矛盾pp虽然也是拷贝但它拷贝的是arr拿着这个地址就能改arr。一句话总结改普通变量用一级指针改指针变量本身用二级指针。这也是“链表头插法”“二叉树根节点替换”在纯C里经常写二级指针的原因。3.3 除了传地址返回值也是一个优雅的方案不是所有场景都非要传地址。如果你想修改一个变量并且这个变量只由一个函数产生新值用返回值更清晰int add_one(int x) { return x 1; } int main() { int a 1; a add_one(a); // a 变成 2 return 0; }这种“纯函数”风格可读性好也更容易测试。它的限制是如果你同时想修改两个或更多个外部变量或者要修改一个指针变量本身的指向返回值方案就会有点绕。实战中我的习惯是只改一个值且逻辑适合“输入→输出”时优先用返回值。需要同时修改多个对象、修改数组内容、修改指针指向时用地址传递。需要修改“指针自身”这种场景时用二级指针或者定义一个包装结构体把指针包起来再传地址。3.4 链表、二叉树里的高频场景头节点为什么经常用二级指针举一个几乎所有人写C链表都会遇到的例子头插法typedef struct Node { int val; struct Node *next; } Node; void insert_at_head(Node **head, int value) { Node *new_node (Node*)malloc(sizeof(Node)); new_node-val value; new_node-next *head; *head new_node; // 修改外部头指针 }为什么insert_at_head(Node **head, int value)要写成二级指针因为在这个函数里我们不仅往链表里插入了一个节点还修改了外部头指针head的指向。一级指针Node *head形参只是外部头指针的拷贝你让形参指向新节点外部头指针根本不理会。只有把外部头指针的地址传进来用*head new_node才能让外部头指针指向新节点。当然也有人写成Node *insert_at_head(Node *head, int value)返回新的头节点让调用者自行更新。两种风格都行但很多老牌C代码偏爱二级指针因为它把“更新调用者变量”这件事隐式完成了调用者不容易忘记接收返回值。缺点是对新手不够友好。理解了“指针形参是拷贝”之后这两种方案都能看得懂。4. 对比C的“引用传递”C到底缺了哪门课4.1 C的引用到底是什么C里的引用传递语法长这样void add(int a) { a 100; } int main() { int x 10; add(x); // x 变成 110 return 0; }这里a不是x的拷贝而是x的别名。函数里对a的任何操作本质上就是对x的操作。编译器在底层通常会把引用实现成类似指针的东西但在语法层面引用和指针有本质区别引用必须在定义时初始化一旦绑定就不能改绑。引用没有自己的独立地址严格说你在代码里取a得到的还是x的地址。指针可以是NULL引用不可能是“空引用”。这种语法级的“变量别名”在C语言里不存在。所以“C不支持引用传递”这句结论不是说C开发人员做不到“让函数修改外部变量”而是说C少了一把名叫“引用”的语言级语法钥匙。4.2 三种传递方式对照表为了帮大家快速对比我列一张表传递方式C语法C语法形参是实参的副本吗函数内能改外部变量吗能修改外部指针本身吗值传递int aint a是否不适用地址传递int *aint *a是指针形参是副本通过解引用可以否引用传递不支持int a否就是别名可以若形参为int *p则可以这张表最后一列值得多说一句在C里你可以写void change_pointer(int *p)让形参p成为外部指针变量的引用这样函数里p NULL就能直接把外部的指针改成NULL。这恰恰是C语言做不到的——在C里你必须用int **再加一层间接。所以搞懂C的传参限制对你看C代码也有帮助你至少能理解“引用”的出现到底填补了哪个痛点。4.3 C为什么坚持不加引用纯C工程又该怎么凑合C语言诞生于上世纪七十年代早期设计哲学是“简单、直接、贴近硬件”。函数调用时参数怎么传、内存怎么布置程序员心里要有一本账。引用作为语法糖会掩盖一部分内存操作的细节这和C的设计理念有冲突。C在设计时为了支持类、运算符重载、拷贝构造等复杂特性需要引用这种更安全、更隐式的传递手段于是把它加进了语法。纯C工程里没有引用想要达到“函数内修改外部变量”的最常见做法就是传地址也就是传指针。如果嫌二级指针难看可以用typedef把指针包一层typedef int* IntPtr; void init(IntPtr *pp) { *pp (int*)malloc(sizeof(int)); }本质上还是指针的指针。另一种思路是把相关变量放进一个结构体传结构体指针这是C语言里非常常见的“输出参数”模式。5. 常见翻车姿势与地址排查实战5.1 翻车一数组名传参到底算哪种void modify(int arr[]) { arr[0] 99; } int main() { int a[5] {1, 2, 3, 4, 5}; modify(a); printf(%d\n, a[0]); // 输出 99 return 0; }这个输出让人以为“数组整体传过去了”很像引用传递。但实际上C语言里数组名作为函数实参时会“退化”成指向首元素的指针。int arr[]这个形参编译器完全按int *arr处理。所以modify(a)传过去的是数组首元素的地址arr[0]就是*(arr 0)自然能改外部数组的内容。想验证这一点很简单在modify函数里执行printf(%zu\n, sizeof(arr));你会发现结果不是整个数组的字节数而是一个指针的大小。这足以说明数组形参并没有“拷贝整个数组”那只是一个指针。5.2 翻车二以为传了指针就能让外部指针指向新内存再看这段经典错误代码void assign(int *p, int value) { p (int*)malloc(sizeof(int)); *p value; } int main() { int *q NULL; assign(q, 100); printf(%d\n, *q); // 这里大概率崩溃 return 0; }很多新手以为给assign传了指针q函数里p malloc(...)就能让外部q指向新内存。可惜形参p是q的拷贝函数里让p指向新内存外部q依然是NULL。随后*q访问空地址程序直接崩掉。正确做法在3.2已经演示过必须传二级指针int **p或者在函数里返回新指针。这种bug极其隐蔽因为它不是编译报错而是运行期崩溃让人排查半天。5.3 翻车三交换两个指针变量的指向再猜一道常见笔试题void swap_ptr(int *a, int *b) { int *tmp a; a b; b tmp; }外部两个指针变量p1和p2被传入后函数结束p1和p2的指向会互换吗答案是完全不会。因为a和b只是形参指针的拷贝交换它们的指向影响不到外部。想要交换外部指针的指向必须传二级指针void swap_ptr(int **a, int **b) { int *tmp *a; *a *b; *b tmp; }这个例子其实和“交换两个int变量”是同一套逻辑要修改外部变量就传外部变量的地址外部变量是int时用int *外部变量是指针时用int **。一层一层往上套规律非常清晰。5.4 自查大法遇到“外部变量没变”先打印几行地址我调试C语言函数传参问题时有一套屡试不爽的排查流程第一步先看函数形参是什么类型。如果形参只是普通值类型那函数内修改当然影响不到外部这直接就能解释。第二步如果形参是指针但外部变量没变就要区分“是要改目标还是要改指针本身”。想改指针本身而只用了一级指针99%会翻车。第三步写几行临时printf/log代码把关键地址打出来函数外外部指针变量的地址、指针的值、指针指向变量的值。函数内形参指针变量的地址、指针的值、指针指向变量的值。只要看到外部指针变量的地址和形参指针变量的地址不同就能确定形参是副本。看指针的值是否相同就能判断它是否还指向同一个目标。这个流程不依赖任何高深工具一个printf就能把问题查得明明白白。5.5 一段实用的记忆口诀我自己带人时常用一段口诀这里分享给你传参两种路值和地址地址也是值指针要拷贝改目标用解引用改指针用二级引用只在C有C语言里别认亲。这段话能覆盖绝大部分场景。每次写函数前在心里过一遍“我这个函数到底要改什么”决定用什么样的形参代码一次写对的概率会高很多。6. 弄懂这个知识点连锁影响远超想象6.1 笔试题里的高频考点基本都能秒杀C语言笔试题里和参数传递沾边的题翻来覆去就那么几类能不能写一个swap交换两个数、链表头插为什么用二级指针、二叉树插入为什么返回根节点、字符串数组传参后为什么能修改内容。这些题的本质全都落在“形参是副本”和“地址传递能改目标”这两个核心认知上。把这篇文章里的代码自己跑一遍相关题型基本出不了你的手掌心。6.2 阅读老牌C代码库不再被**吓到很多经典C项目里充斥着大量多级指针参数比如解析命令行参数、封装内存分配函数、接口层“输出参数”等。以前看到int **out可能会头大现在明白它的含义是“我要在函数内部修改一个外部指针的指向”阅读代码的速度会明显提升。6.3 排查bug的思路会干净很多日常开发中遇到“函数执行完变量值没变”的bug排查方向会异常清晰先判断形参和实参的关系再确认函数内部是否修改了“指针自己的值”最后再用printf打印地址确认。这样一套下来大部分指针疑难杂症都能在几分钟内定位而不是漫无目的地打断点看半天。6.4 对学习C也有正向帮助搞懂了C的传参机制后再学C的引用你会比别人快很多。因为你心里已经有一个对比坐标C的地址传递需要手动解引用C的引用传递不需要C想改指针变量得用二级指针C直接声明int *即可。这种对比式学习比单纯背语法牢固得多。最后说一个我自己多年的习惯不管代码写得对不对牵扯到指针参数修改时我第一步就是先打印函数内外的地址。把实参指针自己的地址打出来把形参指针自己的地址打出来只要两者不同就说明形参是拷贝。这个习惯帮我解决了不知道多少个“明明传了指针却改不动”的深夜bug。要是你也被这种问题折磨过不妨找十分钟把这个实验亲手跑一遍跑完你对C函数传参的理解会比紧盯着教材看十遍都扎实。