Skip to content

函数:定义、值传递与递归

引言:函数是工程的组织单元

写到现在的规模,你已经用过不少函数了——mainprintf、还有前面章节自己写的 computesumgreet 这些。函数就是把一段逻辑封装起来、起个名字、可以反复调用,它是 C 工程最基本的组织单元(一个工程通常就是几十上百个函数互相调用)。这一章我们把函数本身的规矩讲清楚:怎么定义和调用、参数怎么传、返回值怎么回、递归怎么写、还有 static 局部变量这个特殊角色。

定义与调用

一个函数定义长这样:

c
返回类型 函数名(参数列表) {
    函数体
}

比如一个算阶乘的函数:

c
int fact(int n) {
    if (n <= 1)
        return 1;
    return n * fact(n - 1);
}

int 是返回类型、fact 是函数名、(int n) 是参数列表(参数 n 类型 int)、花括号里是函数体。函数体里用 return 表达式; 把一个值返回给调用者、同时结束这个函数;如果返回类型是 void(「无」),就不返回值、可以写 return; 提前结束、或不写(走到 } 自然结束)。第 1 章讲过:函数得先声明(原型)再使用,定义本身也算声明,所以如果 fact 定义在 main 之前、main 直接能用;定义在后面,就得在前面放个原型 int fact(int n);——这跟第 1 章的「声明 vs 定义」是一回事。

参数是「值传递」(副本)

这是新手最容易卡的一个点:C 的函数参数永远是「按值传递」的——传进去的是实参的一份「副本」,函数里怎么改这个副本,都改不到调用者原来的变量。 真跑一个想给参数 +1 的函数:

c
#include <stdio.h>

void bump(int x) {
    x++; /* 改的是「副本」,不影响调用者的变量 */
}

int main(void) {
    int n = 10;
    bump(n);
    printf("n = %d  (没变!bump 改的是 n 的副本)\n", n);
    return 0;
}
text
$ gcc -std=c11 -Wall valpass.c -o vp && ./vp
n = 10  (没变!bump 改的是 n 的副本)

bump(n) 调用时,把 n 的值(10)复制了一份给参数 x,函数里 x++ 改的是这个副本(变 11),但 main 里的 n 还是 10——bump 一返回,那份副本就丢了、n 纹丝不动。所以「在函数里改参数」改不到外面。

那要是真想让函数修改调用者的变量怎么办?你传 n 的「值」进去没用,得传 n地址(指针)——函数拿到地址,就能通过地址改到那块内存。这是阶段 2(指针)的核心主题,这里先埋个伏笔:函数想「输出」或「修改」调用者的数据,靠的就是传指针;光传值只能「输入」。

返回值:函数吐出来的结果

函数用 return 把一个值返回给调用者(return n * fact(n-1);),调用处(fact(5))就拿到这个值、可以继续用(赋值、打印、再当参数)。几个要点:一个函数一次只能 return 一个值(想返回多个结果,靠传指针参数「带出来」,或返回一个结构体);return 一执行,函数立刻结束(后面的代码不执行);如果声明了非 void 的返回类型,每条执行路径都得 return 一个值,否则是 UB(-Wreturn-type 会警告,第 1 章那个 main 不写 return 的坑就是这个的特例)。void 函数不返回值,相当于「我只想要它的副作用(比如打印)、不要它算个结果给我」。

递归:函数调自己

函数可以在体里调用自己,这叫递归(ISO/IEC 9899 §6.5.2.2 函数调用没禁止它)。阶乘是最经典的例子:n!=n×(n1)!,而 1!=1——

c
#include <stdio.h>

int fact(int n) {
    if (n <= 1)
        return 1;           /* 基线:不再递归 */
    return n * fact(n - 1); /* 自己调自己 */
}

int main(void) {
    printf("5! = %d\n", fact(5));
    return 0;
}
text
$ gcc -std=c11 -Wall recursion.c -o rc && ./rc
5! = 120

fact(5)fact(4)fact(4)fact(3)……一路到 fact(1) 命中 if (n <= 1) return 1 这个基线条件(不再递归),然后一层层把结果乘回去:1 → 2*1 → 3*2 → 4*6 → 5*24 = 120。递归的关键就是必须有基线(否则无限调自己、直到栈被撑爆段错误),而且每次递归要朝基线靠近(这里 n-1 朝 1 靠近)。

递归写起来简洁(树形、分治、回溯问题用递归特别自然),但代价是每次调用都要在栈上开一个栈帧(第 4 章讲过函数序言/栈帧),递归太深(比如 fact(100000))会把栈撑爆、段错误。所以深递归的场景常常得改成「迭代」(用循环代替),或上尾递归优化(C 标准不保证尾递归优化,gcc 在 -O2 会做一部分)。

static 局部变量:只初始化一次

函数里的局部变量默认是「自动的」——每次进函数新建、出函数销毁。但加了 static 就不一样了:

c
#include <stdio.h>

int counter(void) {
    static int count = 0; /* 只在第一次进入时初始化,之后值一直保持 */
    return ++count;
}

int main(void) {
    printf("%d\n", counter());
    printf("%d\n", counter());
    printf("%d\n", counter());
    return 0;
}
text
$ gcc -std=c11 -Wall staticvar.c -o sv && ./sv
1
2
3

连调三次 counter(),得 1 / 2 / 3——说明 count 没有每次进函数都重新初始化成 0,它的值在多次调用之间「记住」了。这就是 static 局部变量的特殊之处:它只在程序启动时初始化一次(不是每次进函数),之后值在多次调用间保持;同时它的存储期从「自动」(栈上)变成了「静态」(和全局变量一样存在 .data/.bss,程序全程存活)。注意它作用域没变——还是只有 counter 函数内部能访问 count(别的函数看不到),只是它「活得久了」。所以 static 局部变量适合做「函数内部的、需要跨调用保持的状态」,比如计数器、缓存、单例。static 的更全面含义(用在文件作用域的变量/函数上、变成 internal 链接)我们留到第 9 章讲作用域和存储期时一次讲透。

小结

函数把逻辑封装成可复用的单元:返回类型 函数名(参数列表) { ... },用 return 返回一个值、结束函数(void 函数不返回值)。C 的参数永远是「值传递」——传进去是实参的副本,函数里改参数改不到调用者的变量(真跑 bump(n)n 还是 10),想让函数修改调用者的数据要靠传指针(阶段 2)。一次只能 return 一个值(多值靠指针带出或返回结构体),非 void 函数每条路径都要 return 否则 UB。函数能调用自己(递归),但必须有基线条件且每次朝基线靠近(真跑 fact(5)=120),递归太深会撑爆栈、有时要改迭代。static 局部变量只初始化一次、值跨调用保持(存储期变静态、作用域不变,真跑 counter() 连调得 1/2/3),适合函数内部需要保持的状态,完整含义留第 9 章。下一章我们把作用域、存储期、static 一次讲透。

参考资源

  • ISO/IEC 9899:2011 §6.9.1(函数定义)、§6.7.6.3(函数原型/参数)、§6.5.2.2(函数调用)、§6.8.6.4(return)、§6.2.4(存储期:自动 vs 静态,static 局部)
  • 第 1 章:程序结构与编译四阶段(声明 vs 定义、函数原型)、第 7 章:控制流(函数体里的 if/return
  • 第 9 章:作用域、存储期与 static(static 的完整含义);阶段 2:指针与内存(用指针让函数改调用者的变量)