资讯动态

C语言函数进阶:参数传递、数组退化、函数指针与多文件实战

发布时间:2026/10/3 4:37:04 来源:尧图企业网站定制
这篇写的是C语言基础里函数进阶的收尾部分。上一篇我已经把函数声明、定义、调用、形参实参、返回值这些基础内容梳理过了默认你已经能写出简单的自定义函数也知道return怎么用。但很多同学学到这个位置会卡住原因不外乎三个数组传给函数之后为什么在函数里sizeof结果不对函数名前面加static到底意味着什么多文件项目里声明和定义到底该放哪个文件还有那句看着就头疼的int (*fp)(int, int)到底是个什么东西。这篇就是把函数进阶真正“进阶”的部分讲透我尽量用我实际改作业和带课程设计时遇到的真实例子来说话适合正在学C语言基础、准备刷翁恺老师练习题或PTA题目的同学也适合刚开始接触多文件项目、对函数指针一头雾水的自学者。1. 先搞清楚参数传递传值、传址和数组退化这三件事1.1 swap写错一学期传值调用的本质很多初学者写第一个swap函数时都翻过车void swap(int x, int y) { int t x; x y; y t; } int main() { int a 3, b 5; swap(a, b); printf(a%d, b%d\n, a, b); // 结果还是 a3, b5 return 0; }函数内部明明交换了x和y为什么a和b纹丝不动因为C语言的参数传递默认是传值调用调用swap(a, b)时系统把a和b的值复制一份分别交给形参x和y。函数内交换的是形参x和y这两个形参在栈上有自己独立的内存空间操作它们跟main函数里的a、b没有任何关系。打个比方你把课代表的作业本借给同学抄同学在自己本子上改了答案你手里原来的作业本一个字没变。想真正改动实参必须把“作业本在哪”这个信息传进去让函数直接找到原件去改。这就是传址调用把实参的地址传给函数形参void swap(int *px, int *py) { int t *px; *px *py; *py t; } int main() { int a 3, b 5; swap(a, b); printf(a%d, b%d\n, a, b); // a5, b3 return 0; }这次形参是int *px接收的是a的地址*px就是通过地址去访问a这个“原件”所以交换成功。我改作业时见过最典型的问题是有人把这两个版本混着写函数定义是void swap(int *px, int *py)调用却是swap(a, b)编译时有warning也不看运行时直接段错误。原因是a的值被当成地址去访存了这在64位系统上基本必崩。还有一个很多人忽略的细节传址调用本质上也是传值只是传的值是“地址”。如果函数内部对指针形参重新赋值比如px py这只会改变形参自身的指向main函数里的a、b和指针都没变。想通过函数修改实参指针本身得用到指针的指针int **这个属于更高阶的用法后面用到再说。1.2 传址调用指针参数到底改变了什么这个问题的完整表述应该叫“通过指针间接修改目标变量的值”。函数拿到地址后既能通过解引用读也能通过解引用写。比如一个常见的read函数int read_int(int *out) { if (out NULL) { return -1; } *out 42; return 0; }这种写法在工程里很常见函数用返回值表示成功或失败真正要算出来的结果通过指针参数“带出来”。C语言没有引用类型也没有多返回值所以想“一个函数产出多个结果”就必须靠指针参数。比如要同时求商和余数int divide(int a, int b, int *quotient, int *remainder) { if (b 0) { return -1; } *quotient a / b; *remainder a % b; return 0; }调用时传quotient、remainder函数返回0表示成功结果通过指针写回。这种模式比“用全局变量存结果”安全得多也比“返回结构体”轻量得多。传址调用最容易犯的三个错误第一调用时忘记取地址符直接把值传进去第二函数内部忘记解引用*直接把指针赋值给普通变量第三没有检查指针是否为NULL就开始解引用。第三点在嵌入式环境和大型程序里特别危险一个空指针解引用就能把程序打崩。写过几年的老手普遍习惯在函数开头先判断指针形参是否为NULL这是很值得养成的习惯。1.3 数组参数退化成指针sizeof在函数里“失灵”数组传参和普通变量传参有本质区别。先看这个经典例子void print_size(int arr[]) { printf(%zu\n, sizeof(arr)); // 64位系统上通常输出8 } int main() { int a[10]; printf(%zu\n, sizeof(a)); // 输出40假设int占4字节 print_size(a); return 0; }同样是a在main函数里sizeof(a)是40进了函数就变成8。原因正如小标题所说数组作为函数参数时退化成指针。在C标准里函数形参列表中的int arr[]和int *arr是等价的编译器会把数组形参调整成指向其元素类型的指针。所以print_size接收到的是指向a[0]的指针自然sizeof出来的是指针大小。为什么C语言要这么设计两个原因。一是效率数组可能很大如果每次传参都整体复制一份时间和栈空间开销都很恐怖。二是C语言的设计哲学数组名在表达式中本身就代表首元素地址传数组本质上就是传地址没必要再发明一套新语法。但退化的后果是函数内无法通过sizeof(arr)/sizeof(arr[0])算出元素个数因为arr是指针而不是数组。正确做法是额外传一个长度参数int sum_array(int arr[], int n) { int sum 0; for (int i 0; i n; i) { sum arr[i]; } return sum; }调用sum_array(a, 10)。工程里几乎所有的数组处理函数都遵循这种“数组名长度”的参数设计。二维数组传参也有同样的退化规律但更隐蔽。看这个void print_matrix(int m[][4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , m[i][j]); } printf(\n); } }形参int m[][4]中第一维可以省略第二维的4必须写清楚。因为编译器要计算m[i][j]的地址它必须知道每行有多少个元素才能定位偏移量地址 首地址 (i * 4 j) * sizeof(int)。如果你写成int m[][]编译器算不出偏移量直接报错。有些人问“为什么不能像一维数组那样只传首地址”一维数组知道首地址和元素下标就能算出第n个元素地址n就是偏移量二维数组一行有多个元素必须知道列数才能做“折行”计算。这个机制想清楚之后你再看所有要求“列数固定”的二维数组形参就不会觉得它烦人了。传递方式函数形参写法能否修改实参数组长度信息传值int x不能不需要传址int *p能不需要传数组int arr[] 或 int *arr能改数组元素丢失需额外传长度传二维数组int m[][N]能列数必须写行数可省略这张表建议收藏基本把C语言参数传递最常见的情况覆盖了。写函数前先对照一下能少踩一大半的坑。2. 递归函数会写main会写递归才算真会写函数2.1 递归的“递进”和“回归”从字符串逆序说起很多同学背概念很熟练递归就是函数自己调用自己。但真到写代码时写着写着就绕进去了。真正要理解的是递归函数包含两个阶段递进阶段不断把问题变小逐层调用下去回归阶段最内层的调用先返回然后逐层往外“冒泡”。理解这两个阶段最好的入门题是字符串逆序输出。要求输入hello输出olleh。用递归写void reverse(const char *s) { if (*s \0) { return; } reverse(s 1); // 先递进到字符串的下一个字符 putchar(*s); // 从内层返回后再输出当前字符 }调用reverse(hello)会发生什么第一层先遇到return语句前的reverse(s1)所以它不会立即输出h而是先进入第二层处理ello第二层又进入第三层处理llo……一直到指针指向字符串结尾的\0触发了终止条件直接返回。这时最后一层reverse()返回后倒数第二层才开始执行putchar(*s)输出的是o然后回到上一层输出l再上一层输出l……最终输出olleh。这里的核心是输出语句放在递归调用前面还是后面效果完全不同。放在前面就是先输出当前字符再处理后面的结果是正序放在后面就是先处理后面的部分再回头输出当前字符结果是逆序。PTA里有很多字符串逆序题用递归解是最直观的思路之一因为“先处理后面的字符”和“逆序”这个语义天然吻合。2.2 递归的出口决定生死少了终止条件就是死循环写递归最容易犯的错误不是“不会递进”而是“忘记出口”。任何递归函数至少包含两样东西递归公式和终止条件。缺了终止条件函数会无限调用下去直到栈空间耗尽程序崩溃。比如阶乘long long factorial(int n) { if (n 1 || n 0) { // 终止条件 return 1; } return n * factorial(n - 1); // 递归公式 }递归公式是fact(n) n * fact(n-1)它把规模为n的问题转化成规模为n-1的问题终止条件是n0或n1时直接返回1。写递归时我的习惯是先思考“最简单的情况直接返回什么”再思考“一般情况怎么转化成更小的情况”。很多人一上来就写return那行结果边界情况怎么都想不清楚。这里要提醒一个常见误解递归的“终止条件”不是随便一个if就行它必须能在一个确定的入口被触发。比如if (n 1) return 1;但调用fact(-1)时-1不等于1会一直递归下去。所以严谨的写法应该先处理非法输入或者明确不支持负数。实际工程里递归函数尤其要检查输入的边界绝不能假设调用者一定传合法的值。2.3 递归深度和斐波那契效率什么时候必须改用循环递归虽美但代价不小。每次函数调用都要在系统栈上分配空间存放参数、局部变量和返回地址。递归一万层就要一万份栈空间。Linux默认栈大小通常是8MBWindows默认1MB左右如果每层栈占用几十上百字节递归深度达到几千到几万就可能栈溢出。更典型的问题是斐波那契数列的递归实现long long fib(int n) { if (n 2) { return 1; } return fib(n - 1) fib(n - 2); }这段代码逻辑完全正确但性能惨不忍睹。因为fib(n)会重复计算无数次fib(n-1)和fib(n-2)调用次数呈指数增长。算一个fib(40)需要调用约2.04亿次fib函数fib(45)就是22亿次以上程序跑起来像死机一样。遇到这种“逻辑上能递归、数值规模不允许”的问题有两个优化方向。一是改成循环递推long long fib_loop(int n) { long long a 1, b 1, c; if (n 2) { return 1; } for (int i 3; i n; i) { c a b; a b; b c; } return b; }二是用带记忆化的递归把已经算过的值存进数组下次直接用。后者本质是动态规划的思路很多算法教材会讲。我的个人建议是递归适合“问题天然能分解成同构子问题且深度可控”的场景比如目录遍历、括号匹配、汉诺塔这类。如果一个问题能用循环直接写就优先用循环。不要被“递归更高大上”的错觉绑住工程里栈是宝贵资源能省则省。3. 函数指针与回调把函数当成参数和数据来用3.1 看到int (*fp)(int, int)别慌一步步拆读函数指针是C语言里公认“看着吓人、用起来其实很机械”的语法。很多人第一次看到int (*fp)(int, int);就头皮发麻其实读法有规律fp先和*结合说明fp是一个指针去掉(*fp)之后剩下int (int, int)说明它指向的是一个“返回int、接受两个int参数”的函数。连起来说fp是指向“返回int、参数为两个int的函数”的指针。用typedef改写会更友好typedef int (*BinaryOp)(int, int); int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main() { BinaryOp fp; fp add; // 函数名在表达式里会转换成函数指针 printf(%d\n, fp(10, 3)); fp sub; printf(%d\n, fp(10, 3)); return 0; }这里有个很多人会问的细节fp add;和fp add;等价吗等价。函数名在表达式中会自动转换成“指向该函数的指针”就像数组名会退化成指向首元素的指针一样。调用时fp(10,3)、(*fp)(10,3)、add(10,3)这三种写法也完全等价。我习惯用fp(10,3)这种简洁写法因为C语言本来就允许这样但偶尔调试需要确认自己是函数指针时用(*fp)(10,3)反而更明确。两种写法都行别纠结。3.2 回调函数实战用qsort排序结构体数组所谓回调函数就是把函数指针作为参数传给另一个函数由那个函数在合适的时机调用你提供的函数。C标准库里的qsort是理解回调机制最好的教材void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));qsort负责排序但它不知道你排的是int、double还是结构体。它只知道数组首地址、元素个数、每个元素占多少个字节。具体怎么比较两个元素由你提供的compar回调函数决定。给int数组升序排序比较函数这样写int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); // 返回负数表示a在前正数表示b在前 }调用int arr[] {5, 2, 9, 1, 7, 3}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int);注意几点比较函数接收的是const void *必须先强转成实际类型再解引用比较函数返回负数表示第一个参数排在第二个参数前面返回正数表示第一个参数排在后面返回0表示相等。很多写法是return *(int*)a - *(int*)b这个写法在绝大多数场景没问题但两个极端int相减可能溢出。稳妥写法是用大于小于号组合返回-1、0、1如上所示。再看一个更复杂的场景给结构体数组按成绩降序排typedef struct { char name[20]; int score; } Student; int cmp_stu_desc(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return (sa-score sb-score) - (sa-score sb-score); }然后qsort(stu, n, sizeof(Student), cmp_stu_desc);。同一个qsort函数只需要换一个比较函数就能对完全不同的数据类型排序。这就是回调函数的核心价值把“通用的排序流程”和“具体的比较规则”解耦算法的骨架由库函数提供业务细节由你的回调注入。3.3 函数指针数组用“跳转表”代替switch函数指针不仅可以作为参数传递还能放进数组里形成一张“跳转表”。最常见的例子就是小型计算器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 divide(int a, int b) { return b ! 0 ? a / b : 0; } int (*ops[])(int, int) {add, sub, mul, divide}; int main() { int x 20, y 4; printf(%d\n, ops[0](x, y)); // 加 printf(%d\n, ops[1](x, y)); // 减 printf(%d\n, ops[2](x, y)); // 乘 printf(%d\n, ops[3](x, y)); // 除 return 0; }这样按索引调用函数比写一长串switch要直观得多。扩展新运算时只需要增加一个函数再把函数指针放进数组里调用处逻辑完全不用动。实际项目里命令解析、菜单分发、状态机跳转都喜欢用这种结构因为代码看起来干净扩展起来也方便。但要注意一个约束函数指针数组里的所有函数参数列表和返回值类型必须完全一致。否则数组声明直接写不出来除非全部强转成void *那又是另一套更危险的玩法了。我见过有的项目为了把参数不同的函数塞进同一个跳转表强行用空指针转换结果调用时栈布局全乱故障极难排查。跳转表适合确实同构的函数集合不要强行套用。4. 函数的“可见范围”static、extern与多文件工程组织4.1 static修饰函数把接口锁在模块内部这一节要从工程角度理解而不是背语法。在函数定义前加static这个函数就只能在本.c文件内使用其他文件就算用extern声明也无法调用到它因为它在链接阶段没有外部链接属性。为什么需要这个能力真实项目里模块一般都很大你写一个工具库可能内部有十几个辅助函数但真正需要对外开放的只有两三个接口。剩下的函数如果不加static别人在别的文件里也可以声明并调用。这会造成两类问题一是命名冲突不同模块里都定义了同名的helper函数链接直接报redefinition二是误用内部函数被外部代码以外的方式调用参数校验可能都没做很容易出bug而且出了问题还不好定位。static就起到了“信息隐藏”的作用。Java用private关键字C语言就用static修饰函数来实现类似效果。我记得有个学生做课程设计时整个项目只有一个.c文件几千行全堆在一起问我要不要拆文件。我让他拆开之后所有非入口函数尽量加static他一开始不理解为啥要这么麻烦后来发现编译时的“未使用函数”警告一下就能看出哪些函数写错了参数排查效率反而高了。原因是static把每个函数的作用域限制住了问题范围缩小了定位自然容易。另外注意别和“静态局部变量”混淆。static int count 0;写在函数体内部表示这个局部变量存放在静态存储区生命周期延长到整个程序运行期间。这跟“static修饰函数”是两回事前者管生命周期后者管链接属性。考试很容易考这个区别。4.2 多文件项目声明放头文件定义放源文件很多学生从“一个文件写到底”过渡到“多文件项目”时非常不适应。其实规则很简单函数定义函数体实现放在.c文件里函数声明原型放在.h头文件里其他.c文件想用这个函数只需要#include头文件。一个最基础的三文件结构utils.h#ifndef UTILS_H #define UTILS_H int add(int a, int b); int mul(int a, int b); #endifutils.c#include utils.h int add(int a, int b) { return a b; } int mul(int a, int b) { return a * b; }main.c#include stdio.h #include utils.h int main() { printf(%d\n, add(3, 5)); printf(%d\n, mul(3, 5)); return 0; }编译时一起编译gcc main.c utils.c -o program为什么非要声明和定义分离因为C语言编译器是按“翻译单元”工作的也就是按.c文件一个个编译。编译main.c时它根本不知道utils.c里发生了什么只看到main.c里调用了add和mul如果没有头文件里的声明编译器连这两个函数的参数类型都不知道。很多老教材会说“找不到函数时默认假设返回int”那是古老标准的行为现在的主流编译器直接报错。所以头文件的作用就是在编译当前.c文件时告诉编译器“这个函数存在它的签名是这个”。头文件里只放声明不放定义。如果某个函数定义写在utils.h里main.c和utils.c又都#include了这个头文件那链接时就会看到两个add函数的定义直接报multiple definition错误。函数定义在工程里必须全局唯一。4.3 头文件卫士和常见的链接错误头文件的第一行和最后两行永远不要省略#ifndef UTILS_H #define UTILS_H // 声明、宏定义、结构体定义放这里 #endif这叫头文件卫士作用是防止同一个头文件被间接包含多次。比如main.c在最上面#include utils.h然后又在别处#include math_utils.h而math_utils.h内部也#include了utils.h没有头文件卫士的话utils.h的内容会被展开两次。函数声明重复声明问题不算大但结构体typedef重复定义和宏重复定义就是有实感的编译错误。#pragma once也能达到类似效果很多编译器都支持但头文件卫士是标准写法兼容性最好。多文件项目常见的链接错误还有三种。第一种是“声明了但没定义”头文件里写了int add(int a, int b);但所有.c里都没实现add链接时报undefined reference。第二种是“重复定义”两个.c文件都实现了同一个函数报multiple definition。第三种是“类型不匹配”声明时写void f(int)定义时写void f(double)链接阶段可能不报错但行为完全扭曲这种错最难查因为编译器往往不提示。所以头文件必须和源文件的声明严格一致。写多文件程序我有个习惯每写一个对外函数先到头文件里补声明再回源文件里写实现。这样不会出现“函数写了但忘了暴露接口”或者“接口声明了但忘了实现”的尴尬局面。这个习惯在团队协作时特别重要因为同事只需要看头文件就能知道这个模块提供了什么能力不需要翻源码。5. 再进阶一步可变参数函数与inline函数的正确打开方式5.1 可变参数函数printf是怎么做到“想传几个就传几个”printf之所以能接收任意多个参数是因为它用了C标准库stdarg.h提供的一套可变参数机制。你完全可以在自己代码里实现一个可变参数函数比如一个简单的求和函数#include stdarg.h int my_sum(int count, ...) { va_list ap; va_start(ap, count); int total 0; for (int i 0; i count; i) { total va_arg(ap, int); } va_end(ap); return total; }调用my_sum(4, 10, 20, 30, 40)返回100。原理是va_start(ap, count)让ap指向第一个可变参数va_arg(ap, int)每调用一次就“取走”下一个参数并推进指针va_end(ap)做清理。但这里有个极其重要的原则可变参数函数必须自己知道“一共有几个参数每个参数是什么类型”编译器不会帮你做任何校验。所以这种函数的第一个参数通常扮演“描述信息”的角色。printf的format字符串里有几个%d、几个%s就是告诉函数“接下来会有多少个参数、各是什么类型”。如果实际传参和format不匹配轻则打印垃圾值重则栈指针错乱直接崩溃。用哨兵值标记参数结束也是一种常见方案比如约定最后一个参数传NULL表示结束。还有一个隐蔽的细节可变参数存在默认实参提升char和short会被自动提升为intfloat会被自动提升为double。所以在va_arg里取float类型参数时必须写va_arg(ap, double)而不能写va_arg(ap, float)。这也解释了为什么printf的%f对应的是double而不是float。可变参数功能很灵活但也很“低层”。我的建议是库函数里或者确实需要不定参数的地方可以用日常业务逻辑尽量少自己写因为你写的可变参数函数一旦被调用方传错参数崩溃现场往往很难定位。相比之下固定参数的普通函数有编译器帮你查类型安全得多。5.2 inline函数替代宏函数少点副作用很多C语言教材都会讲“宏函数”比如#define SQUARE(x) ((x) * (x))宏在工作原理上就是文本替换没有类型检查还容易踩副作用陷阱。最常见的例子int a 3; int b SQUARE(a);宏展开后变成((a) * (a))a被自增了两次b的结果完全不是预期。这就是宏的“副作用”问题因为宏是文本级别的重复展开传入的表达式被复制了两份。C语言的inline函数可以在很多场景替代宏static inline int square(int x) { return x * x; }square(a)在语义上就是一个真正的函数调用a只会自增一次没有二次求值问题。编译器可以把函数体直接展开到调用处省去函数调用的开销但又保留了类型检查和参数只求值一次的安全语义。而且static inline函数放在头文件里是合法的即使多个.c文件都include同一个头文件也不会造成重复定义因为它是内部链接的每个翻译单元各有一份。这个特性很适合用来做短小、高频调用的工具函数比如求两数最大值、计算绝对值这类。不过要注意inline只是一个“建议”最终编译器是否内联由它自己判断。现代编译器在优化模式下会把很多小函数自动内联比如GCC的-O2。所以不要到处堆inline关键字更合理的思路是凡是能用普通函数写的用普通函数原本想用宏写的小工具函数改成static inline。这样既保留了效率又消除了大量宏带来的坑。可变参数和inline这两个特性日常写课程设计和应付考试不一定会用到但读C语言库源码时几乎天天遇到。比如printf、scanf、qsort这些函数都是函数进阶里“进阶”二字的集中体现。你能把这两个特性看懂以后读任何C代码在函数层面的障碍就基本清光了。

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

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

免费获取报价 →
↑