函数与程序结构
对照 K&R 第四章掌握函数原型和值传递、返回契约、外部变量、作用域/链接/存储期、头文件、static/register、块结构、初始化、递归和 C 预处理器,并建立可链接的多文件模块。
学习目标
- 能实现带完整原型、状态返回和输出参数的 ANSI C 函数,并解释数组形参与值传递边界
- 能区分作用域、链接与存储期,组织一个头文件加两个源文件且通过编译/链接检查
- 能比较函数式宏、ANSI C 普通函数与 C99
static inline,说明各自的求值、类型和链接代价
机制总览
函数与程序结构:机制路径
- 1
从调用点能证明什么开始
函数不是“跳到另一段代码”的语法糖,而是一份跨调用边界的契约:调用点必须知道参数数量与类型、返回类型、哪些对象可能被间接修改、失败怎样表达。K&R 第二版最重要的 ANSI C 更新之一就是函数原型,它让编译器能在调用发生前检查定义与使用是否兼容。
- 2
原型、定义与返回类型
K&R 第一版时代的旧式定义与隐式 int 是历史语法;第二版讲 ANSI 原型,现代 C99 以后还删除了隐式函数声明。本站示例统一使用原型形式,不用“编译器猜返回类型”掩盖接口错误。
- 3
C 只有值传递
改变形参指针本身只改变副本: pointer = NULL 不会把调用者的指针置空。若接口要更新调用者保存的指针,需传“指向该指针对象的指针”,并明确新所有权与失败时旧值是否保留。
章级决策实验
函数与程序结构:机制与证据
切换《函数与程序结构》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 从调用点能证明什么开始
函数不是“跳到另一段代码”的语法糖,而是一份跨调用边界的契约:调用点必须知道参数数量与类型、返回类型、哪些对象可能被间接修改、失败怎样表达。K&R 第二版最重要的 ANSI C 更新之一就是函数原型,它让编译器能在调用发生前检查定义与使用是否兼容。
可核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从调用点能证明什么开始」的实际契约。
学完《函数与程序结构》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
函数与程序结构:失效与核验
从调用点能证明什么开始
典型失效
若把「从调用点能证明什么开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从调用点能证明什么开始」的实际契约。
原型、定义与返回类型
典型失效
若把「原型、定义与返回类型」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「原型、定义与返回类型」的实际契约。
C 只有值传递
典型失效
若把「C 只有值传递」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「C 只有值传递」的实际契约。
从调用点能证明什么开始
函数不是“跳到另一段代码”的语法糖,而是一份跨调用边界的契约:调用点必须知道参数数量与类型、返回类型、哪些对象可能被间接修改、失败怎样表达。K&R 第二版最重要的 ANSI C 更新之一就是函数原型,它让编译器能在调用发生前检查定义与使用是否兼容。
图中没有“参数从右到左压栈”或“栈向低地址增长”,因为 C 标准不保证这些 ABI 细节。参数可能进寄存器,函数可能被内联,优化后可能没有稳定帧指针。调试器展示的是某个编译器、目标 ABI 与优化级别的实现,不是语言语义。
原型、定义与返回类型
↡声明函数名、返回类型以及参数类型列表,使调用点能检查参数数量、转换与返回值使用是否与定义兼容。
应在调用前可见,通常来自模块头文件。double atof(const char *text);
同时声明非整数返回类型和只读输入约定;若调用点错误地假定返回
int,寄存器与值解释都可能不兼容。
K&R 第一版时代的旧式定义与隐式 int 是历史语法;第二版讲 ANSI 原型,现代 C99 以后还删除了隐式函数声明。本站示例统一使用原型形式,不用“编译器猜返回类型”掩盖接口错误。
函数若可能失败,应避免用某个合法数据值兼作模糊错误。可以返回状态码,把多个结果写到调用者提供的对象:
int divide_int(int dividend, int divisor, int *quotient, int *remainder)
{
if (divisor == 0 || quotient == 0 || remainder == 0)
return 0;
*quotient = dividend / divisor;
*remainder = dividend % divisor;
return 1;
}调用者只在返回真后读取两个输出。真实接口还要考虑最小有符号值除以负一的溢出边界;对外 API 应把允许范围写入契约,而不是假设“除数非零就一定安全”。
C 只有值传递
↡函数调用把每个实参的值复制给对应形参。指针实参复制的是地址值,形参可通过该地址访问同一对象,但形参指针本身仍是独立副本。
解释了两件看似矛盾的事:修改 int 形参不影响调用者,修改 *pointer
却可能影响调用者对象。后者不是引用传递,而是两个指针值指向同一对象。
改变形参指针本身只改变副本:pointer = NULL 不会把调用者的指针置空。若接口要更新调用者保存的指针,需传“指向该指针对象的指针”,并明确新所有权与失败时旧值是否保留。
数组参数还有一层调整:形参写作 int values[10]、int values[] 或 int *values,在函数类型中都调整为指针,10 不会随调用传入。函数必须另收 size_t count,并规定指针在 count > 0 时有效、目标容量和是否允许重叠。
作用域、链接与存储期是三条轴
↡名字在源程序中可以被查找到的区域,例如块作用域、函数作用域、函数原型作用域与文件作用域。 回答名字在哪里可见; ↡多个声明是否指向同一个函数或对象。外部链接可跨翻译单元解析,内部链接只在当前翻译单元共享,无链接的名字不与其他声明合并。 回答不同声明是否代表同一实体;存储期回答对象生存期多长。三者相关但不等价。
文件作用域 static 函数/对象具有内部链接,只在当前翻译单元可用。块内 static 对象仍只有块作用域且无链接,但具有静态存储期,程序启动时初始化一次并贯穿执行。普通块内变量通常是自动存储期,每次进入声明所在块获得一个对象。
所有静态存储期对象在程序启动前先零初始化;未显式初始化的自动对象值不确定,读取它来计算结果可能触发未定义行为。register 在 ANSI C 中是自动存储期声明说明符和优化提示,并限制取地址;现代优化器通常自行分配寄存器,接口设计不应依赖该提示。
旧页常把“全局变量就是 extern”说得过于简单。文件作用域 int count; 是暂定定义,程序仍需要满足外部定义规则;头文件若在多个翻译单元放这种写法,可能产生多重定义或依赖旧工具链的 common 扩展。头文件应写 extern int count;,并在恰好一个 .c 文件提供 int count = 0;,更好的设计则优先用函数封装状态。
头文件与翻译单元
每个 .c 文件经过预处理后形成一个 ↡一个源文件及其递归包含的头文件经预处理后交给编译器的完整单位;各翻译单元分别检查,链接器再解析外部符号。。头文件是共享接口文本,不是单独编译的实现容器。它应包含函数原型、共享类型、必要宏和 extern 声明,并使用包含保护。
头文件 counter.h:
#ifndef COUNTER_H
#define COUNTER_H
void counter_reset(void);
void counter_add(unsigned long amount);
unsigned long counter_value(void);
#endif实现文件 counter.c 把状态设为文件私有:
#include "counter.h"
static unsigned long value;
void counter_reset(void) { value = 0; }
void counter_add(unsigned long amount) { value += amount; }
unsigned long counter_value(void) { return value; }调用文件只看公开接口:
#include "counter.h"
#include <stdio.h>
int main(void)
{
counter_reset();
counter_add(3);
printf("%lu\n", counter_value());
return 0;
}用 cc -std=c90 -Wall -Wextra -Wpedantic counter.c main.c -o counter 一次编译并链接。再分别编译为对象文件,可以区分编译期原型错误与链接期未定义/重复符号错误。
外部变量与接口状态
extern 声明表示该名字具有相应链接并在别处定义,它不是“从另一个文件复制变量”。函数声明通常默认具有外部链接,不需要重复写 extern。跨文件声明必须类型兼容;某个源文件把对象声明成 long,另一个定义成 int,链接器未必能诊断出 C 类型契约冲突。
外部可变状态让任何调用都可能改变结果,增加初始化顺序、重入、测试隔离和并发风险。优先把状态隐藏在模块实现后,通过函数维持不变量;测试提供明确 reset/setup,而不是让用例依赖前一个用例留下的值。
局部名字可以遮蔽外层名字。int x = x + 1; 在块内声明时,初始化器中的 x 已指向新声明的自动对象,而不是同名文件对象,读取不确定值会出错。开启 -Wshadow,并让局部名字表达角色而非复用短名。
递归需要基例、进展和深度界限
↡函数直接或间接调用自身,把问题缩小后交给下一层调用,并由基例停止展开。正确性需要基例、严格进展和资源深度分析。 适合树、分治和递归语法,但不是天然比循环清楚。必须证明参数每次更接近基例,并估计最坏深度;C 不保证尾调用优化,也不规定递归一定对应某种向低地址增长的硬件栈。
下面用返回状态避免阶乘溢出被静默接受:
#include <limits.h>
int factorial(unsigned int n, unsigned long *result)
{
unsigned long previous;
if (result == 0)
return 0;
if (n < 2) {
*result = 1;
return 1;
}
if (!factorial(n - 1, &previous) || previous > ULONG_MAX / n)
return 0;
*result = previous * n;
return 1;
}基例处理 0 和 1;n - 1 保证进展;乘法前用除法边界避免无符号回绕。对不可信大输入仍应限制 n 或改迭代实现,避免调用深度耗尽实现资源。
预处理器只操作记号
C ↡在正式语法和类型检查前处理 include、宏展开与条件编译的翻译阶段。它操作预处理记号,不理解 C 对象类型或运行时值。 负责 #include、#define、#if/#ifdef 等。对象式宏适合编译期开关、字符串化/记号粘贴等记号任务;函数式宏可能重复求值实参,也没有函数调用那样的参数类型契约。
#define MAX_BAD(a, b) ((a) > (b) ? (a) : (b))
/* MAX_BAD(i++, j++) 会先比较两个实参,再次求值被选中的那个。 */为参数和整体加括号只能修复分组,不能修复多次求值。ANSI C90 没有 inline;在 C99 及之后,头文件内的私有小助手可用 static inline,获得类型检查与每个实参一次求值。公开的 inline 与 extern inline 定义规则更复杂,应遵循项目统一模式,不把 C++ 经验直接套入 C。
条件编译会产生多个实际程序。每个受支持宏组合都要构建与测试,公开结构布局和函数声明不能在调用者与实现之间悄悄分叉。遇到错误时先判断是宏展开、C 类型检查还是外部符号解析,必要时查看预处理输出而不是只读原源码。
先预测:
MAX_BAD(i++, j++)至少会修改哪个变量两次?答案取决于第一次比较选择了哪个分支,这正说明宏把副作用次数交给了运行数据。
小结
- 函数原型让调用点检查参数和返回类型;C 参数始终值传递,数组形参调整为指针
- 作用域、链接、存储期分别回答名字可见性、实体身份和对象生存期,不能混用
- 头文件放共享声明与类型,外部定义放一个源文件,文件私有实现使用
static - 递归要证明基例、进展、深度和算术边界,C 不保证具体栈帧或尾调用优化
- 预处理器操作记号;宏适合记号任务,C99
static inline适合有类型的私有小函数
练习
问题 1 void set_null(int *p) { p = 0; } 为什么不能把调用者指针置空?若接口确实需要更新调用者指针,应怎样设计?
问题 2 解释 counter.h、counter.c、main.c 中声明、定义、内部链接和外部链接各在哪里,并说明把 static unsigned long value; 放进头文件会发生什么。
问题 3 比较 #define SQUARE(x) ((x) * (x))、ANSI C90 普通函数和 C99 static inline。对 SQUARE(i++) 各有什么行为与诊断能力?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 函数原型
声明函数返回类型与参数类型列表,使调用点能检查数量、转换和返回值使用。定义与所有可见声明必须兼容。
- 值传递
调用把实参值复制到形参。指针形参复制地址值,可通过地址访问同一对象,但修改形参指针本身不改变调用者指针。
- 作用域
名字在源程序中可被查找的区域,包括块、函数、函数原型和文件作用域;它不等于对象生存期或跨文件实体身份。
- 链接
多个声明是否指向同一函数或对象。外部链接可跨翻译单元解析,内部链接限制在当前翻译单元,无链接名字不与其他声明合并。
- 翻译单元
一个源文件及递归包含内容经预处理后交给编译器的单位。各单元分别检查,链接器再解析外部符号。
- 递归
函数直接或间接调用自身。正确实现需要停止展开的基例、每次调用的严格进展和可接受的最坏深度。
- 预处理器
在 C 语法与类型检查前处理包含、宏展开和条件编译的阶段。它操作记号,不理解运行时对象和类型契约。