资讯动态

C语言函数核心:参数传递、递归与函数指针全解析

发布时间:2026/10/14 5:35:40 来源:尧图企业网站定制
C语言学到“函数”这一章差不多算是正式开始写“能复用”的程序了。前面几章的变量、循环、数组解决的都是“这一步做什么”的问题而函数解决的是“这件事我要反复做还希望以后好维护、好复用、好改”的问题。很多自学者走到这里会有一个错觉——函数不就是把代码包起来吗好像没什么难的。但实际写下去你就会发现参数传递怎么传、值为什么换不回来、返回指针怎么老是错、递归到底是怎么展开的每一个都是坎。这篇笔记就把第五章涉及的核心内容完整拆一遍从函数的基本形态讲到参数机制、作用域、递归和函数指针全程配合小代码示例和具体踩坑说明适合刚学到函数、或者学完一遍想回头巩固的人。1. 函数解决的第一个痛点代码重复1.1 没有函数的代码能膨胀到什么程度先想象一个场景你要在一个班级管理系统里分别算三个同学的平均分。没有函数的时候代码大概是复制三遍#include stdio.h int main(void) { double s1[3] {80.5, 90.0, 70.5}; double sum1 s1[0] s1[1] s1[2]; printf(同学A平均分: %.2f\n, sum1 / 3); double s2[3] {60.0, 75.5, 88.0}; double sum2 s2[0] s2[1] s2[2]; printf(同学B平均分: %.2f\n, sum2 / 3); double s3[3] {95.0, 66.5, 72.5}; double sum3 s3[0] s3[1] s3[2]; printf(同学C平均分: %.2f\n, sum3 / 3); return 0; }问题很明显逻辑重复了三遍如果哪天需求改成“去掉一个最高分再算平均”就要同时改三处漏掉一处就是bug。等你需要统计一百个同学的时候复制粘贴会是噩梦。函数的价值就在这里把“计算一组数的平均”这个操作单独抽出来变成一段可复用的逻辑。用函数改写后只需要一行一句调用#include stdio.h double average(double scores[], int n) { double sum 0.0; for (int i 0; i n; i) { sum scores[i]; } return sum / n; } int main(void) { double s1[3] {80.5, 90.0, 70.5}; double s2[3] {60.0, 75.5, 88.0}; double s3[3] {95.0, 66.5, 72.5}; printf(同学A平均分: %.2f\n, average(s1, 3)); printf(同学B平均分: %.2f\n, average(s2, 3)); printf(同学C平均分: %.2f\n, average(s3, 3)); return 0; }体会一下两者的差别前者是“把三份代码重复写三遍”后者是“把逻辑定义一次然后用不同数据调用三次”。以后需求变了只需要改average函数内部所有调用它的地方同步生效。这个“一次定义多次调用”的思路是整个函数章节最核心的立足点。1.2 函数定义的基本长相C语言的函数定义结构上固定是四部分返回类型 函数名(参数列表) { 函数体; return 返回值; }以刚才的average为例double average(double scores[], int n)返回类型是double表示这个函数会返回一个浮点数。函数名是average你自己起要能看出函数在做什么。参数列表是(double scores[], int n)表示调用时要传入什么东西。函数体是花括号里的循环和return。有几个细节值得专门强调。第一如果函数不需要返回值返回类型就写void例如一个只负责打印提示信息的函数void printMenu(void) { printf(1. 查询成绩\n); printf(2. 修改成绩\n); printf(3. 退出\n); }第二没有参数时建议参数列表写void而不是留空。在C语言里f()和f(void)在旧标准下语义不同f()没有写明参数个数可能被看作“参数个数未指定”f(void)明确表示不接受任何参数。写代码时养成写void的习惯可以避免一些奇怪问题。第三return和返回类型必须匹配。返回类型是double函数里就不能写return 1;之外的类型编译器会警告甚至报错。void函数里当然可以不写return也可以写一个裸的return;用来提前结束。1.3 声明与定义编译器需要“先见面”很多初学者在写函数时遇到过这样的报错调用函数的位置在定义函数的位置之前编译器提示“函数未声明”。看代码#include stdio.h int main(void) { int result add(3, 5); // 这里用到了add但编译器还没见过它 printf(%d\n, result); return 0; } int add(int a, int b) { return a b; }在C语言里代码是按顺序编译的。编译器在main里看到add(3,5)但在此之前没有见过任何关于add的信息它不知道add是什么、返回什么、参数是什么所以会报错或给出隐式声明的警告。解决办法有两种一是把add的定义挪到main之前二是用函数声明也叫函数原型提前告诉编译器#include stdio.h int add(int a, int b); // 函数声明我告诉编译器add长这样 int main(void) { int result add(3, 5); printf(%d\n, result); return 0; } int add(int a, int b) // 函数定义真正的实现 { return a b; }从工程习惯来说函数声明通常放在头文件里比如写一个mymath.h里面放声明再写一个mymath.c里面放实现调用方包含mymath.h并使用。这样做的好处是多个文件之间可以共享函数的“使用说明”而实现细节藏在.c文件里。编译的时候把多个.c文件一起编译即可gcc main.c mymath.c -o app多文件组织是函数章节往后必然会接触的内容建议从现在就养成“每个模块一个.h一个.c”的意识哪怕当前程序很小这种习惯会帮你省掉后面重构的很多麻烦。1.4 函数设计的基本心法函数不止是语法更是设计问题。我给刚开始写函数的人三条非常实在的建议。第一一个函数只做一件事。比如“计算平均分并打印成绩单”这种需求不要写成一个超大函数拆成average()负责算、printReport()负责打印main里组合调用。函数一大人脑就很难同时追踪里面的分支和循环出问题也很难定位。第二函数命名要一看就懂。getAverage、isLeapYear、printMenu这种名字别人看名字就知道用途千万不要用f1、fun2、doSomething这种命名。不少初学者为了省事用拼音缩写我当时见过有人写dqpf取平均分半年后自己都看不懂这并不好笑是真实教训。第三函数代码长度控制在50行上下。超过一屏的函数大概率应该拆。这不是硬性规定但很管用。我自己的经验是如果一个函数你要翻滚动条才能看到全貌那它已经不太适合继续塞代码了。2. 参数传递形参与实参的底层真相2.1 值传递形参只是实参的副本函数参数可能是整个第五章最容易被误解的地方。很多人一开始以为“把变量传给函数函数就能直接改这个变量”结果写出的代码完全不生效。看下面这个经典的失败案例#include stdio.h void swap(int a, int b) { int temp a; a b; b temp; } int main(void) { int x 3, y 5; swap(x, y); printf(x%d, y%d\n, x, y); // 输出x3, y5没换过来 return 0; }为什么没换过来因为C语言传参是值传递。当你调用swap(x, y)时实际发生的是把x的值3复制一份交给参数a把y的值5复制一份交给参数b。swap函数内部交换的是a和b这两个副本x和y本身从始至终没有被碰过。函数执行完a、b作为局部变量被回收交换的结果也随之消失。理解值传递的关键是在脑子里建立“变量里存的是值参数是独立的存储空间”这个模型。可以把a和b理解为新开的两个小盒子里面放的是从x和y复制出来的数字。你在小盒子里做任何操作都影响不到原来的大盒子。2.2 用指针绕过值传递的限制要想在函数里真正修改调用方的变量需要传变量的地址也就是指针#include stdio.h void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main(void) { int x 3, y 5; swap(x, y); printf(x%d, y%d\n, x, y); // 输出x5, y3 return 0; }这次调用的是swap(x, y)传进去的是x和y的地址。函数里a、b是存放地址的指针变量a是“a指向的那个变量”也就是x本身。这样在函数内修改a就相当于修改x。整个过程仍然是值传递——传的是地址值但因为地址指向了原来的变量函数就能通过这个地址间接操作原变量。初学者在这个环节常见两个错误。一是调用时忘记取地址写成swap(x, y)编译器会报指针类型不匹配二是函数内部忘记用*解引用写成int temp a试图把地址当成整数用。我的建议是每次看到指针参数先问自己“它指向谁”再问“我要操作它指向的东西还是操作指针本身”。把这个问题想清楚指针就顺了。2.3 数组传参的退化规则数组传参是另一个容易踩坑的点。很多人以为数组传进函数就是“整个数组传过去”于是在函数里用sizeof想求数组长度#include stdio.h void printArray(int arr[]) { int n sizeof(arr) / sizeof(arr[0]); // 妄想在函数里算长度 for (int i 0; i n; i) { printf(%d , arr[i]); } } int main(void) { int nums[] {1, 2, 3, 4, 5}; printArray(nums); return 0; }这个代码输出的内容会非常诡异因为sizeof(arr)在函数内部拿到的是“指针变量”的大小通常是8字节64位系统除以int的4字节得到2于是循环只打印前两个数。C语言的真相是数组做参数时并不会把整个数组复制一份而是退化成指向首元素的指针。int arr[]只是一个语法糖本质上等价于int *arr。既然退化成了指针函数就不知道数组有多长必须额外传一个长度参数#include stdio.h void printArray(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); } int main(void) { int nums[] {1, 2, 3, 4, 5}; printArray(nums, 5); return 0; }二维数组传参的规则更严格除了第一维可以省略后面每一维的大小都必须写清楚。比如int matrix[][4]是可以的因为编译器需要知道每一行有多少列才能计算出matrix[i][j]的实际地址偏移如果写成int matrix[][]编译器根本无法寻址。2.4 用const保护只读参数当函数不需要修改传入的数据时可以给指针参数加上const限定表明“我是只读的”#include stdio.h void printArray(const int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); }const在这里是写给两方看的对调用者这是承诺“你放心传我不会改你的数据”对写函数的人自己这是约束“你想在函数里修改arr[i]编译器会报错”。这个习惯非常值得养成。C语言标准库里大量函数都用了const修饰参数比如strlen、strcmp都在告诉你“我只读不写”。现在写代码就带上const后面看别人代码时会更顺利。3. 返回值、作用域与变量的寿命3.1 return的几种正确姿势return在函数里有三种典型用法。第一种是带值返回这个最简单return 表达式;表达式的值会作为函数调用结果。第二种是void函数里裸return用来提前结束函数void process(int n) { if (n 0) { return; // 负数直接返回不继续执行后面的逻辑 } printf(处理 %d\n, n); }第三种用法很多人没意识到return可以当“早退逻辑”用避免深层嵌套的if。比如检查输入是否合法如果不合法直接return让主逻辑保持在缩进最浅的位置int divide(int a, int b, int *result) { if (b 0) { return -1; // 错误码除数不能为0 } *result a / b; return 0; // 0表示成功 }这种风格在真实代码里非常常见称为“守卫子句”。我见过很多初学者把错误检查写成三层嵌套的if一旦复杂就晕。遇到需要多重条件判断时优先考虑先return掉错误情况让正常流程一路向下。3.2 绝对不要返回局部变量的地址这是第五章里最危险的一个坑。看这段错误代码#include stdio.h int *getNumber(void) { int value 42; return value; // 大错特错 } int main(void) { int *p getNumber(); printf(%d\n, *p); // 未定义行为 return 0; }问题在于value是getNumber的局部变量函数一结束value所占的栈空间就被释放了。返回的指针指向的是一块“已经不归你管”的内存。运气好时那块内存暂时还没被覆盖打印出来还是42但一旦后续有别的函数调用覆盖了栈空间这个指针指向的内容就变成垃圾数据。这是未定义行为你无法预测结果。如果确实想要返回一块内存地址可行方案有三个。第一用static局部变量把变量的生命周期拉长到整个程序运行期间int *getNumber(void) { static int value 42; return value; }第二让调用者传入缓冲区的地址函数负责往里写内容。第三用malloc在堆上申请内存不过调用方必须记得free否则会内存泄漏。从工程角度第二种方案最干净第一种能用但要注意static变量是全局共享的多次调用会互相覆盖。3.3 static局部变量作用域不变寿命变长static修饰局部变量改变了变量的存储期但不改变作用域。普通局部变量的生命周期是从进入函数到离开函数static局部变量的生命周期是整个程序运行期间但它的可见范围仍然是声明它的函数内部函数外面访问不到。一个典型的应用是计数器#include stdio.h int nextId(void) { static int counter 0; counter; return counter; } int main(void) { printf(%d\n, nextId()); // 1 printf(%d\n, nextId()); // 2 printf(%d\n, nextId()); // 3 return 0; }这里的counter只初始化一次之后每次调用nextIdcounter都保持着上一次的值。注意两点第一static局部变量的初始化发生在程序启动阶段而不是每次调用时第二多线程程序里共享同一个static变量会有并发问题现在先知道有这回事学多线程时再深入。3.4 全局变量与extern能少用就少用全局变量指的是定义在函数外部的变量它的生命周期是整个程序作用域是声明位置之后的整个文件。初学者很喜欢用全局变量因为所有函数都能直接读写它省去了传参的麻烦。但工程上全局变量是必须谨慎使用的。用一个全局变量保存程序状态最大的问题是你无法判断哪个函数在什么时候修改了它。代码一多排查起来像大海捞针。更麻烦的是全局变量让函数之间的耦合变得隐晦——一个函数改了全局状态另一个函数的行为跟着改变这种依赖关系根本没法从函数签名上看出来。如果确实需要多文件共享某个全局变量规范做法是在某个.c文件里定义int g_counter 0;然后在对应的.h文件里写extern int g_counter;。其他文件只要包含这个头文件就能访问同一个g_counter。extern的意思是“这个变量在别处已经定义了我声明一下让编译器知道它的存在”。这里要区分清楚定义是真正分配内存的语句声明只是告知编译器类型和名字。更推荐的做法是用访问函数封装全局状态就像前面的nextId那样把“读”和“写”集中管理这样后续想加锁、加日志都只需要改封装函数内部不会影响其他调用方。4. 递归把大问题拆成小问题4.1 递归的两个基本要素递归这个概念说简单也简单就是“函数调用自己”。但很多人看别人的递归代码能看懂轮到自己写就无从下手。关键在于抓住两个要素边界条件递归出口和递推关系。以阶乘为例n!的定义是0! 1n! n * (n-1)!。第二个式子本身就是递推关系把n的阶乘转化为n-1的阶乘。代码写出来long long factorial(int n) { if (n 0) { return 1; // 边界条件最底层的已知答案 } return n * factorial(n - 1); // 递推关系把问题缩小一号 }计算factorial(4)时实际展开过程是factorial(4) 4 * factorial(3) 4 * 3 * factorial(2) 4 * 3 * 2 * factorial(1) 4 * 3 * 2 * 1 * factorial(0) 4 * 3 * 2 * 1 * 1 24可以把这个过程理解为你只管“把当前这一步算出来 相信递归函数能搞定更小的子问题”。没有边界条件的递归会无限调用自己直到把栈空间耗尽程序崩溃。4.2 递归经典案例与效率陷阱斐波那契数列是递归教科书案例但也是最典型的反面教材。定义是f(1)1, f(2)1, f(n)f(n-1)f(n-2)照写递归long long fib(int n) { if (n 2) { return 1; } return fib(n - 1) fib(n - 2); }代码看起来优雅但计算fib(40)会慢到你怀疑人生。原因是这里存在大量重复计算fib(40)需要算两次fib(38)、三次fib(37)……整个计算树近似指数级膨胀。我做过一个简单测试用这种递归算fib(45)在我的机器上要跑几秒fib(50)直接卡死。怎么让初学者直观感受重复计算可以先加一个计数器int callCount 0; long long fib(int n) { callCount; if (n 2) { return 1; } return fib(n - 1) fib(n - 2); }算fib(30)callCount会超过16万。递归深度只有30层但调用次数爆炸这就是“时间都花在重复劳动上”。遇到类似问题通常应该改用循环迭代或者用记忆化把算过的结果存起来long long fibMemo(int n, long long memo[]) { if (n 2) { return 1; } if (memo[n] ! 0) { return memo[n]; } memo[n] fibMemo(n - 1, memo) fibMemo(n - 2, memo); return memo[n]; }这种优化思路在算法学习里非常重要现在先记住递归写起来漂亮但不等于高效。4.3 栈空间与递归深度递归依赖调用栈而栈空间是有限的。默认情况下Linux系统栈大小通常是8MBWindows下主线程栈空间默认1MB左右。每调用一次函数至少要消耗几十字节到几百字节的栈帧递归太深就会栈溢出表现是程序崩溃或Segmentation fault。怎么估算递归能走多深用ulimit -s查看栈大小Linux下一般是8192KB。假设每次递归调用栈帧占用几百字节那几万次的深度已经到了危险边缘。所以写递归时心里要有数问题规模太大别硬用递归。有一种优化叫尾递归——递归调用是自己函数里的最后一个操作。某些编译器能把尾递归优化成循环从而避免栈深度增长int factorialTail(int n, int acc) { if (n 0) { return acc; } return factorialTail(n - 1, acc * n); }注意C标准并不强制编译器做尾递归优化GCC在开启-O2优化时通常能做但这个话题了解即可。工程上的经验是优先考虑迭代方案除非递归带来的代码清晰度优势非常明显。4.4 什么场景适合用递归递归真正的用武之地是那些“数据结构本身就是递归定义”的场景。典型的例子包括二叉树的遍历、目录树的扫描、汉诺塔等。以汉诺塔为例需求是把n个盘子从A柱移到C柱借助B柱并且大盘子不能压在小盘子上。思路是这样的把前n-1个盘子从A移到B把第n个盘子从A移到C再把n-1个盘子从B移到C。翻译成代码void hanoi(int n, char from, char aux, char to) { if (n 1) { printf(把盘子从%c移到%c\n, from, to); return; } hanoi(n - 1, from, to, aux); printf(把盘子从%c移到%c\n, from, to); hanoi(n - 1, aux, from, to); }这个函数最妙的地方在于你不需要在脑子里模拟每一条移动路径。你只需要相信hanoi(n-1, from, to, aux)能完成“把n-1个盘子从from移到aux”这件事然后自己在中间移动第n个最大的盘子即可。这种“大胆假设子问题能解然后专注当前一步”的思维方式正是递归最核心的训练价值。5. 函数指针、main函数与可变参数5.1 函数指针的语法与识别函数在C语言里也有地址函数名就是函数的入口地址就像数组名是数组首元素地址一样。把函数的地址存进指针变量就叫函数指针。它的声明语法比较容易混淆对比看int *fp(int, int); // 这是函数返回类型是 int*名字是fp int (*fp2)(int, int); // 这是函数指针fp2指向一个返回int的函数关键差别是星号有没有被括号包起来。有了函数指针就可以让函数变成“可以被选择、被传递”的东西。一个典型例子是用函数指针数组做计算器#include stdio.h 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 b ? a / b : 0; } int main(void) { int (*ops[4])(int, int) {add, sub, mul, div}; int a 20, b 5; for (int i 0; i 4; i) { printf(op[%d] %d\n, i, ops[i](a, b)); } return 0; }调用可以写成ops[i](a, b)也可以写(*ops[i])(a, b)两种写法等价。这个例子展示了函数指针的数据化能力函数可以被放进数组可以通过索引选择调用。第一次看到这种写法可能会觉得绕但它在GUI事件处理、插件系统里非常常见。5.2 回调函数qsort的用法函数指针最实用的落地场景是回调函数。回调就是你提供一个函数给某个库函数让它在适当时机替你调用。C标准库里的qsort就是一个绝佳示例#include stdio.h #include stdlib.h int compareInt(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); } int main(void) { int nums[] {4, 1, 7, 3, 9, 2}; qsort(nums, 6, sizeof(int), compareInt); for (int i 0; i 6; i) { printf(%d , nums[i]); } printf(\n); return 0; }qsort需要你告诉它两个信息数组元素多大、按什么规则比较大小。这里的compareInt就是我们提供的回调qsort在排序过程中会反复调用它来决定两个元素谁前谁后。这种设计把“排序流程”和“排序规则”解耦了只要你会写比较函数同一套qsort能排整数、排浮点数、排结构体。写比较函数时有个容易踩的坑参数类型是const void *必须把它转换成实际的类型再解引用。很多人第一次写会忘记转类型直接解引用void指针编译器直接报错。记住这是qsort的固定套路多写几遍就熟了。5.3 main函数的参数argc与argv很多教材讲main只讲int main(void)导致不少人一直不知道main还能带参数。实际上C标准允许main写成int main(int argc, char *argv[])argc表示命令行参数的个数argv是一个字符串数组里面是每一个参数。写一个回显程序#include stdio.h int main(int argc, char *argv[]) { for (int i 0; i argc; i) { printf(参数%d: %s\n, i, argv[i]); } return 0; }编译后运行gcc echo.c -o echo ./echo hello world 42输出为参数0: ./echo 参数1: hello 参数2: world 参数3: 42注意一个细节argv[0]永远是程序自身的名字或路径。你在写命令行工具、需要读取用户传入的文件名或选项时就会用到这两个参数。而且argv里的每个字符串都是以\0结尾的C字符串可以直接用printf %s打印。5.4 可变参数printf是怎么做到想传几个就传几个的printf的神奇之处在于它可以接收任意多个参数printf(a%d, a)、printf(a%d, b%d, c%d, a, b, c)都合法。C语言通过stdarg.h提供了可变参数机制原理不算复杂。看一个求可变个整数之和的函数#include stdio.h #include stdarg.h int sum(int count, ...) // 先固定一个参数再用...表示其余可变参数 { va_list args; va_start(args, count); int total 0; for (int i 0; i count; i) { total va_arg(args, int); } va_end(args); return total; } int main(void) { printf(%d\n, sum(3, 10, 20, 30)); // 60 printf(%d\n, sum(5, 1, 2, 3, 4, 5)); // 15 return 0; }流程固定四步va_list声明变量va_start初始化va_arg逐个取值va_end收尾。使用可变参数的关键痛点是调用者和函数之间必须约定好“到底传了几个、是什么类型”。printf依靠的是格式字符串里的%d、%f来决定后面参数的数量和类型。如果格式说明和实际参数不匹配printf不会自己检查出来运行结果就是垃圾数据或者崩溃。这个坑在真实代码里经常出现所以写可变参数函数时务必保持接口设计简单清晰别让调用方太容易用错。到这里函数这一章的骨架就算完整过了一遍。我自己的体会是函数学得好不好取决于有没有真的动笔写而不只是看懂了语法。建议你把这章里每一个示例都自己在编辑器里敲一遍再故意改错几次——比如把值传递的swap改成指针版本、把返回局部变量地址的代码跑一遍看看警告、试试不用const修饰参数观察编译器给的提示。这些亲手踩坑得到的经验比单纯背概念牢固得多。还有一个值得养成的小习惯每写一个函数先在一行注释里写下“这个函数做什么、输入是什么、返回什么”再动手实现。坚持几个月你会明显感觉代码质量开始有质的提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑