
很多人学完C语言第六章“函数”之后都会有一种奇怪的感觉书上例题都能看懂老师讲的东西也听明白了可一旦让自己动手写一个稍微完整点的程序写出来的代码还是挤在一坨恨不得把所有的逻辑都塞进main函数里。这不是个别现象我带了这么多届学生几乎每一届都会有人在函数这一章反复卡壳。说白了函数这章理解起来不难难的是把它变成你写代码的本能。这篇内容不是教材的复述而是我把函数这一章掰开揉碎之后的实践笔记。语法层面我不会占太多篇幅重点放在那些教材里一笔带过、但在实际写代码和查bug时天天都会遇到的事情上比如声明和定义的关系、参数到底怎么传、递归背后的栈机制、函数指针怎么用、多文件工程里static和extern怎么配。刚学完函数的在校生可以看准备考计算机二级的可以看工作几年想回来补补基础的也可以看。1. 从一个“复制粘贴”的教训说起函数到底解决了什么问题先讲个实际例子。几年前我帮一个朋友改一个控制台版的学生成绩管理系统他当时还在读大二代码写得特别认真整个项目有一个main函数和几个辅助函数。问题出在打印成绩单这个功能上程序需要在三个不同的地方打印成绩单按学号查询后打印、按姓名查询后打印、统计完平均分后打印。因为当时不会写函数他直接把打印成绩单的代码复制粘贴了三份加起来大概二百多行。后来需求变了成绩单要加一列“排名”。于是他在文件里搜索“printf”找到三处几乎一模一样的代码依次修改。改完第一处忘了第二处第二处改完又忘了第三处折腾了半个多小时。这其实就是没有函数意识最典型的后果你以为你在写程序实际上你在当人肉CtrlC。1.1 函数不是语法是一种“抽象”C语言里的函数说穿了就是给一段代码块起个名字定义好输入和输出然后你就可以在任何地方反复调用它。但这句话太轻了函数的真正价值是抽象。什么叫抽象就是调用者只需要关心“这个函数能帮我做什么”而不用关心“它是怎么做到的”。比如你调用printf的时候你不需要知道它内部怎么解析格式串、怎么和操作系统交互、怎么把字符输出到终端。你知道输入一个格式串和几个参数它就能按格式输出这就够了。自己写函数也是同样的道理。你写一个calc_average(int scores[], int n)调用者只需要传一个数组和长度就能得到平均分完全不用关心函数体里是遍历求和还是边累加边除。这种“让别人用起来不需要看内部实现”的能力才是函数存在的根本意义。1.2 函数拆分的判断标准一个函数只做一件事每次写到函数拆分学生最爱问的问题是“老师我到底应该把哪些代码放进函数里”这个问题没有绝对的标准答案但有一条经验法则非常好用如果一个函数能用“A和B”来描述它的功能那它就该被拆成两个函数。比如“初始化并检查配置”应该拆成“初始化配置”和“检查配置”“读取文件并统计单词数”应该拆成“读取文件内容”和“统计单词数”。你拆出来的函数名字最好能直接用“动词名词”说清楚它做了什么像print_report、find_student、save_to_file这种。另外一个实用的辅助判断标准是行数。如果一个函数体超过了五十行你就应该警惕了大概率是可以拆分的。五十行不是硬性规定而是一个提醒信号代码超过这个量级别人读起来就会开始失去上下文你再往下堆的话可读性会急剧下降。1.3 main函数也是函数别把它当特权阶层初学者还有个常见误解觉得main函数和普通函数不一样是神圣不可侵犯的。实际上main函数在C语言里就是程序的入口点但它本质上也是一个函数可以被其他函数“间接”调用虽然正常程序不会去调用main。把main当成普通函数来理解你就明白为什么main里定义的局部变量在别的函数里也访问不到——因为它和其他函数一样都有自己的栈帧和局部作用域。把复杂的逻辑拆到函数里、让main函数保持简洁这是C语言模块化编程的起点。你去看任何成熟的项目main函数通常都很短几十行到一二百行里面基本就是初始化、调用业务函数、清理退出。main函数一旦超过几百行基本上就可以认定是设计出了问题。2. 函数声明与函数定义几乎所有初学者都绕不开的坎函数声明和函数定义是第六章里最容易被忽视的知识点但恰恰是实际编译时报错最多的地方。很多学生写代码时习惯把自定义函数写在main后面然后编译就报出“implicit declaration of function”这类警告或者错误一脸懵。要搞清楚这个问题得从C语言的编译方式说起。2.1 为什么调用之前要先声明C语言是自上而下、逐行编译的。编译器在编译一个文件时遇到一个函数调用它需要知道这个函数的返回类型、参数列表和参数个数才能生成正确的调用指令、检查参数是否匹配。如果你在调用点之前没有让编译器看到函数的任何信息编译器就会陷入“不知道该把这次调用翻译成什么样的指令”的困境。早期的C89标准比较宽容编译器会默认这个函数返回int、参数个数不做检查同时在链接阶段帮你找到符号。这种做法听起来方便实际上隐患很大如果函数的真实返回类型是double你在调用点没有声明编译器默认按int返回处理拿到的返回值就会是错的。到了C99标准隐式声明直接被禁止了编译器会明确报告错误。所以要么把函数定义写在调用点之前要么在调用点之前写函数声明。函数定义包含函数体函数声明只描述函数的“签名”。#include stdio.h // 函数声明告诉编译器 add 函数返回 int接收两个 int 参数 int add(int a, int b); int main(void) { int result add(3, 5); printf(result %d\n, result); return 0; } // 函数定义真实实现 int add(int a, int b) { return a b; }这段代码里第4行就是函数声明也被称为函数原型。有了这行声明main函数里调用add的时候编译器就知道该按什么规则去调用和检查参数了。2.2 声明放在头文件里定义放在源文件里到了工程阶段你不会只写一个.c文件。成熟的工程都是多个.c文件配合多个.h头文件协作这时候声明和定义的分工就非常明确了头文件(.h)里放函数声明、宏定义、类型定义、全局变量声明源文件(.c)里放函数定义和全局变量定义为什么要这样设计举个例子。你写了一个util.c里面实现了一个int string_to_int(const char *str)函数。其他文件想调用它按理说只需要在自己的文件顶部写一行声明就可以但这样做的坏处是如果有一天你改了函数的参数比如增加一个进制参数所有直接写声明的地方都要跟着改漏一处就会出现诡异的“形参不匹配”链接错误。把声明统一放在util.h里其他文件只要#include util.h改动就集中在一处。这个模式也体现了接口和实现分离的思想头文件对外是接口契约源文件是内部实现调用者通过头文件使用接口但不关心实现细节。2.3 关于extern函数声明里你很可能会见到它C语言里用extern修饰函数声明表示“这个函数是在其他编译单元里定义的”。不过有个容易忽略的细节函数声明本身默认就带有extern语义。你在头文件里写int add(int a, int b);和写extern int add(int a, int b);对于外部链接的函数来说并没有本质区别。extern真正需要显式使用的地方是全局变量的声明。比如一个.c文件里定义了int global_count 0;另一个.c文件想访问它就需要写extern int global_count;。很多初学者看到头文件里的extern修饰函数以为必须这么写其实不写也没事。搞清楚这一点你在阅读项目代码时就不会再被extern吓到了。2.4 注意函数原型里参数名的“障眼法”函数声明里可以写参数名也可以不写。int add(int a, int b);和int add(int, int);是完全等价的因为声明阶段编译器只关心参数类型。但这个细节会埋坑假设你的函数定义里参数名叫a和b头文件声明里你写的是int add(int x, int y);这完全合法编译器不会报错。问题是读代码的人容易犯嘀咕参数名不一样是不是接口变了所以实际项目里建议让声明和定义的参数名保持一致减少不必要的认知负担。3. 值传递、地址传递和“你以为的传引用”函数参数传递这一节是第六章里概念密度最高的部分也是面试和技术问答里出现频率极高的考点。先说结论**C语言只有值传递没有传引用。**很多人听到这句话会反驳“C语言不是可以用指针实现传地址吗”别急这句话恰恰是理解这一节的关键。3.1 形参是实参的副本改形参动不了实参看这段代码#include stdio.h void swap(int a, int b) { int tmp a; a b; b tmp; } int main(void) { int x 3, y 5; swap(x, y); printf(x %d, y %d\n, x, y); // 依然是 x 3, y 5 return 0; }运行结果会让你大失所望x和y没有被交换。原因就在于调用swap(x, y)时C语言把x和y的值复制了一份分别给到形参a和b。a和b在函数内部被交换了但那是a和b自己的事情和main函数里的x、y没有任何关系。这就像你把一份文件的复印件拿去修改原件丝毫无损。要真正修改调用方的变量你要传递变量的地址而不是值#include stdio.h void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main(void) { int x 3, y 5; swap(x, y); printf(x %d, y %d\n, x, y); // x 5, y 3 return 0; }这里的要点是形参a和b从int变成了int *类型也就是指针。调用时传x和y传的其实是x和y的地址值。形参仍然是被赋值出来的副本只不过这个副本是地址。你通过这个地址去解引用访问x的内存操作的就是x本身。所以“传地址”本质上还是在传值只是值的类型变为了指针类型。3.2 数组传参为什么还要额外传一个长度函数处理数组是C语言里极其常见的场景但这里藏着一个新手必踩的坑。把数组传给函数你真的以为把整个数组都复制过去了不是的数组名在表达式里会退化为指向首元素的指针所以函数参数列表里写int arr[]实际上等价于int *arr。void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); }你在函数内部无法通过sizeof(arr) / sizeof(arr[0])来算出数组长度因为arr在这里已经是一个指针了sizeof(arr)得到的是指针的大小可能是8字节或4字节而不是数组的总字节数。所以实际应用中处理数组的函数几乎都要额外接收一个长度参数n。除了字符串可以利用结尾的\0判断长度其他类型的数组没有天然的“结束标记”长度必须显式传递。3.3 const修饰指针参数是接口的“礼貌”看这个函数原型void print_string(const char *str);这里的const char *str是什么意思它的含义是这个函数承诺不会通过str去修改它所指向的字符内容。这既是一种自我保护也是对调用者的一种明确约定。const和指针的组合有几种容易混淆的写法我列个表帮你理清写法含义const char *pp指向的字符内容不可修改p本身可以修改char * const pp本身不可修改但p指向的字符内容可以修改const char * const p内容和指针都不可修改在实际工程中最常用的是const char *p这种写法。比如字符串处理函数、日志输出函数内部只做读取操作就应该用const修饰。这能让代码的意图更加明确也帮助编译器在你不小心试图修改内容时及时报错。3.4 一个必须警惕的坑传入了未初始化的指针这里特别提醒一个几乎所有C程序初学者都犯过的错误声明了一个指针变量没让它指向任何有效的内存就直接传给函数去写值。void fill(int *p, int val) { *p val; } int main(void) { int *p; // 野指针没有指向有效内存 fill(p, 42); // 对野指针解引用属于未定义行为 return 0; }正确做法是让p先指向一个已有的变量或者用malloc分配内存使用完再free。内存管理相关的细节在C语言里很重要但这里至少记住一条**指针必须先有合法的指向才能作为输出参数传给函数。**否则运行崩溃、数据被改坏都是有可能的。4. 递归调用自己之前先想清楚栈里的故事递归是第六章的另一个重点也是很多学生第一次感受到“程序运行时的动态过程”的地方。递归的概念很简单函数直接或间接调用自己。但只记住概念意义不大重要的是想清楚两个问题递归为什么能终止递归是怎么在底层实现的4.1 递归的两个核心要素一个正确的递归必须有两个部分终止条件基准情形问题规模足够小的时候直接返回结果不再调用自己。递归表达式把当前问题转化成规模更小的同类问题逐步向终止条件靠拢。以阶乘为例long long factorial(int n) { if (n 1) return 1; // 终止条件 return n * factorial(n - 1); // 递归表达式n 在变小 }调用factorial(5)时计算机会依次展开成5 * factorial(4)、4 * factorial(3)……一直展开到factorial(1)返回1然后再逐层返回相乘的结果最终得到120。这个过程可以理解为“先深入再回溯”。没有终止条件或者递归表达式不能到达终止条件的递归就是无限递归最终会导致栈溢出stack overflow程序崩溃。这个错误我已经数不清见过多少次了。4.2 每个函数调用都有一个栈帧要理解递归为什么会有栈溢出的风险得先理解调用栈。每次函数调用系统会在内存中分配一块区域叫栈帧用来保存函数的局部变量、参数、返回地址等信息。递归调用自己表面上是在调用同一个函数实际上每层调用都拥有独立的栈帧互不干扰。也就是说factorial(5)会用掉5个栈帧factorial(100000)理论上会用掉10万个栈帧。而栈空间是有限的通常只有几MB。如果你写的递归深度太大栈帧把空间耗尽就会栈溢出。这也是递归最容易被声讨的地方同样一个问题迭代循环写法的空间复杂度是O(1)递归写法却可能是O(n)。所以我的建议是递归适合处理那些天然具有“自我相似”结构的问题比如树、链表、目录结构、括号匹配等而不是纯粹为了炫技去替代循环。4.3 经典的递归练习题学递归光看不练根本学不会我平时给学生推荐的几个经典题建议你一个一个敲一遍字符串逆序用递归反向输出字符串。注意标准的“逆序输出”版本只是打印字符不是真正把字符串内容反转。真正原地逆序数组会涉及边界处理要小心。汉诺塔把n个盘子从from柱移到to柱借助aux柱要求大盘永远不压小盘。二叉树遍历前序、中序、后序的递归写法几乎是面试必考题。二分查找用递归实现有序数组的查找写完对照迭代版本感受一次。给你看一个经典的汉诺塔递归代码它只有短短几行但把递归的“分治”思想体现得淋漓尽致#include stdio.h void hanoi(int n, char from, char to, char aux) { if (n 1) { printf(%c - %c\n, from, to); return; } hanoi(n - 1, from, aux, to); printf(%c - %c\n, from, to); hanoi(n - 1, aux, to, from); } int main(void) { hanoi(3, A, C, B); return 0; }你不需要试图在脑子里模拟每一层的细节只需要相信这个函数本身是成立的先把上面n-1个盘子移到辅助柱再把底盘移到目标柱最后把辅助柱上的n-1个盘子移到目标柱。这种“信任递归”的思维方式是写递归的关键。4.4 斐波那契的教训递归要注意性能用递归写斐波那契是很多入门教材的常客long long fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }代码确实很简洁可你只要运行fib(40)就会体验到什么叫卡顿。原因是这个递归有大量的重复计算fib(n)会计算fib(n-1)和fib(n-2)而fib(n-1)又会计算fib(n-2)和fib(n-3)fib(n-2)在下层被重复计算了一次又一次。整体时间复杂度是指数级的O(2^n)n稍微大一点就扛不住了。解决思路有两个一是用循环的迭代写法把状态保存下来时间复杂度和空间复杂度都更低二是用记忆化递归加上一个数组缓存中间结果。这个例子在C语言函数章节里极具教育意义递归不是不好而是要用在合适的地方并且要考虑效率。5. 函数指针与回调把函数本身当成数据来用前面讲到的函数都是被直接调用的对象。到了函数指针这一节你会发现C语言有一个特别灵活的能力函数可以像变量一样被“存储”和“传递”。这个特性在你写通用代码时非常有用也是理解很多库函数内部机制的关键。5.1 函数指针的声明语法函数在编译后也有地址这个地址就代表函数在内存中的位置。你可以用一个指针变量保存这个地址然后通过指针去调用函数。声明一个函数指针的语法稍微有些绕得刻意练习int (*func_ptr)(int, int);这行代码的意思是func_ptr是一个指针指向一个函数这个函数的签名是“返回int类型接收两个int类型参数”。注意括号的位置非常关键少了括号就变味了。回忆一下int *func(int, int);表示一个返回int*类型指针的函数而int (*func_ptr)(int, int);表示一个指向函数的指针。括号改变了一切判断一个声明到底是函数还是函数指针找不到技巧时就看括号。你还可以用typedef把函数指针类型定义成一个更有意义的名字这在工程里更常见typedef int (*binary_op)(int, int);之后你就可以用binary_op op add;这样的写法代码的可读性会好很多。5.2 把函数指针当作参数传给另一个函数函数指针最常见的用法之一是作为另一个函数的参数。C标准库里大名鼎鼎的排序函数qsort就是典型例子它的最后一个参数是一个函数指针比较函数不同的比较函数可以决定排序是升序还是降序、排的是整型还是字符串等。#include stdio.h #include stdlib.h int compare_int(const void *a, const void *b) { return (*(int *)a - *(int *)b); // 升序反过来写就是降序 } int main(void) { int arr[] {5, 3, 8, 1, 9, 2}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), compare_int); for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); return 0; }注意qsort的比较函数用的是const void *指针因此如果你要排整型数组必须在比较函数内部把void*强制转换回int*再解引用。这算是C语言早期留给我们的“泛型”手段因为void*可以指向任意类型qsort才有可能只是比较大小而不用关心数组元素的类型。5.3 回调机制你调用我我回头调用你当函数指针作为参数传递时接收它的那个函数可以在适当的时候去调用你传入的函数这个机制就叫“回调”。是不是听起来有点绕换个说法你就明白了你给一个框架注册了一个功能框架在某个事件发生时反过来调用你注册的这个功能这就是回调。回调在C语言嵌入式开发里四处可见比如定时器到期、按键按下、串口收到数据都会触发一个回调函数。在图形界面或者游戏引擎里回调也到处都在。有了回调你的代码就不必是严格的“总-分”线性结构可以做成事件驱动模式模块与模块之间解耦。一个简单但很实用的例子是用函数指针数组实现菜单命令分发#include stdio.h void cmd_help(void) { printf(available commands: help, version\n); } void cmd_version(void) { printf(v1.0.0\n); } typedef void (*cmd_func_t)(void); struct Command { char *name; cmd_func_t func; }; struct Command commands[] { {help, cmd_help}, {version, cmd_version}, }; int run_command(const char *cmd) { int n sizeof(commands) / sizeof(commands[0]); for (int i 0; i n; i) { if (strcmp(cmd, commands[i].name) 0) { commands[i].func(); // 通过函数指针调用 return 0; } } printf(unknown command\n); return -1; }这种表驱动写法比用一长串if-else判断命令要清晰得多以后加一个新命令只需要在表里增加一行这个思路在很多实际项目里都会用到。5.4 函数指针和普通指针的区别有同学问函数指针能不能当成普通指针随便强转、存储任意大小时要小心。标准C并不保证函数指针和数据指针的大小一致也不建议你把函数指针强转成void*再转回来。虽然在实际的嵌入式平台上经常有人这么做比如把函数地址放进协议栈、引导程序里但那是特定平台的约定不是C标准保证的跨平台行为。建议你在学习阶段遵循标准在工程阶段再根据具体平台和编译器的约束来做取舍。6. 多文件工程里函数、static与头文件是怎么协作的函数这章最后一般会顺带讲变量的作用域和生命周期。可一旦从单文件跳到多文件工程很多学生就会彻底晕掉extern、static、头文件、include这些东西我和函数有什么关系关系很大因为在真实项目里函数不会只活在一个文件里。6.1 头文件反复包含导致的“重复定义”#include的工作机制说白了就是把被包含文件的内容原封不动地复制到当前文件中。如果一个头文件被两个源文件包含同时头文件里写了函数定义而不是声明那么这两个源文件在编译之后的链接阶段就可能出现“重复定义”错误因为同一个函数出现了两份实体。解决办法是头文件只放声明源文件放定义。声明可以被重复包含很多次不会生成多个函数实体定义不行全局变量定义和函数定义都只能有一个。如果你自己写了一个头文件还应该加上防止重复包含的保护常见的写法有两种#ifndef MY_UTIL_H #define MY_UTIL_H // 声明部分 #endif或者用编译器扩展的#pragma once它更简洁但不是所有历史编译器都支持。对于现代开发环境来说两种写法都足够可靠我更推荐传统的#ifndef写法因为它的可移植性更好。6.2 static的两副面孔关键字static在C语言里有两种完全不同的用途放在函数这一章里尤其要区分清楚。第一static修饰函数或全局变量时表示“内部链接”。意思就是这个函数或变量只在当前这个.c文件里可见其他文件就算用extern声明也访问不到。这个特性非常适合做模块内部的“私有工具函数”比如一个util.c里写了一个internal_parse_line函数它是给本文件其他函数使用的不希望被外部暴露那就用static修饰。这样即使别的文件里有个同名函数也不会发生链接冲突。第二static修饰函数内的局部变量时表示“延长生命周期”。局部变量存放在静态存储区而不是栈上。它的作用域仍然只在函数内但生命周期贯穿程序整个运行过程。这个特性适合用作计数器或者缓存。比如int next_id(void) { static int id 0; return id; }每次调用next_id()返回的id都会自增因为静态局部变量在程序启动时就已经初始化函数调用结束后它不会销毁。6.3 全局变量尽量少用要用也要有体面写函数时最忌讳的事情之一是函数内部直接读写全局变量。这样看起来省事但坏处非常明显函数的行为不再由它的参数决定而取决于外界某个看不见的变量状态。调用顺序一变结果就变查起bug来欲哭无泪。如果实在需要使用全局变量我建议至少做到两条在头文件里用extern声明在某个.c文件里定义一次不要把定义放在头文件里。尽量用static限制它的可见范围缩小影响面。从一个模块设计的角度来说把全局变量定义到相关模块的.c文件里配合static和对外函数接口就能做到“数据对外不可见行为对外可见”。这套思路在嵌入式开发和日常工程中非常常见也是在函数这一章之后你应该逐步养成的习惯。6.4 内联函数和递归的小知识C99之后引入了inline关键字用来建议编译器把函数调用处的代码直接展开省去函数调用的开销。内联比较适合那些函数体特别短、又被频繁调用的场景。但有两个容易忽略的限制内联函数的定义通常要放在头文件里因为多个源文件都要在调用处“看见”函数体才能展开。递归函数一般是不能真正内联到底的因为编译器无法确定递归深度。所以如果你在一个源文件里写了一个static inline函数通常没问题如果你试图把一个递归函数声明为inline大概率也是白搭。了解这些边界你就能在工程里合理使用内联优化而不至于踩坑。函数这一章的知识点真的不难我见过太多学生卡在这里其实大多不是天赋问题也不是语法不懂而是练习得不够。第六章学完之后建议你回头看看自己写过的所有单文件程序把它们按功能拆成函数再把这些函数按模块分到不同的.c和.h文件里。等你能把一个乱七八糟的两百行程序拆成结构清晰的多文件工程这一章就算是真正过关了。