函数、参数、返回值与递归
本节目标
查询 C23 函数声明、参数列表、按值传递、返回值、递归与可变参数的核心规则。
本章用于查询 C23 中函数接口与调用的核心规则。阅读函数时,先核对调用点可见的声明,再核对参数和返回类型,最后检查每条控制路径是否按接口约定结束。
函数声明、原型与定义
函数声明告诉编译器函数名、返回类型和参数类型;函数定义还提供函数体。同一个函数可以先声明后定义,但各处声明的函数类型必须兼容。
static int is_zero(int value); // 声明
static int is_zero(int value) { // 定义
return value == 0;
}
调用发生前,兼容的函数声明必须在该位置可见。通常把供多个源文件使用的声明放进头文件;本章的单文件示例则把声明放在 main 之前,把定义放在调用点之前或之后都可以。C23 中每个函数声明都提供原型,编译器会据此检查实参数量并完成规定的类型转换。
C23 参数列表
在 C23 中,函数声明里的空参数列表表示函数没有参数,就这一用途而言与 (void) 一致。也就是说,int ready(); 与 int ready(void); 都声明无参数函数,而不是“参数信息未知”的函数。这个变更由 WG14 的 N2841:No function declarators without prototypes 提出并进入 C23。
int ready(); // C23:没有参数
int ready(void); // 同样明确表示没有参数
int scale(int n); // 一个 int 参数
历史兼容说明(C17 及更早版本): 在旧标准的普通函数声明中,
int ready();表示没有提供参数数量和类型信息,而不是明确声明无参数;int ready(void);才明确表示没有参数。这是旧标准规则,不能反过来解释 C23。维护以旧标准编译的代码时,继续写(void)可避免版本歧义。
普通函数声明可以省略形参名,但当后续形参声明符需要引用该名字时不能省略。例如,不依赖形参名的声明可写成 int scale(int);。后续专章提示:“数组与字符串”将讲解数组参数的调整规则。
调用、实参与按值传递
调用表达式中的值是实参;函数声明和定义中的对象是形参。准备调用时,每个形参对象都由对应实参的值初始化,所以形参随后是该值在本次调用中的独立副本。
C 的实参按值传递,不另提供“按引用传递”这一独立的参数传递模式。修改下面的 value 不会修改调用方用于计算实参的对象:
static int incremented(int value) {
value += 1;
return value;
}
需要让函数访问或修改调用方对象时,C 代码会把指针值作为实参传入;这仍然是按值传递。后续专章提示:“指针、指针运算与回调”将讲解指针修改、函数指针与回调。
返回值与 void 函数
在返回非 void 的函数里,return expression; 结束本次调用,并把表达式的值转换为函数的返回类型;这种转换遵循赋值转换的规则。每条可能完成正常执行的路径都应返回符合接口含义的值。
返回 void 的函数不产生供调用方使用的值,可以写 return; 提前结束,也可以执行到函数体末尾。不要在 void 函数中用带表达式的 return 冒充结果。
返回类型与 int 兼容的 main 是一个特例:初始调用执行到结尾,效果相当于返回 0。其他返回非 void 的函数若执行到结尾,并且调用方使用了该次调用的返回值,则行为未定义;不要把 main 的隐式零返回推广到普通函数。
局部状态、static 函数与接口边界
函数体内普通局部对象通常具有自动存储期:每次进入相应块都会得到一次新的对象生存期,离开块后生存期结束。函数体内的 static 局部对象具有静态存储期,会在多次调用之间保留状态;这种隐藏状态会影响可测试性和并发使用,只有接口确实需要记忆状态时才使用。
本章示例把辅助函数声明为文件作用域的 static 函数:
static int clamp(int value, int low, int high);
这里的 static 让函数具有内部链接,把名字限制在当前翻译单元内,适合作为 .c 文件的实现细节。后续专章提示:“作用域、链接与存储期”将讲解作用域、链接和存储期的完整组合。
递归、基例与终止条件
递归函数直接或间接调用自身。递归既需要基例,也需要每一步都向基例推进。缺少任一条件都可能让调用不断加深,直到耗尽实现可用的调用深度或其他资源。
示例中的欧几里得算法以 right == 0u 为基例;否则把问题缩小为 gcd_recursive(right, left % right)。余数小于非零除数,因此每一步都会逼近基例。
clamp 的前置条件是 low <= high。调用方满足前置条件后,它分别处理低于区间、位于区间和高于区间的输入;函数没有擅自猜测反向边界应如何解释。
static unsigned gcd_recursive(unsigned left, unsigned right) {
if (right == 0u) {
return left;
}
return gcd_recursive(right, left % right);
}
递归是否合适还取决于最大深度是否可控。C 不保证把尾递归优化为循环;深度可能随输入线性增长时,应考虑显式循环或给出可验证的深度上界。
#include <stdio.h>
static int clamp(int value, int low, int high);
static unsigned gcd_recursive(unsigned left, unsigned right);
static int clamp(int value, int low, int high) {
/* Precondition: low <= high. */
if (value < low) {
return low;
}
if (value <= high) {
return value;
}
return high;
}
static unsigned gcd_recursive(unsigned left, unsigned right) {
if (right == 0u) {
return left;
}
return gcd_recursive(right, left % right);
}
int main(void) {
printf("clamp-low=%d\n", clamp(-3, 0, 10));
printf("clamp-mid=%d\n", clamp(7, 0, 10));
printf("clamp-high=%d\n", clamp(14, 0, 10));
printf("gcd-normal=%u\n", gcd_recursive(48u, 18u));
printf("gcd-zero=%u\n", gcd_recursive(9u, 0u));
return 0;
}
输出:
clamp-low=0
clamp-mid=7
clamp-high=10
gcd-normal=6
gcd-zero=9
可变参数函数
参数列表末尾的 ... 表示还有数量和类型未写入函数类型的尾随实参。可变参数不会自我描述,调用方和被调用方必须遵守外部约定的类型与数量契约。常见契约由显式的计数参数或格式字符串承载。
下面的接口用 count 约定后面有多少个经默认实参提升后与 int 兼容的值。调用方还必须保证每次 total + next 的中间结果都可由 int 表示,避免有符号整数溢出。
#include <stdarg.h>
#include <stddef.h>
int sum(size_t count, ...) {
va_list arguments;
int total = 0;
va_start(arguments, count);
for (size_t index = 0; index < count; ++index) {
total += va_arg(arguments, int);
}
va_end(arguments);
return total;
}
可移植协议是:声明 va_list,用 va_start 初始化,按外部契约依次用 va_arg 读取,最后在离开函数前配对调用 va_end。每次 va_start 或 va_copy 初始化都必须在同一函数内配对调用 va_end。尾随实参会经历默认实参提升,例如 float 提升为 double,较窄整数类型经历整数提升;除标准列出的兼容例外外,va_arg 指定的类型若与经默认实参提升后的实际类型不兼容,行为未定义;读取超过实际数量同样如此。
常见陷阱
| 陷阱 | 规则或替代做法 |
|---|---|
在 C23 中仍把 f() 解释为参数未知 | C23 的空参数列表表示无参数;旧代码兼容性另行判断 |
| 调用点之前没有可见声明 | 在调用前提供兼容声明,跨源文件接口放入头文件 |
| 认为修改形参会自动修改实参对象 | C 按值传递;形参由实参值初始化,是本次调用的独立对象 |
普通非 void 函数遗漏返回路径 | 确保所有正常完成路径返回;不要依赖 main 的特例 |
把无效区间交给 clamp 猜测 | 在调用前满足 low <= high 的前置条件 |
| 递归只有自调用,没有基例或推进 | 同时证明基例可达,并评估最大调用深度 |
| 读取可变参数时猜测类型或数量 | 用计数或格式契约,并让 va_arg 类型匹配提升后的实参类型 |
忘记 va_end | 每次 va_start 或 va_copy 初始化都在同一函数内配对结束 |
排查函数问题时,按“调用点可见声明 → 实参数量和转换 → 形参对象 → 返回路径 → 隐藏状态 → 终止与资源上界”逐项检查,通常能快速定位接口违约的位置。