第9章:函数
函数把一项任务封装成可以调用的代码单元。设计函数时,需要说清楚:接收什么、返回什么、会修改什么,以及调用前必须满足哪些条件。
本章可以沿着三条线理解:用参数传入值,用返回值交回结果,用指针访问调用方提供的对象。 递归和多文件组织,则是在这些规则上进一步构建程序。
1. 把函数看作有明确接口的任务单元
1.1 函数的作用不只是减少重复
即使一段代码只执行一次,也可以提取成函数。例如,一个数据处理程序可以分成读取、计算、显示几个步骤,让 main 保留整体流程。
函数名表达任务,函数体实现细节。调用者只需要掌握接口约定,不必在每次调用时重新理解内部算法。
设计接口时,至少确认:
- 输入值的类型和允许范围。
- 返回结果的含义。
- 是否输出文字、读取输入或修改外部对象。
- 失败时如何报告,输出对象是否已经改变。
1.2 输入与结果有不同组合
| 接口示例 | 参数 | 返回值 | 典型作用 |
|---|---|---|---|
void show_title(void) |
无 | 无 | 输出固定提示 |
void repeat_char(char ch, int count) |
有 | 无 | 根据参数完成输出 |
int get_choice(void) |
无 | 有 | 取得一个选项或状态 |
int smaller(int first, int second) |
有 | 有 | 计算并返回结果 |
没有返回值不代表没有作用。输出文字和修改对象等行为,也可以是函数的任务。
2. 函数原型、定义与调用
2.1 一个完整示例
#include <stdio.h>
void repeat_char(char ch, int count);
int smaller(int first, int second);
int main(void)
{
repeat_char('=', 8);
putchar('\n');
printf("smaller: %d\n", smaller(7, 3));
return 0;
}
void repeat_char(char ch, int count)
{
for (int index = 0; index < count; ++index) {
putchar(ch);
}
}
int smaller(int first, int second)
{
return first < second ? first : second;
}
输出:
========
smaller: 3
2.2 三种形式分别承担什么任务
| 形式 | 作用 |
|---|---|
int smaller(int first, int second); |
函数原型,提供接口信息 |
smaller(7, 3) |
函数调用,提供本次实参 |
int smaller(...) { ... } |
函数定义,给出具体实现 |
原型末尾有分号,定义中的函数头后接函数体。
返回类型与参数类型是不同信息。double average(int total, int count) 接受两个 int,但返回 double。
2.3 原型中的参数名可以省略
下面两种原型描述相同的接口:
int smaller(int first, int second);
int smaller(int, int);
原型中的参数名用于说明含义,不会因此创建函数执行时的形参对象,也不要求与定义中的名称一致。
定义中的参数列表则需要提供在函数体里使用的名称,而且每个参数都要写类型:
void display(char ch, int rows, int columns);
不能用 int rows, columns 这样的普通变量合写形式省略第二个形参的类型。
2.4 先让编译器看到接口
调用位置之前,需要有适当的函数声明。可以先写原型、后写定义;也可以把完整定义放在首次调用之前。
标准 C 的函数定义不能嵌套在另一个函数体中。函数之间通过调用建立执行关系,而不是通过把一个定义写进另一个定义来组织。
标准库函数的声明由相应头文件提供。包含 <stdio.h>,就取得 printf、scanf 等接口信息,不必自己猜写它们的原型。
把声明写出来,不等于已经提供函数实现。若最终没有相应定义或链接库实现,仍然可能在链接阶段失败。
3. 参数传递:调用方给值,函数接收副本
3.1 实参与形参
repeat_char('*', times + 2);
调用中的 '*' 与 times + 2 是实参表达式。它们求值后,按形参类型转换,交给被调用函数的形参 ch 与 count。
| 名称 | 所在位置 | 角色 |
|---|---|---|
| 实参 | 调用表达式中 | 提供本次传入的值 |
| 形参 | 函数定义的参数列表中 | 函数内部使用的局部对象 |
常量、变量或计算表达式都可以作为实参。函数接收到的是本次计算的值,不会自动与调用方原变量保持同步。
3.2 修改普通形参,不会修改调用方的整数
void change_copy(int value)
{
value = 100;
printf("inside: %d\n", value);
}
如果调用方有 int value = 5;,执行 change_copy(value); 后,调用方的 value 仍是 5。
即使形参和实参使用相同名称,也只是两个不同的对象。形参在这里取得初始值后,函数修改的是自己的对象。
3.3 局部对象与作用域
函数体内的普通局部变量和形参,只能在相应作用域内通过这些名称使用。另一个函数不能直接靠写出相同变量名访问它们。
每次函数调用都会为普通自动局部对象建立本次调用所需的状态。这个结论针对这里的普通局部变量;后续学习的静态局部对象有不同的生存期规则。
3.4 实参之间不保证从左到右求值
函数形参按位置接收对应实参,但这不意味着实参表达式一定按书写顺序求值。
不要把 f(number++, number++) 当作有固定结果的计数技巧。在本章的 C99/C11 语境下,这种缺少适当顺序约束的重复修改属于未定义行为。
如果参数的计算需要先后关系,先分成独立语句保存结果,再调用函数。
4. return:交回结果并结束当前调用
4.1 返回的是值,不是局部变量的名字
int smaller(int first, int second)
{
int result = first < second ? first : second;
return result;
}
调用者通过函数调用结果取得返回值,例如 int answer = smaller(7, 3);,并不能在外部直接使用这个函数内部的 result 名称。
返回局部整数的值没有问题:值交给调用方后,调用方不需要继续访问原局部对象。
4.2 返回表达式按返回类型转换
int whole_part(double value)
{
return value;
}
对可由 int 表示的截断结果,whole_part(3.75) 返回 3。写了 return value;,不会让声明返回 int 的函数自动变成返回 double。
类型转换的范围规则仍然适用,不能用返回类型转换来替代数据验证。
4.3 可以提前返回
int smaller(int first, int second)
{
if (first < second) {
return first;
}
return second;
}
一旦执行 return,当前调用结束,后面不再执行。多个清楚的返回路径可以减少不必要的嵌套,并不要求每个函数都只能在末尾返回一次。
void 函数可以用 return; 提前结束,不返回数值;也可以正常执行到函数体末尾。
4.4 有返回值的函数要覆盖需要的路径
对于普通非 void 函数,应确保需要正常交回结果的路径执行适当的 return。若执行到末尾而调用方又使用了结果,会产生未定义行为。
main 在 C99/C11 中执行到最外层末尾等价于返回零,是专门的规则,不能推广到其他整数返回函数。
返回值可以保存、参与计算、作为其他函数的实参,也可以被忽略。打印文字与返回结果仍是不同的动作。
5. 函数原型如何帮助检查调用
5.1 固定参数按接口检查和转换
int smaller(int first, int second);
这个原型让编译器知道函数需要两个 int 参数,并返回 int。
- 少传或多传固定参数,属于需要诊断的调用问题。
- 某些数值类型不同时,允许按形参类型转换。
- 无法按规则兼容的参数,例如错误的指针类型,不能靠“大小相同”解释为正确。
例如,传入 3.9 与 5.4,会按 int 形参要求转换成 3 与 5。原型帮助协调调用,但不会判断这种截断是否符合业务意图。
5.2 无参数与未说明参数不同
在本章采用的 C99/C11 规则下:
| 声明 | 含义 |
|---|---|
void show_title(void); |
明确说明不接受参数 |
void show_title(); |
没有在这个声明中给出参数类型信息 |
因此,无参数函数应写清 (void)。阅读旧代码时要能识别空参数声明和旧式定义,自己整理代码时则采用明确的原型形式。
省略返回类型、依赖隐式 int 的旧写法,也不是 C99/C11 中可继续依赖的通用规则。
5.3 固定形参与可变参数部分不同
printf 的接口包含固定的格式字符串参数和可变参数部分。后者中的 float 会提升为 double,较窄整数会进行整数提升。
但对已经由原型明确指定类型的固定形参,实参按该形参的要求转换。
所以,不能把“传入任何函数时,所有 float 都必定变成 double”作为统一规则。对可变参数接口,仍需确保格式字符串与实际传入的类型、数量一致。
6. 递归:一个调用尚未结束,又调用同一个函数
6.1 调用前与返回后的两个阶段
#include <stdio.h>
void trace(int level);
int main(void)
{
trace(1);
return 0;
}
void trace(int level)
{
printf("enter %d\n", level);
if (level < 3) {
trace(level + 1);
}
printf("leave %d\n", level);
}
输出:
enter 1
enter 2
enter 3
leave 3
leave 2
leave 1
可以把过程看作:
trace(1)
输出 enter 1
trace(2)
输出 enter 2
trace(3)
输出 enter 3
不再继续递归
输出 leave 3
返回到第二层,输出 leave 2
返回到第一层,输出 leave 1
递归调用返回后,外层会从调用位置之后继续执行,而不是重新从函数第一行开始。
6.2 每层保存自己的调用状态
这里三次调用各有自己的形参对象 level。第三层使用 3,不代表第一层保存的值也变成了 3。
函数代码可以共享,不需要为每层复制一份代码;需要保留的是每次尚未结束调用的局部状态和继续执行的位置。
普通实现常借助调用栈管理这些信息,但地址排列方向、每层占用多少空间等不是可写死的语言约定。
6.3 递归必须逐步接近终止情形
| 要素 | 本例 |
|---|---|
| 基本情形 | level 到达 3 后不再调用自己 |
| 递归推进 | 下一层使用 level + 1 |
| 返回后的工作 | 输出本层 leave |
只写一个终止判断还不够,下一层参数还必须能朝它推进。无限递归通常会耗尽运行资源,不能靠系统替程序完成正确终止。
7. 阶乘:循环、普通递归与尾递归
7.1 同一个数学关系的不同实现
对非负整数,0! = 1,n! = n × (n - 1)!。
下面两个函数的调用约定都是 n 位于 0 到 20,结果使用 unsigned long long 保存:
unsigned long long factorial_loop(unsigned int n)
{
unsigned long long result = 1;
for (unsigned int value = 2; value <= n; ++value) {
result *= value;
}
return result;
}
unsigned long long factorial_recursive(unsigned int n)
{
if (n <= 1) {
return 1;
}
return n * factorial_recursive(n - 1);
}
例如,输入 5 都返回 120,输入 0 都返回 1。20! 能由标准保证的至少 64 位无符号长长整型表示,不能把更大阶乘也当作必然精确可存。
这两个计算函数依赖调用前满足范围约定;仅把参数写成无符号类型,不会自动验证输入是否在 0 到 20。
7.2 递归调用后还有乘法,就不是尾递归
return n * factorial_recursive(n - 1);
这里必须等递归结果返回,再乘以本层的 n。因此,这不是尾递归,不能仅因为递归调用写在最后一条 return 中,就把它归为尾递归。
真正的尾递归形式可以把累计结果传给下一层:
unsigned long long factorial_tail(unsigned int n,
unsigned long long accumulated)
{
if (n <= 1) {
return accumulated;
}
return factorial_tail(n - 1, accumulated * n);
}
在相同的 0 到 20 范围内,用 factorial_tail(n, 1) 开始计算。下一层返回后,本层没有额外算术工作要做。
C 不保证实现一定进行尾调用优化。如果需要明确限制额外调用空间,直接使用合适的循环通常更容易控制。
8. 递归的倒序输出与效率
8.1 先计算低位,后输出低位
一个无符号整数的最低二进制位可由 % 2 得到,/ 2 则去掉当前最低位。
但最终输出需要从最高位开始,所以先递归处理较高位,返回后再输出本层的低位:
void print_binary(unsigned long value)
{
if (value >= 2) {
print_binary(value / 2);
}
putchar(value % 2 == 0 ? '0' : '1');
}
调用 print_binary(13) 输出 1101;传入 0 也会输出一个 0。函数本身不输出换行,由调用者决定。
13 → 6 → 3 → 1
返回时依次输出:1、1、0、1
这个算法体现了递归调用之前与之后的工作顺序。如果改成先输出本层余数再递归,得到的就会是相反方向的位序列。
8.2 多分支递归可能重复计算
按数学定义直接写斐波那契递归:F(n) = F(n - 1) + F(n - 2),会多次计算相同子问题。
例如,求 F(6) 时,F(4) 既会由 F(5) 再次求出,也会由外层另一条分支直接求出。
需要区分两个量:
- 朴素实现的总调用次数和总工作量增长很快,呈指数级。
- 同时存活的最长递归调用链深度通常随
n线性增长,并不是把整棵调用树的所有节点同时放在栈里。
循环保存前两项,或者保存已算出的子问题结果,可以减少重复计算。选择递归不能只看代码短,还要分析总工作量、最大深度和数值范围。
9. 多源文件与头文件
9.1 接口与实现分别保存
一个简单模块可以分为三个文件:
main.c 主流程,调用模块
number_ops.h 公共常量、函数声明
number_ops.c 函数实现
下面用一个逐项按固定比例递减的累计计算演示组织方式。参数约定为非负首项、非负项数,并满足所需数值范围。
number_ops.h:
#ifndef NUMBER_OPS_H
#define NUMBER_OPS_H
#define DECAY_FACTOR 0.9
double decaying_total(double first_amount, int terms);
#endif
外层条件指令构成头文件保护,避免同一个头文件被重复包含时反复处理内容。
number_ops.c:
#include "number_ops.h"
double decaying_total(double first_amount, int terms)
{
double amount = first_amount;
double total = 0.0;
for (int index = 0; index < terms; ++index) {
total += amount;
amount *= DECAY_FACTOR;
}
return total;
}
main.c:
#include <stdio.h>
#include "number_ops.h"
int main(void)
{
printf("total: %.2f\n", decaying_total(100.0, 3));
return 0;
}
输出 total: 271.00,对应 100 + 90 + 81。
9.2 为什么实现文件也包含自己的头文件
调用方需要声明来检查调用;实现文件也包含公共头文件,能让编译器发现“接口写一种类型,实现却写另一种类型”的问题。
不要在多个源文件中各自手写一套可能逐渐不一致的原型。公共常量也可以集中管理,避免只修改了一部分使用者。
#include "number_ops.h" 的具体查找过程受实现和构建选项影响。把项目头文件放在合适的源码目录或配置包含路径,比假定运行时当前目录决定头文件查找更准确。
9.3 头文件不等于实现文件
这个模块把原型放在 .h,把普通函数定义放在 .c。包含头文件,不会自动把对应 .c 文件加入构建。
也不要靠在 main.c 中直接包含 number_ops.c 来替代正常的多文件编译,否则容易与其他构建配置重复定义同一函数。
9.4 编译与链接
在已有 cc 的 macOS 或类 UNIX 环境中,可以一次构建两个源文件:
cc -std=c11 -Wall -Wextra main.c number_ops.c -o demo
./demo
也可以分开编译,再链接:
cc -std=c11 -Wall -Wextra -c main.c -o main.o
cc -std=c11 -Wall -Wextra -c number_ops.c -o number_ops.o
cc main.o number_ops.o -o demo
-c 生成目标文件后停止。普通的一次性编译链接命令,不保证把中间 .o 留在当前目录。
只改实现文件时,可以重新编译受影响的模块再链接;若修改公共头文件,所有依赖该头文件的源文件都可能需要重新编译。
IDE 中的对应操作是把相关源文件纳入同一目标,并配置头文件查找。文件出现在项目目录里,不等于它已经被当前目标编译。
10. 地址:对象位置与对象值不同
10.1 一元 & 取得对象地址
int value = 21;
printf("value: %d\n", value);
printf("address: %p\n", (void *)&value);
value 在这里提供保存的整数,&value 则取得这个对象的地址,类型为 int *。
普通对象地址使用 %p 输出时,应传入 void *,所以示例显式转换为 (void *)。
地址的显示形式与实现有关,可能每次运行都不同。不能根据打印出多少个十六进制字符,直接推断指针对象占多少字节。
10.2 同名变量可以有不同地址
调用方的 value 与另一个函数的形参 value,即使名字相同、初始数值也相同,仍是不同对象。
观察地址有助于理解这一点,但不应根据某次地址排列推导所有局部对象的固定顺序或相邻关系。
11. 指针变量与解引用
11.1 指针保存指向对象的信息
int value = 21;
int *pointer = &value;
可以这样理解:
pointer 对象
保存 value 的地址 ─────→ value 对象
保存整数 21
| 表达式 | 表示什么 |
|---|---|
value |
整数对象中的值 |
&value |
整数对象的地址,类型为 int * |
pointer |
指针对象中保存的地址值 |
*pointer |
通过指针定位到的整数对象 |
&pointer |
指针变量自身的地址,不是 value 的地址 |
指针本身也有类型、值和存储位置,不能把它与指向的对象混为一谈。
11.2 声明中的 * 与表达式中的 *
int *pointer = &value;
int copy = *pointer;
第一行的 * 参与声明,说明 pointer 是指针;第二行的 * 是一元解引用运算符,用指针定位对象并读取其值。
它们又与乘法表达式 left * right 中的二元 * 不同。
11.3 通过指针读写目标对象
int value = 21;
int *pointer = &value;
*pointer = 30;
printf("%d\n", value);
输出 30。赋值左侧的 *pointer 指定的是 value 这个可修改对象,而不是把指针变量本身改成整数三十。
如果另有一个有效整数对象 other,执行 pointer = &other; 改变的是指针所指的位置,不会自动修改 value 或复制两个对象的内容。
11.4 类型与声明细节
int *first, second;
这里 first 是 int *,second 只是 int。要声明两个指针,应写 int *first, *second;,或者分别声明。
指针类型说明如何访问目标对象。不能因为 int 与 float 在某个环境中一样大,就把它们的地址当作同一类读写接口。
指针不是普通整数类型,也不是任意数值都可以当成可用地址。它的大小和目标对象的大小是不同问题。
11.5 解引用的基本前提
指针必须已经取得有效值,并指向仍然存在、类型和访问方式都合适的对象。写入还要求目标确实可修改。
未初始化指针、空指针以及已经失效对象的地址,都不能被当作正常目标解引用。仅仅检查不等于 NULL,也不足以证明一个任意指针一定有效。
不要返回普通自动局部对象的地址,让调用方在对象生存期结束后继续使用。返回局部对象的值与返回它的地址,是两回事。
12. 交换变量:传值为什么失败,传地址为什么有效
12.1 只交换形参副本
void swap_copies(int first, int second)
{
int temporary = first;
first = second;
second = temporary;
}
这个函数即使在内部完成交换,也只改变自己的两个形参对象。调用 swap_copies(x, y),不会交换调用方的 x、y。
单纯把局部变量命名成与调用方一样,也不能改变这个结论。
12.2 把目标地址传进去
#include <stdio.h>
void swap_int(int *first, int *second);
int main(void)
{
int x = 4;
int y = 9;
printf("before: %d %d\n", x, y);
swap_int(&x, &y);
printf("after: %d %d\n", x, y);
return 0;
}
void swap_int(int *first, int *second)
{
int temporary = *first;
*first = *second;
*second = temporary;
}
输出:
before: 4 9
after: 9 4
12.3 跟踪每一步修改了哪里
调用期间,first 保存 &x,second 保存 &y:
| 步骤 | x |
y |
temporary |
|---|---|---|---|
| 初始 | 4 |
9 |
尚未初始化 |
temporary = *first |
4 |
9 |
4 |
*first = *second |
9 |
9 |
4 |
*second = temporary |
9 |
4 |
4 |
临时变量保存最初的值,避免第一次覆盖后丢失数据。
12.4 传指针仍然是值传递
C 没有因此切换成另一种参数传递规则。这里复制给形参的值,恰好是地址。
指针形参是函数自己的对象,但它指向调用方提供的对象,所以通过 *first 可以修改调用方的数据。
如果只在函数中给形参指针重新赋地址,改变的是形参自身,并不会自动改变调用方保存的指针变量。
scanf("%d", &number) 也使用相同思路:传入地址,让函数把结果写到这个位置。
13. 一个返回状态、通过指针交回多个结果的接口
如果函数需要报告成功与否,同时给出商和余数,可以把状态作为返回值,把结果写入调用方对象:
#include <stdbool.h>
#include <stddef.h>
bool divide_nonnegative(int value, int divisor,
int *quotient, int *remainder)
{
if (value < 0 || divisor <= 0 ||
quotient == NULL || remainder == NULL ||
quotient == remainder) {
return false;
}
*quotient = value / divisor;
*remainder = value % divisor;
return true;
}
这个函数约定两个输出指针指向有效、可修改的 int 对象,并且两者不同。检查空指针不能替代调用方满足这个有效性约定。
int quotient = 0;
int remainder = 0;
if (divide_nonnegative(17, 5, "ient, &remainder)) {
printf("%d %d\n", quotient, remainder);
}
输出 3 2。参数不满足函数中检查的条件时,返回 false,不会先写入部分结果。
这种“返回状态 + 输出参数”的形式,也能解决第八章输入函数难以用单个整数同时表达所有有效数值与失败状态的问题。
return 直接交回一个结果,并不代表函数永远只能提供一个数据项。输出参数是一个方法;后续还可以使用包含多个字段的结构体作为返回结果。
14. 在接口上区分三种需求
| 需求 | 常见接口形式 | 对调用方数据的影响 |
|---|---|---|
| 根据数据计算一个值 | 普通参数 + 返回值 | 通常不必修改原对象 |
| 根据数据执行动作 | 普通参数 + void 返回类型 |
例如输出文字 |
| 修改调用方提供的对象 | 合适的指针参数 | 通过解引用修改目标 |
能只传值完成的计算,不必为了“用了指针”而增加间接访问;需要修改对象时,也应在接口和命名中让这种行为容易看出来。
多文件组织、递归与指针,都建立在同一套函数规则上。把输入、返回值、对象生存期和修改范围交代清楚,调用者才能稳定地使用函数,而不依赖内部实现的偶然细节。

