C语言期末速成6:函数
函数定义、值传递与地址传递、函数调用栈、递归与常用库函数。
本文是《C语言期末速成》系列第 6 篇。函数是 C 语言的基本组成单位——把大问题拆成小零件,每个零件一个函数。这一章除了语法,更要理解两件事:值传递为什么”传不回来”,以及递归到底怎么一层层算。这两点是函数部分的题眼。
考点清单
- 函数是 C 语言的基本组成单位;C 程序由函数组成,有且仅有一个
main - 函数不可以嵌套定义,但可以嵌套调用
- 函数名缺省返回值类型时默认为
int - 实参可以是常量、变量、表达式,甚至函数的调用结果(
add(add(7,8),9)) - 传值(值传递):形参的变化不影响实参;传地址:形参的变化会影响实参
- 函数声明需要:函数名、返回类型、参数类型(形参名可省略)
- 递归必须有结束条件,否则无限递归
- 常用库函数:
abs、fabs、sqrt、pow(a,b)、sin
一、函数长什么样
1.1 定义
返回类型 函数名(形参列表) {
函数体;
return 返回值;
}
int add(int x, int y) { /* int 是返回类型,x、y 是形式参数 */
return x + y;
}
几个硬规则:
- 函数名缺省返回类型时默认为
int:fun(int a)等价于int fun(int a); - 不能在函数内再定义函数(嵌套定义非法),但可以嵌套调用(函数里调另一个函数,甚至调自己——递归);
- 返回值用
return带回,没有返回值用void。
1.2 调用:程序是怎么”跳走又回来”的
int add(int x, int y) {
int z = x + y;
return z;
}
int main(void) {
int a = 5, b = 6, c;
c = add(a, b); /* ① 把 a、b 的值传给形参 ② 程序跳去执行 add ③ 拿回返回值 */
printf("%d\n", c); /* 11 */
return 0;
}
执行流程:
- 主程序从上往下顺序执行;
- 碰到
add(a,b),把实参 a、b 的值复制给形参 x、y; - 程序中断在调用处,跳去执行
add的函数体; add里算出z,return z把值交回来;- 程序回到
add(a,b)这个调用点,用返回值继续往下。
这里 a、b 叫实参(实际参数),x、y 叫形参(形式参数)——它们之间的关系是本章的核心(见第三节)。
1.3 实参的花样:函数调用也能当实参
实参可以是常量、变量、表达式,甚至另一个函数调用的结果:
int sum = add(add(7, 8), 9);
/* 内层 add(7,8)=15,外层 add(15,9)=24,sum = 24 */
先算内层再算外层——嵌套调用由内而外。
二、函数调用栈:理解”跳走再回来”的机器视角(加深)
第 1 篇的内存图上,栈区域正是函数调用的舞台。每次函数调用,系统会压入一个”栈帧”,里面装着:该函数的形参、局部变量、以及”返回地址”(调用结束后该回哪一行)。
main 的栈帧
┌────────────┐
│ a=5 b=6 c= │
├────────────┤
│ add 的栈帧 │ ← 调用 add 时压入
│ x=5 y=6 z= │
│ 返回地址 │
└────────────┘
- 调用时压栈:新栈帧盖在上面,所以 add 里的 x、y、z 和 main 里的 a、b 是两份独立的空间;
- 返回时弹栈:add 的栈帧被销毁,局部变量随之消失,程序沿返回地址回到调用点。
这个”压栈-弹栈”机制同时解释了三个现象:值传递改不了实参(形参是复制品)、递归能层层深入(每层一个栈帧)、局部变量会”失忆”(返回后栈帧销毁)。
三、值传递 vs 地址传递:传值传址(必考)
先立一个总纲,它是本章的灵魂:
C 语言里所有的参数传递都是”值传递”——所谓”传地址”,本质也是把”地址这个值”复制一份传过去。区别只在于:传的是普通值还是地址值。形参始终是实参的复制品,改形参改不到实参;但”传地址”时,形参手里握着实参的地址,能顺着地址摸到实参本身去修改。
记住这条,下面所有结论都不用背。
3.1 传值:形参是”复印件”
void swap(int a, int b) {
int t;
t = a; a = b; b = t; /* 换的是复印件 */
}
int main(void) {
int x = 1, y = 3;
swap(x, y); /* 把 x、y 的"值"传过去 */
printf("%d %d\n", x, y);/* 1 3 —— 没换过来! */
return 0;
}
为什么没换过来? 因为调用时,swap 里的 a、b 是 x、y 的拷贝。swap 内部交换的是拷贝,x、y 本人纹丝不动。值传递时,形参的任何变化都不会影响实参。
3.2 传地址:把”抽屉钥匙”传过去
想真正修改实参,就得把实参的地址传给函数,函数用指针接收,然后通过指针操作原来的变量:
void swap(int *a, int *b) {
int t;
t = *a; *a = *b; *b = t; /* *a 就是 main 里的 x,*b 就是 y */
}
int main(void) {
int x = 1, y = 3;
swap(&x, &y); /* 传地址! */
printf("%d %d\n", x, y); /* 3 1 —— 换过来了 */
return 0;
}
地址传递时,形参(指针)虽然也是拷贝,但它拷的是”实参的地址”,函数能顺着地址找到并修改实参本身。所以结论是:
传值,形参变不了实参;传地址,形参绝大多数能改实参。
选择哪种:需要改调用者的变量就传地址(如 swap、scanf("%d",&x));只是”拿数据算结果”就传值。
3.3 一个经典判断题
int fun(int a) { a = a * 2; return a; }
int x = 5, y = fun(x);
/* x 还是 5;y = 10 */
x 传给 fun 的是拷贝,a = a*2 只影响拷贝,x 不变。
四、全局变量与局部变量:作用域与默认值
按声明位置,变量分三类:
| 种类 | 声明位置 | 默认值 | 生命周期 |
|---|---|---|---|
| 全局变量 | 所有函数之外 | 自动初始化为 0(int=0、char='\0'、指针=NULL) | 程序运行全程 |
| 局部变量 | 函数体内 | 不定值(垃圾值),必须自己初始化 | 函数调用期间 |
| 形式参数 | 函数参数列表 | 由实参传入 | 函数调用期间 |
两个必考点:
① 默认值天差地别
int g; /* 全局:g 自动是 0 */
int main(void) {
int x; /* 局部:x 是垃圾值,不可靠 */
printf("%d %d\n", g, x); /* g 是 0,x 是随机残留值 */
return 0;
}
局部变量不初始化就是垃圾值——这一点和第 11 章的 static int x(默认为 0)要严格区分。
② 同名遮蔽
局部变量和全局变量同名时,函数内优先使用局部那个(局部”遮蔽”全局):
int g = 10; /* 全局 */
int main(void) {
int g = 20; /* 同名局部,遮蔽全局 */
printf("%d\n", g); /* 20:用的是局部 */
return 0;
}
五、函数声明:提前”打招呼”
调用函数时,编译器需要知道它的返回类型、名字、参数类型,否则不知道该怎么调用。函数可以定义在使用之后,但必须先声明。
int add(int x, int y); /* 函数声明(原型) */
int main(void) {
printf("%d\n", add(3, 4)); /* 先声明后调用,合法 */
return 0;
}
int add(int x, int y) { return x + y; } /* 定义放在后面 */
声明必须有:函数名、返回类型、参数类型;可以没有:形参名。
int *fun(int *a, int *b); /* 形参名完整 */
int *fun(int *, int *); /* 形参名省略,合法 */
函数的正确声明怎么写(典型考题)
已知定义是 int *fun(int a[], int b[]),那么声明可以写成:
int *fun(int *a, int *b); /* 数组当指针,合法 */
int *fun(int b[], int c[]); /* 形参名随便换,合法 */
int *fun(int *, int *); /* 名字都不写,合法 */
理解:形参列表中数组的写法等价于指针(第 7、8 章详细展开),声明只关心类型不关心名字。
六、递归:函数调自己
5.1 两个必要条件
递归 = 函数直接或间接调用自己。但必须有结束条件,否则会无限递归直到栈溢出:
- 递归出口(终止条件):满足时直接返回,不再递归;
- 递归推进:每次调用都把问题规模缩小一点,逼近出口。
5.2 阶乘:最经典的递归
int fact(int n) {
if (n <= 1) return 1; /* 递归出口 */
return n * fact(n - 1); /* 规模缩小:n-1 */
}
/* fact(4) = 4 × fact(3) = 4×3×fact(2) = 4×3×2×fact(1) = 4×3×2×1 = 24 */
递归树(fact(4) 的展开):
fact(4)
└─ 4 × fact(3)
└─ 3 × fact(2)
└─ 2 × fact(1)
└─ 1 ← 出口,开始"逐层回填"
先一路下钻到出口,再自下而上把结果带回来。
5.3 斐波那契:双递归
int fib(int n) {
if (n == 1 || n == 2) return 1; /* 前两项是 1 */
return fib(n - 1) + fib(n - 2); /* 第 n 项 = 前两项之和 */
}
/* 1,1,2,3,5,8,13,... */
注意双递归调用次数增长极快(fib(40) 会调用天文数字次),工程上常改用迭代。
5.4 递归转迭代(加深)
能用递归解决的问题,几乎都能用循环改写(递归本质靠栈,循环靠显式变量):
/* 阶乘的迭代版 */
int fact_iter(int n) {
int p = 1;
for (int i = 1; i <= n; i++) p *= i;
return p;
}
/* 斐波那契的迭代版 */
int fib_iter(int n) {
int a = 1, b = 1, c;
for (int i = 3; i <= n; i++) { c = a + b; a = b; b = c; }
return b;
}
递归代码简洁、思路直观(尤其树形问题),迭代省内存、快。考试中递归必考,至少要能徒手算出简单递归的结果——画递归树是万能办法。
5.5 判断质数(背这个函数)
int isPrime(int n) {
if (n < 2) return 0;
for (int i = 2; i * i <= n; i++) /* 只要试到 √n 即可 */
if (n % i == 0) return 0;
return 1;
}
判断质数只需试除到 √n(因为 n 若有因数,必有一个 ≤ √n),比试到 n/2 更高效。
七、库函数:别人写好的函数仓库
库函数是已经写好、放在库中的函数,我们只负责正确调用:
abs(x):整数绝对值;fabs(x):浮点数绝对值;sqrt(x):开平方;pow(a, b):a的b次幂,2^3写作pow(2, 3);sin(x)、cos(x)等数学函数。
使用数学库函数通常要 #include <math.h>,链接时可能还需要 -lm。考试填空题里 pow(a,b) 是重点——它俩参数顺序是底数在前,指数在后。
#include <stdio.h>
#include <math.h>
int main(void) {
printf("%.2f\n", sqrt(9)); /* 3.00 */
printf("%.0f\n", pow(2, 3)); /* 8 */
printf("%d\n", abs(-5)); /* 5 */
return 0;
}
易错点 / 陷阱
- 函数嵌套定义:C 里不允许(不能在一个函数体里定义另一个函数),但嵌套调用可以。
- 值传递想改实参:
swap(x, y)换的是拷贝,实参不变;要改必须传地址。 - 形参名在声明里漏掉:声明里形参名可省,但类型不能省。
- 递归没出口:没有结束条件的递归会栈溢出崩溃。
- 缺省返回类型:
fun(...)默认返回int,但正式代码建议写清楚。 pow(2,3)写成pow(3,2):底数在前、指数在后。return后语句还执行:return之后的语句不会执行。
自测小练习
第 1 题:int add(int x,int y){return x+y;},求 add(add(3,4),add(5,6)) 的值。
第 2 题:写一个值传递的交换函数,调用后实参不变——请说明原因。
第 3 题:void fun(int *p){ *p = 100; },int x=1; fun(&x); 后 x 是多少?为什么?
第 4 题:用手算 fact(5) 的递归过程,写出每一步。
第 5 题:判断对错:函数 swap 里能用 void 作返回值类型,因为没有值需要返回。
参考答案
add(3,4)=7,add(5,6)=11,add(7,11)=18。- 值传递把实参的值复制给形参,形参是独立拷贝,交换形参不影响实参。
x = 100。&x是地址,p指向 x,*p = 100修改了 x 本身。fact(5)=5×fact(4)=5×4×fact(3)=5×4×3×fact(2)=5×4×3×2×fact(1)=120。- 对。
void表示没有返回值,只做”副作用”(如交换)的函数正好用它。
下一篇:C语言期末速成 7:数组