最近给一位朋友讲《C语言第19章 函数的参数与返回值》时,我又双叒叕撞见了那道所有人都写过的经典代码:swap交换函数。他老老实实写了void swap(int a, int b),函数体里交换得有模有样,结果回到main里一printf,两个变量纹丝不动。朋友很认真地问:“我明明在函数里把值换过来了,为什么外面还是老样子?”这个问题看起来只是“形参实参”四个字的背诵题,但真正把参数和返回值搞透彻,恰恰要从这个翻车现场开始。
这一章我不想堆定义,也不打算机械地罗列“传值调用”“传址调用”的教科书表述。我想把函数调用时底层的参数复制、返回值寄存、指针参数的真实用途、数组和结构体参数的奇怪行为,以及做题和实际编码里最容易被低估的几个细节,从头到尾捋一遍。顺便,把一些和本章高度相关的练习题,比如5×5鞍点、字符串逆序、冒泡排序、PAT里的“霍格沃茨找零钱”,都放进来做例子。看完之后你再回去翻C语言教材第19章,应该会有完全不一样的感觉。
1. 经典翻车现场:swap函数为什么“交换了个寂寞”
1.1 值传递的底层逻辑:形参只是实参的复印件
很多初学者会把函数理解成“把变量丢进去,函数加工一下,变量就自己变了”。这个直觉在部分高级语言里勉强成立,但在C语言里完全不成立。C语言中函数参数只有一种传递姿势:值传递。调用发生时,实参表达式的值会被完整复制一份,然后塞给形参变量。你在函数里对形参做任何修改,改的都是那份复制品,和原来的实参变量半毛钱关系都没有。
打个比方,你拿着身份证原件去办事窗口,递进去的是窗口复印的一份复印件,工作人员在复印件上写写画画,你手里的原件不会多出一道划痕。实参x把值1复制给形参a,实参y把值2复制给形参b,然后你在swap内部交换a和b,交换的是两个独立的新变量。main函数里的x和y,从头到尾就没参与过交换。
如果再往下挖一层,就涉及函数调用时的栈帧概念。每次调用函数,系统都会在当前调用栈上为它的局部变量和形参开辟一块独立空间,这块空间在函数返回后自动释放。所以形参a和b是函数私有的“临时工”,函数一结束,它们就被清退。这也是为什么很多人调试的时候看到函数内部打印的值是对的,回到调用方一看又变回去了,原因就在这一层复制的隔阂。
1.2 想让函数改变外面的世界,必须传地址
要解决swap问题,正确的写法几乎人尽皆知:把参数改成指针。
void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main(void) { int x = 1, y = 2; swap(&x, &y); printf("x=%d y=%d\n", x, y); return 0; }这里传进去的其实是x和y的地址。形参a、b的类型是“指向int的指针”。从本质上看,这仍然是一次值传递——只不过这次复制的东西是x的地址,而不是x的值。函数内部使用*a去读写a所指向的内存,读写的对象就直接命中main函数里的x。所以C语言里那句经典论断“一切传参都是值传递”依然成立,只不过当你传的是地址时,这个“值”本身携带了定位能力。
这也解释了scanf为什么总要加&。当你写scanf("%d", &num),实际上是在把num的地址交给scanf,让scanf从键盘读一个整数,直接写到num所在的那块内存。如果你不小心漏掉&,scanf会把num的值当作地址,轻则写进一个非法区域导致程序崩溃,重则悄悄覆盖一块不该动的内存。这里我特别提醒初学者:指针参数和普通整数参数在写法上都很像“传一个数字”,但两者的意图完全不同。指针搞清楚的是“你住在哪”,普通变量关心的只是“你现在值多少”。
有一次我帮一个朋友调试链表翻转函数,他在函数内部创建了新的链表头,返回值却声明成了void。他以为改到了外部指针变量就算成功,实际上他那个函数只是在局部变量上挂了个新节点,函数一退出,整条链表的头就找不到了。那一刻他真正理解了参数与返回值的分工:参数负责把数据送进门,返回值负责把结果带出门,而指针参数相当于“能在你家里拆墙的施工队”。
2. 返回值:你以为“return一下”很简单,背后是寄存器与存储期限的权衡
2.1 return的底层:它带回来的值存在哪里
当函数执行return 42;时,处理器一般会把结果放进一个约定好的寄存器,比如x86-64环境下的RAX,然后执行ret指令。函数自己的栈帧随后销毁,但寄存器的值仍然保留,调用者从寄存器里把结果取走。这就是为什么函数返回一个局部变量是完全安全的:局部变量的内存虽然销毁了,但它的值在返回之前已经被复制到了寄存器里,相当于人走之前把钱转走了。
所以int、double这种基础类型的返回值几乎没什么悬念。真正有意思的是结构体。现代编译器在函数返回较大的结构体时,往往会在调用者的栈上预留一块隐藏缓冲区,函数内部把结构体内容拷进缓冲区,调用者再从缓冲区取。语法上你只写了一个return,底层其实搬了好几次家。因此“按值返回结构体”在C语言中完全合法,但如果结构体很大,每次返回都是一次内存搬运,性能损耗就很实在了。
新手容易犯的一个错误是:以为函数可以像Python那样一次性返回多个值。C语言没有这种语法。你只能返回一个东西,想要多个数据就得靠结构体,或者靠后面要讲到的指针出参。
2.2 返回指针,最容易踩的悬空指针陷阱
返回值的真正坑,藏在返回指针里。最典型的翻车代码长这样:
char *get_message(void) { char buf[64] = "hello"; return buf; // 危险 }buf是函数内的局部数组,函数一结束,栈空间就被回收。这之后返回的地址就成了悬空指针。编译器不一定报错,程序运行也可能碰巧输出hello,因为那块内存还没被立刻覆盖。但只要后续再调用几个别的函数,栈上内容被写乱,输出就会变成乱码,甚至直接段错误。这种问题比立刻崩溃更折磨人,因为它有时候能跑,有时候不能跑,毫无规律可循。
我实际写项目时,遇到需要从函数返回一块内存的情况,一般有三条稳妥路线:
| 方案 | 写法要点 | 代价 |
|---|---|---|
| 返回字符串字面量 | return "hello"; | 字面量有静态存储期限,但不能修改 |
| 返回static局部变量 | 在函数内声明static char buf[64]; | 所有调用方共享同一块内存,会被后续调用覆盖 |
| 返回堆内存 | 用malloc分配,返回指针 | 调用方必须记得free,否则泄漏 |
三种方案各有适用场景。临时打印个只读消息,用字面量最省心;函数只维护一个内部缓冲,可以用static;想要每次调用都有独立、可修改的数据,就老老实实走malloc。很多教程只强调“不能返回局部变量”,却不解释替代方案,导致初学者彻底不敢返回指针了。其实返回指针并不可怕,可怕的是没想清楚这块内存在函数退出之后到底归谁、谁负责释放。
2.3 返回值不够用时:把“状态码”和“业务结果”分开
实际项目里,一个函数经常需要同时告诉调用者两件事:操作有没有成功,以及成功的时候结果是多少。C语言里最常见的约定是“返回值承载状态码,指针参数承载业务结果”。
int divide_int(int a, int b, int *result) { if (b == 0) { return -1; // 失败 } *result = a / b; return 0; // 成功 }调用方先判断返回值,再使用result。这种模式在标准库和Linux系统编程里四处可见,比如scanf返回成功匹配的个数,strtol用尾指针传出解析停下的位置。养成这种习惯能规避一个特别隐蔽的问题:如果函数既要用返回值传数据,又想在出错时返回一个特殊值,最后就得用魔法值硬扛。我在代码评审里见过有人用-1表示“没找到”,结果某天业务数据恰好算出个-1,整个程序就开始安静地错乱,排查起来非常痛苦。
所以我一贯的主张是:返回值优先表达函数执行的“结果状态”,而不是业务数值。业务数据用出参、用结构体、用指针来传递,都比用返回值硬塞更清晰。
3. 参数设计里那些容易被忽略的细节
3.1 数组参数为什么默默变成指针,以及为什么sizeof会骗你
把数组传给函数是C语言里最举重若轻的一个操作,也是误解最多的地方。你写:
void print_array(int arr[]) { int n = sizeof(arr) / sizeof(arr[0]); // 你一定以为这就是数组长度 }然后你发现sizeof(arr)的值是8而不是40。原因在于,C语言规定数组作为函数参数时,会发生“数组退化为指针”。函数签名里的int arr[]本质上会被编译器改写成int *arr。也就是说,数组传参时根本没有把整个数组复制进函数,只复制了数组首元素的地址。这既节省了内存,也带来了一个铁律:在函数内部,光靠数组参数无法知道数组有多长。
解决方案很朴素:把长度一并传进去。
void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } }这个“数组长度”参数几乎是所有C函数设计里最不起眼但最重要的一环。我看到不少初学者写的排序、查找函数,只传数组不传长度,然后靠着某个魔法宏或者全局变量去猜长度,一旦数组规模变化,程序就开始越界读写。越是老练的人,越会在函数签名里老老实实写上int n,这不算啰嗦,这是让函数可以脱离上下文独立运行的底线。
3.2 结构体:传值还是传地址,这笔账要会算
结构体作为函数参数有两种选择:直接传结构体副本,或者传结构体指针。前者会把整个结构体逐字节复制进函数,后者只传一个地址过去。
我见过有人写一个保存学生信息的函数,结构体里有姓名、学号、几十门课成绩,每次调用都把整个结构体传进去,代码是能跑,编译也通过,但程序性能被拖得一塌糊涂。对于这种体积较大的结构体,正确姿势是传指针:
typedef struct { char name[32]; int scores[30]; int id; } Student; void print_student(const Student *stu) { printf("%s %d\n", stu->name, stu->id); }这里的const也很有讲究。const Student *stu的意思是“你不能通过stu修改这个结构体”,函数只能读取它。这等于在函数签名里写下一份承诺:我只读你的数据,不碰你的数据。编译器会在你试图stu->id = 100;时报错,把这个可能破坏数据的低级失误从运行时提前到编译期。我在团队里一直强调,C语言不是只有“能跑”和“不能跑”两种状态,还有很多“跑得歪”的状态,比如在大型项目里,一个函数不该改的数据被悄悄改了,造成的连锁故障远比直接崩溃难查。
那什么时候适合直接传结构体值呢?结构体本身很小,比如就两三个int字段,而且你的确需要一份副本去修改它,不希望影响调用方原来的结构体。这时传值简单直观,甚至因为ABI会把部分小结构体塞进寄存器,效率也不差。原则一句话:需要读不改,传const指针;需要改,传普通指针;要副本,才传值。
3.3 const放在哪里,含义完全不同
谈到指针参数,const就是绕不开的细节。三行代码,三种语义:
const int *p; // p指向一个const int,不能通过p修改目标 int *const p; // p本身不能指向别的地址,但可以修改目标 const int *const p; // 两者都不能改许多刚学指针的同学看到这几种写法的第一反应是:这有什么意义?区别可太大了。拿函数签名举例,int find_max(const int *a, int n)非常明确地告诉调用方:a所指向的数据我不会改,你放心传。而int *const p更多用在函数内部:你希望p始终指向同一个内存区域,避免手误让它飘到别处去。
我自己的编码习惯是:函数参数里的指针,凡是只读用途的,一律加const。这其实是一种“接口契约”。写出来既是给自己看的,也是给编译器一个把关的机会。等函数代码量大了,你很难记住哪个参数是只读、哪个是要被修改的。const一标注,别人读你的代码就能少猜很多。
3.4 可变参数:高回报高风险,能不用就别用
C语言里还有一类参数个数不固定的函数,典型代表就是printf。它靠格式字符串里的%d、%f告诉函数参数的类型和数量,然后函数内部通过va_list、va_start、va_arg、va_end逐个取出参数。
有个段子说:C语言的printf能编译通过,但运行起来能给你表演一场“内存读杂技”。比如printf("%d", 3.14),它会把double的二进制位的一部分当作int来读取,输出一个莫名其妙的值。在某些编译选项下,编译器连警告都不给。这种机制完全没有类型安全可言,因为你把“参数有哪些、是什么类型”这件事全都寄托在格式字符串上,而格式字符串和后面实际传入的参数是否匹配,编译器帮不了多少忙。
自己写可变参数函数时,比printf更麻烦:你必须有一个办法知道参数个数,否则va_arg会一路读越界。所以我的原则很简单:能不用可变参数就不用。如果只是为了打印调试信息,写几个固定参数的格式化函数已经绰绰有余。可变参数适合标准库级、充分验证过的场景,不适合日常业务代码里图省事。
4. main函数和经典练习题里的“参数-返回值”实战
4.1 main不是普通的“函数”,它在和操作系统对话
main函数的参数和返回值,其实是整个程序与操作系统的一场对话。标准写法是int main(int argc, char *argv[])。argc是命令行参数的个数,argv是每个参数的字符串数组,argv[0]通常是程序名。返回值传给操作系统,约定返回0表示正常结束,非0表示出错。shell脚本、CI工具、以及系统服务里的启动脚本,都是靠这个值来判定程序到底跑没跑成功。
C语言还存在一个历史悠久的“void main”争议。很多老编译器和某些嵌入式IDE里能见到void main(),但标准C并不认可这种写法。不要小看这一点,我之前帮一个项目排查过链接问题,启动代码里把main声明成了void,换一个工具链之后,链接器对返回值的使用出现了预期差,排查了半天才定位到就是这个签名惹的祸。所以无论你的程序多小,请老老实实写int main(int argc, char *argv[]),或者参数不用时可以写int main(void)。
4.2 5×5鞍点问题:二维数组做参数时最容易漏掉“列数”
搜索热门列表里有一道高频题:用stdio.h和limits.h求5×5矩阵的鞍点。所谓鞍点,是指这个元素在它所在的那一行最大,同时在它所在的那一列最小。这类题目特别适合检验参数与返回值的理解,因为二维数组作为参数时,对“列数”有硬性要求。
int find_saddle_point(int matrix[5][5], int *row, int *col) { for (int i = 0; i < 5; i++) { int max_col = 0; for (int j = 1; j < 5; j++) { if (matrix[i][j] > matrix[i][max_col]) { max_col = j; } } int min_row = 0; for (int k = 1; k < 5; k++) { if (matrix[k][max_col] < matrix[min_row][max_col]) { min_row = k; } } if (min_row == i) { *row = i; *col = max_col; return 1; } } return 0; }这段函数分离了两件事:先锁定第i行的最大值所在列,再在这个列上找出最小值所在行;如果那一行正好是第i行,说明当前这个元素就是鞍点。返回1表示找到,row和col带回坐标;返回0表示没有。这里就体现了“返回值装状态,指针出参装结果”的设计思路。如果硬要一个返回值同时表示“找到没有”和“坐标是多少”,最后要么拼出一个魔法值,要么把坐标编码进返回值,怎么都别扭。
我在讲二维数组的时候还发现,很多同学会在函数形参里写int matrix[][5],却漏掉了列数5,导致编译报错或无法正确索引。二维数组传参时,第一维长度可以不写,但第二维长度必须明确,因为编译器需要用列数来计算数组寻址的偏移量。这一点是本章概念在实际解题里的最直接延伸。
4.3 字符串逆序和冒泡排序:应该返回指针,还是原地修改
字符串逆序是PTA里出现频率很高的题,也是初学者最容易纠结“我到底该返回什么”的题。常见的写法有两种:
void reverse_str(char s[]) { int len = strlen(s); for (int i = 0, j = len - 1; i < j; i++, j--) { char t = s[i]; s[i] = s[j]; s[j] = t; } }这种“原地反转”版本修改的是调用者传入的字符数组本身,函数不需要返回值。调用方执行完这个函数,字符串已经被倒过来了,干净利落。另一种写法是char *reverse_str(char *s),在函数内部malloc一块新内存,返回反转后的新字符串。代价是调用方得记得free。
到底选哪个?我自己的习惯是优先原地修改。原因很简单:资源所有权清晰。函数没有产生新内存,调用者不需要记着释放;而且字符串逆序这个操作本身,没有“保留原串”的必要。反过来,如果你的函数需要返回一个全新的、独立于输入的字符串,那就老老实实malloc,并在注释里写明“返回值需要调用者free”。以后你回头看代码,少猜一次内存泄漏。
冒泡排序也一样。我写排序函数时几乎总是定义成:
void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; } } } }void返回值并不是“没设计”,而是一种设计决策。排序本身是对原数据的重排,函数不做新数据生产,自然不需要返回新数组首地址。如果你让排序函数返回int*,调用方反而容易误以为这份数据是函数新创建的,后续处理时出现重复free或者忘记free的问题。函数签名应该努力做到让人一眼看出:这个函数会在哪块数据上动手,以及它的输出到底放在哪里。
4.4 从PAT“霍格沃茨找零钱”看返回类型设计
PAT乙级里有一道题叫《在霍格沃茨找零钱》,要求处理类似“加隆-西可-纳特”的三级进制找零。很多人一上来就设计一个函数,返回一个int表示“应找的钱数”。问题是钱有三种单位,返回哪种都丢信息。这道题特别适合当成参数与返回值设计的综合案例。
更好的做法是用结构体配合指针出参:
typedef struct { int galleon; int sickle; int knut; } Money; int change(Money paid, Money cost, Money *result) { long paid_total = paid.galleon * 17 * 29 + paid.sickle * 29 + paid.knut; long cost_total = cost.galleon * 17 * 29 + cost.sickle * 29 + cost.knut; if (paid_total < cost_total) return -1; long diff = paid_total - cost_total; result->galleon = diff / (17 * 29); result->sickle = diff % (17 * 29) / 29; result->knut = diff % 29; return 0; }这里既有结构体做入参,又有结构体指针做出参,返回值专门用来表示成功失败。三种都在一个函数里出现。正的金额差异被统一换算成最小单位再折算回去,避免逐位借位换算的麻烦。这题做下来,参数传值、传地址、返回值状态码这些概念全都能用上,比背十遍定义都管用。
平时我遇到同学纠结“该用返回值还是出参”,都会让他先画一张数据流向图:这个函数处理完,结果数据到底应该出现在调用者的哪一块内存里?如果结果本来就该住在调用者的结构体里,那就是出参;如果结果需要脱离函数独立存在、有独立生命周期,那就要考虑返回值或堆内存。
5. 我在函数边界上坚持的几个小习惯:踩坑多年攒下来的经验
5.1 打开-Wall -Wextra,认真对待隐式声明
C语言函数原型声明有一个古老的坑:如果编译器在调用点之前没有看到函数原型,C89标准下会默认假定函数返回int,参数类型也按照“提升后的类型”处理。这和你实际定义的类型一旦不一致,程序运行就非常魔幻。我调试过一位同学写的double返回函数,因为漏了头文件声明,编译器以int来读取寄存器,结果只拿走低32位,输出全是一堆乱码。
用gcc或clang时,从第一天就打开-Wall -Wextra,不要等到程序莫名其妙才想起来开。编译器给出的隐式声明警告,十有八九都指向真实存在的接口设计问题。自己写头文件时也要养成习惯:所有对外函数原型集中列在一起,实现文件里包含自己的头文件,这样编译器会自动帮你校验函数定义和原型声明是否一致。这一下就能挡掉大量“函数签名改了一处、调用点忘了改”造成的错位。
5.2 命名与注释:把函数签名当成一份合同
C语言参数没有默认值,没有重载,函数签名一旦定下来就是一份很硬的合同。我见过太多类似int f(int a, int b, int c)的代码,不翻实现根本猜不到参数含义。
现在我写函数,凡是参数超过三个的,几乎都会在头文件里写明每个参数是输入、输出还是输入输出,用in/out/inout来标。函数名本身也要尽量带动作语义,比如copy_file(const char *src, const char *dst, int overwrite),一眼就能看出源、目标和覆盖策略。这些不是花架子,是给三个月后的自己省时间。你永远会高估自己对旧代码的记忆力,也永远会低估别人的注释需求。
5.3 边界值和哨兵值,每写完一个函数都值得多跑几遍
说到参数与返回值,最实用的测试习惯就是盯边界。空数组、长度0、只有一个元素、最大负数、溢出边界,这些才是暴露真相的好地方。字符串逆序如果只有5个字符,中间元素刚好不动;如果6个字符,中间两个要互换。你如果只测奇数长度,很可能把一个错误的循环边界带进项目。
我改别人代码时,会先拿几组边界值快速验证,而不是只在正常路径上跑一把。很多“碰巧能跑”的函数,都是在边界条件下露出马脚。调试返回值问题还有一个笨办法但非常有效:在函数入口和出口各打印一组关键值,先定位问题是参数传进来就错了,还是函数内部算错了,还是返回后处理错了。真正常见的坑,仍然集中在“参数复制”和“返回值生命周期”这两类,定位到这两个层面,问题基本就清楚了一大半。
C语言的函数参数与返回值,说到底就四个关键词:值传递、地址传递、返回状态、生命周期。把swap为什么失效、返回局部数组为什么崩、数组参数为什么得不到数组长度这三件事想明白,这一章的核心就算真正嚼透了。剩下的,都是在无数个练习题和真实项目中一遍遍强化出来的手感。