函数
掌握函数定义/声明/调用、形参与实参、返回值、函数原型、局部变量的块作用域与自动存储期,能编写模块化C程序
学习目标
- 能写出带返回类型的函数定义与函数原型,并解释声明、定义、调用三者在编译/链接阶段各自的作用
- 能区分形参与实参,并说明 C 对 int/double 等类型采用值传递时,函数内修改形参为何不影响调用方的原变量
- 能利用局部变量与块作用域把程序拆成多个函数模块,并回答:fact(4) 到达 fact(1) 时同时有几次 fact 调用尚未返回?
机制总览
函数:机制路径
- 1
为什么要把代码装进「函数」?
想象一家工厂把「切菜」「炒菜」「装盘」拆成三个工位:每个工位只做一件事,原料从上一个工位递过来,成品交给下一个。主程序如果把所有步骤写进 main,就像一个人包办全流程——代码越长越难改,改一处可能牵全身。
- 2
创建和使用函数:定义、原型与调用
int 是返回类型, sum 是名字, (int a, int b) 是形参列表,花括号里是函数体。
- 3
形参、实参与值传递
调用 sum(3, 5) 时, 3 和 5 是 实参(actual argument) ;定义里的 a 、 b 是 形参(formal parameter) 。
章级决策实验
函数:机制与证据
切换《函数》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 为什么要把代码装进「函数」?
想象一家工厂把「切菜」「炒菜」「装盘」拆成三个工位:每个工位只做一件事,原料从上一个工位递过来,成品交给下一个。主程序如果把所有步骤写进 main,就像一个人包办全流程——代码越长越难改,改一处可能牵全身。
可核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么要把代码装进「函数」?」的实际行为。
学完《函数》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
函数:失效与核验
为什么要把代码装进「函数」?
典型失效
若只记语法而忽略「为什么要把代码装进「函数」?」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么要把代码装进「函数」?」的实际行为。
创建和使用函数:定义、原型与调用
典型失效
若只记语法而忽略「创建和使用函数:定义、原型与调用」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「创建和使用函数:定义、原型与调用」的实际行为。
形参、实参与值传递
典型失效
若只记语法而忽略「形参、实参与值传递」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「形参、实参与值传递」的实际行为。
为什么要把代码装进「函数」?
想象一家工厂把「切菜」「炒菜」「装盘」拆成三个工位:每个工位只做一件事,原料从上一个工位递过来,成品交给下一个。主程序如果把所有步骤写进 main,就像一个人包办全流程——代码越长越难改,改一处可能牵全身。
如果没有函数呢? 重复逻辑只能复制粘贴,变量名容易冲突,团队协作也无法分工。函数把「一段可复用的加工步骤」打包命名:需要时调用,不需要时不用管内部细节。这一章教你 C 语言里函数怎么定义、怎么声明、怎么传参、怎么返回值,以及怎样用多个函数拼出模块化程序。
创建和使用函数:定义、原型与调用
↡由返回类型、函数名、形参列表和函数体组成的完整实现。告诉编译器函数做什么,并提供可执行的代码。 是函数的「完整档案」:
int sum(int a, int b) /* 函数头 */
{
return a + b; /* 函数体 */
}int 是返回类型,sum 是名字,(int a, int b) 是形参列表,花括号里是函数体。return 把结果交回给调用方。
若函数定义写在 main 之后,调用点之前的编译器还不知道 sum 长什么样——需要 ↡在调用之前向编译器说明函数名、返回类型和形参类型的语句。也称函数声明。不含函数体。:
int sum(int a, int b); /* 原型:分号结尾,无函数体 */
int main(void) {
int x = sum(3, 5); /* 调用 */
return 0;
}
int sum(int a, int b) { /* 定义 */
return a + b;
}原型是「名片」,定义是「车间」,调用是「下单」。编译器用原型检查 sum(3, 5) 类型是否匹配;链接器把调用绑到真正的定义上。
猜一猜:只有原型、没有定义,程序能通过链接吗?用 Stepper 看声明、定义、调用如何串起来。
第一步:写函数原型
double cube(double x); 告诉编译器:有个叫 cube 的函数,接受 double、返回 double。此时还没有实现。
void 作返回类型表示「无返回值」——函数体里用 return; 提前结束,或不写 return 落到函数末尾。
形参、实参与值传递
调用 sum(3, 5) 时,3 和 5 是 ↡调用函数时括号里提供的表达式或变量。其值按规则传给形参。;定义里的 a、b 是 ↡函数定义或原型中列出的占位参数。调用时接收实参传来的值。。
C 对 int、double、char 以及结构体等,默认采用 ↡把实参的值拷贝一份给形参。形参与实参占用不同内存,修改形参不影响实参。:
void try_change(int n) {
n = 99;
}
int main(void) {
int num = 5;
try_change(num);
printf("%d\n", num); /* 仍是 5 */
return 0;
}函数内改 n 只改形参副本,main 里的 num 不动。要让函数改调用方的变量,需传指针(下一章)或把新值 return 回去。
第一步:调用前
num 在 main 的栈帧里,值为 5。
↡函数执行完毕或通过 return 交还给调用方的结果。类型须与函数声明的返回类型兼容。 常用赋值或表达式接收:int r = sum(3, 5); 或 printf("%d", sum(3, 5));。
调用记录:一次函数调用发生了什么
从 C 抽象机看,每次函数调用都有独立的参数与自动对象实例,并在返回后结束本次执行。常见 ABI 用 ↡常见机器实现中用于保存一次函数调用状态的区域,可含返回地址、被保存寄存器与部分自动对象;具体布局不是 C 语言保证。 表示这份调用记录,但优化器可以把参数和局部对象放入寄存器、消除对象,甚至内联整个调用。因此下图是理解调用关系的典型实现模型,不是可移植程序可以依赖的内存布局。
以 int result = add(5, 3); 为例:
第一步:传参压栈
main 调用 add:实参表达式的值被交给形参,调用方暂停等待。典型 ABI 可能把值放在寄存器或栈上,并保留返回位置。
局部变量、块作用域与自动存储期
↡在函数体或复合语句块内声明的对象;其名字通常具有块作用域,未带 static 时通常具有自动存储期。 的名字属于 ↡从声明点开始,到包含该声明的最小复合语句或函数定义末尾为止的可见区域。。形参的作用域覆盖整个函数定义;函数体内普通局部对象通常具有自动存储期,每次进入相应块都会产生新的对象实例:
int twice(int n);
int main(void) {
int x = 10; /* main 的局部变量 */
printf("%d\n", twice(x));
return 0;
}
int twice(int n) { /* n 是 twice 的局部变量 */
int tmp = n * 2; /* tmp 也是 twice 的局部 */
return tmp;
}main 看不到 tmp,twice 也看不到 x,因为它们的块作用域不重叠。同名局部标识符在不同函数里互不影响;内层块还可以隐藏外层同名标识符。C 标准术语中的函数作用域只属于 label 标签,普通局部变量不是“函数作用域”。
函数内的 static 局部对象(static int count)具有静态存储期,生命周期贯穿整个程序,但名字仍只有块作用域;后续章节会展开。
递归:函数调用自身
↡函数直接或间接调用自身的编程技巧。必须有基准情形终止递归,否则无限压栈导致栈溢出。 把大问题拆成「同样结构的小一号问题」:
long fact(int n) {
if (n <= 1)
return 1; /* 基准情形 */
return n * fact(n - 1); /* 递归情形 */
}fact(4) = 4 × fact(3) = 4 × 3 × fact(2) = … 直到 fact(1) 直接返回 1,再逐层乘回。
第一步:fact(4) 入栈
第一次调用 n=4,尚未算完,需要 fact(3) 的结果。
尾递归不是“自动省栈”承诺
当递归调用是返回前最后一个动作时,称为 ↡递归调用的结果被当前函数直接返回,调用后不再执行乘法、加法等待完成操作的递归形式。。把中间结果放进累加器,就能把阶乘写成尾递归:
static unsigned long fact_tail(unsigned n, unsigned long acc) {
if (n <= 1)
return acc;
return fact_tail(n - 1, acc * n);
}
unsigned long fact(unsigned n) {
return fact_tail(n, 1UL);
}实现可以把尾调用复用为当前调用记录,但 C11 不保证尾调用优化;可移植程序不能因为“这是尾递归”就假定常量栈空间。若深度由不可信输入决定,循环通常更稳妥。无论递归还是循环,unsigned long 的阶乘都会很快溢出,生产代码还要在乘法前检查 acc > ULONG_MAX / n。
多源文件与头文件:从翻译单元到链接
较大的程序会把声明放进头文件,把定义分散到多个源文件。预处理后,每个 .c 文件连同它包含的头文件形成一个 ↡一个源文件经过预处理、展开 include 后交给编译器处理的完整单位;各翻译单元分别生成目标文件。,再由链接器把目标文件组成程序。
/* math_utils.h */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int square(int value);
#endif/* math_utils.c */
#include "math_utils.h"
int square(int value) {
return value * value;
}/* main.c */
#include <stdio.h>
#include "math_utils.h"
int main(void) {
printf("%d\n", square(7));
return 0;
}cc -std=c11 -Wall -Wextra -Wpedantic -c math_utils.c
cc -std=c11 -Wall -Wextra -Wpedantic -c main.c
cc main.o math_utils.o -o app头文件保护宏允许同一声明被间接包含多次;函数定义留在一个 .c 文件中。声明告诉每个调用所在的翻译单元函数类型,定义提供最终可链接实体。具有外部链接的普通函数在整个程序中通常只能有一个定义:没有定义会产生未解析符号,重复定义会产生多重定义错误。
地址和指针:仍然是值传递
一元 & 运算符取得对象的地址,* 对指针做 ↡通过指针保存的地址访问它所指向对象的运算,也称解引用;只可用于有效、对齐且指向对象生存期内的指针。。把地址作为实参,函数收到的仍然是指针值的副本,但副本和调用方指针指向同一对象,所以能修改该对象:
#include <stdio.h>
void swap(int *left, int *right) {
int temp = *left;
*left = *right;
*right = temp;
}
int main(void) {
int a = 3, b = 8;
swap(&a, &b);
printf("a=%d b=%d\n", a, b);
return 0;
}调用方用 &a、&b 提供有效地址,swap 用 *left、*right 间接访问对象。若接口允许空指针,函数必须在解引用前明确处理;本例的契约要求两个参数都指向有效 int。传指针没有改变 C 的值传递规则,只是被复制的值恰好是地址。
代码详解:模块化小工具
场景一:函数原型 + 定义 + 调用
#include <stdio.h>
double cube(double x); /* 原型 */
int main(void) {
printf("2^3 = %.0f\n", cube(2.0));
return 0;
}
double cube(double x) { /* 定义 */
return x * x * x;
}main 在前、定义在后时,原型不可省略。
场景二:多函数分工
#include <stdio.h>
int get_int(int *value);
int square(int n);
void greet(void);
int main(void) {
int x;
greet();
if (!get_int(&x))
return 1;
printf("平方 = %d\n", square(x));
return 0;
}
void greet(void) {
printf("输入一个整数:");
}
int get_int(int *value) {
return scanf("%d", value) == 1;
}
int square(int n) {
return n * n;
}每个函数一块职责:问候、读入、计算——main 只做调度。
场景三:值传递与返回值
#include <stdio.h>
int max_of_two(int a, int b) {
if (a > b)
return a;
return b;
}
int main(void) {
int m = max_of_two(12, 7);
printf("较大者 %d\n", m);
return 0;
}形参 a、b 是实参的副本;return 把结果传回调用方,不修改 main 里原有变量。
场景四:递归求阶乘
#include <stdio.h>
unsigned long fact(unsigned n) {
if (n <= 1)
return 1UL;
return n * fact(n - 1);
}
int main(void) {
printf("4! = %ld\n", fact(4));
return 0;
}本例聚焦递归结构,并假设 4! 可由 unsigned long 表示。若 n 来自输入,调用前还要限制范围或在每次乘法前做溢出检查。
容易踩的坑
小结
- 函数 = 返回类型 + 名字 + 形参列表 + 体;原型供编译器检查,定义提供实现,链接器绑定调用
- 实参是调用时的值,形参是定义里的占位;C 默认值传递,改形参不改实参
- 每次调用有独立执行状态;栈帧是常见实现模型,不是所有局部对象的标准布局保证
- 普通局部变量具有块作用域;只有 label 标签具有 C 术语中的函数作用域
- 递归需基准情形与进展;尾调用优化不是 C11 保证
- 多源文件用头文件共享声明、源文件提供定义、链接器组合目标文件
- 传地址仍是值传递;被复制的指针可用于间接访问调用方对象
练习
问题 1(改代码型) 下面程序链接失败,缺少什么?补全后应输出 面积 = 50。
#include <stdio.h>
int main(void) {
printf("面积 = %d\n", rect_area(5, 10));
return 0;
}问题 2(问答型) 调用 try_change(num) 后 num 仍是 5,用文字说明值传递下内存里发生了什么。
问题 3(综合实现型) 写程序:用函数 int is_prime(int n) 判断素数,void print_primes(int limit) 打印 2 到 limit 之间所有素数。main 只负责读 limit 并调用。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 函数定义
由返回类型、函数名、形参列表和函数体构成的完整实现。既声明接口又提供可执行代码。具有外部链接且被使用的普通函数在整个程序中应有一个外部定义。
- 函数原型(声明)
在调用前向编译器说明函数名、返回类型和形参类型的语句,以分号结尾、无函数体。让编译器能检查调用是否类型匹配。详见 FunctionPrototypeDiagram。
- 形参(formal parameter)
函数定义或原型括号里列出的参数名,是实参值的接收占位。在函数栈帧中有自己的存储,值传递时是实参的副本。
- 实参(actual argument)
调用函数时写在括号里的表达式或变量。其值按传递规则交给形参。如
sum(3, 5)中 3 和 5 是实参。- 值传递(pass by value)
把实参的值拷贝给形参,形参与实参占用不同内存。修改形参不影响实参。C 对 int、double、结构体等默认采用值传递。详见 ParameterPassingDiagram。
- 返回值
函数通过 return 表达式交还给调用方的结果,类型须与函数声明的返回类型兼容。无返回值函数用 void,return 不带表达式。
- 栈帧(stack frame)
常见 ABI 用来保存一次函数调用状态的区域,可含返回地址、被保存寄存器和部分自动对象。具体布局以及对象是否实际入栈不由 C 标准保证。详见 FunctionCallStackDiagram。
- 局部变量
在函数体或复合语句块内声明的对象。名字通常具有块作用域;未带 static 时具有自动存储期,其生存期在相应块执行期间。
- 块作用域
从声明点到包含声明的最小复合语句或函数定义末尾的名字可见区域。普通局部变量和形参使用块作用域;C 的函数作用域只适用于 label 标签。
- 递归(recursion)
函数调用自身解决问题。须有基准情形停止递归,否则栈溢出。fact(n) 是经典示例。详见 RecursionStackDiagram。
- 尾递归(tail recursion)
递归调用结果被当前函数直接返回,调用之后没有待完成操作的递归形式。实现可以优化尾调用,但 C11 不保证常量栈空间。
- 翻译单元
一个源文件经过预处理并展开 include 后交给编译器的完整单位。多个翻译单元分别生成目标文件,再由链接器组成程序。
- 间接访问
对有效指针使用一元星号访问其所指对象的操作,也称解引用。指针必须满足对象生存期、类型和对齐等要求。