K&R 全书契约式总复习
以第一章教程导论闭环全书:main、温度表、字符 I/O、数组与函数、作用域,并串联类型、指针、结构体和系统边界。
学习目标
- 能复现第一章从
main、温度表、for、字符 I/O 到数组与函数的教程链,并解释每步新增的契约 - 能分析类型、控制流、函数、指针、结构体、标准 I/O 与 UNIX 接口之间的依赖和可移植边界
- 能设计一个带输入边界、状态不变量、错误传播和资源清理的小型 C 程序,并用多层证据验证
机制总览
K&R 全书契约式总复习:机制路径
- 1
从第一章的可运行反馈环开始
K&R 的第一章 A Tutorial Introduction 不是缩小版参考手册,而是
- 2
变量与算术表达式:先确定计算域
define LOWER 0 define UPPER 300 define STEP 20
- 3
for 语句:用不变量解释循环
数组循环常用 0 <= index && index < count 作为范围不变量。修改循环条件或步进时要同时检查空数组、单元素、最大边界和溢出。
章级决策实验
K&R 全书契约式总复习:机制与证据
切换《K&R 全书契约式总复习》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 从第一章的可运行反馈环开始
K&R 的第一章 A Tutorial Introduction 不是缩小版参考手册,而是
可核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从第一章的可运行反馈环开始」的实际契约。
学完《K&R 全书契约式总复习》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
K&R 全书契约式总复习:失效与核验
从第一章的可运行反馈环开始
典型失效
若把「从第一章的可运行反馈环开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从第一章的可运行反馈环开始」的实际契约。
变量与算术表达式:先确定计算域
典型失效
若把「变量与算术表达式:先确定计算域」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「变量与算术表达式:先确定计算域」的实际契约。
for 语句:用不变量解释循环
典型失效
若把「for 语句:用不变量解释循环」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「for 语句:用不变量解释循环」的实际契约。
从第一章的可运行反馈环开始
K&R 的第一章 A Tutorial Introduction 不是缩小版参考手册,而是
↡从最小可运行程序出发,通过编译、运行和观察逐步引入变量、控制流、函数、数组与作用域的学习过程。 路线。每加入一个语法点,就用可观察结果验证;后续七章再把第一章略过的类型规则、生命周期和系统边界展开。
最小 hosted C 程序把源文件、编译器、链接器、运行时和宿主环境连起来。标准入口之一是
↡hosted C 程序的标准入口函数形式之一;int main(void) 不接收命令行参数,返回状态交给宿主环境。。
printf 的声明来自头文件,字符串中的 \n
是一个换行转义字符,而不是两个普通字符。
#include <stdio.h>
int main(void)
{
if (printf("hello, world\n") < 0)
return 1;
return 0;
}返回 0 表示成功,非零表示失败;宿主如何显示状态属于环境约定。printf 可能失败,所以最终程序不能把“调用过输出函数”等同于“输出成功”。这个小例子已经包含声明、函数调用、字符串、返回值和错误策略。
变量与算术表达式:先确定计算域
原书用华氏温度与摄氏温度表引入
↡给值命名并按运算符组合计算的基础机制;结果受操作数类型、整数提升、转换和溢出规则约束。。 公式相同不代表 C 表达式结果相同:整数除法截断,浮点运算按浮点类型执行,格式化输出还要匹配实参类型。
#include <stdio.h>
#define LOWER 0
#define UPPER 300
#define STEP 20
int main(void)
{
int fahrenheit;
double celsius;
for (fahrenheit = LOWER; fahrenheit <= UPPER; fahrenheit += STEP) {
celsius = (5.0 / 9.0) * (fahrenheit - 32);
printf("%3d %6.1f\n", fahrenheit, celsius);
}
return 0;
}这里用 5.0 / 9.0 明确浮点计算;若写 5 / 9,整数结果为 0,之后再转 double 已无法恢复。循环头清楚写出初值、继续条件和步进。声明放在块开头,使示例保持在 ANSI C90 边界;现代 C 虽允许声明与语句交错,计算类型规则并未因此改变。
LOWER、UPPER 和 STEP 是
↡用宏或枚举为程序参数命名的常量概念,使公式表达意图并集中修改;预处理宏本身没有 C 类型。。 宏替换不做类型检查,也没有作用域对象;简单整数集合可考虑枚举,需要地址或明确类型时可用适当对象。不要把“全大写”当成语言规则,它只是常见命名约定。
for 语句:用不变量解释循环
for 语句适合把初始化、继续条件和推进放在一起,但正确性来自
↡每次循环体开始前都成立,并在一次迭代后仍被保持的事实;结合退出条件可证明范围和结果。。
温度表中,不变量包括 fahrenheit 始终位于当前待输出刻度并按 STEP
推进;还要确认加法不会在到达上界前溢出。对不可信上界,不能只凭“每次增加”就断言终止。
数组循环常用 0 <= index && index < count 作为范围不变量。修改循环条件或步进时要同时检查空数组、单元素、最大边界和溢出。break、continue 或函数返回会改变控制流证明,应明确它们离开时哪些资源已取得、哪些结果仍有效。
先预测:把整数温度公式改为
celsius = 5 / 9 * (fahrenheit - 32)会得到什么?5 / 9在整数域先截断为 0,所以所有结果为 0;赋给浮点变量不会让之前的计算重做。
字符输入输出:EOF 不是字符
↡以 getchar 和 putchar 等接口逐个读取或写出字符的标准 I/O 模式;读取结果用 int 同时表示 unsigned char 值与 EOF。
把流循环压缩成一个清晰协议:先调用读取函数,检查返回值,再处理字符。EOF
是负的整数常量,用来表示没有字符可返回;它不是文件中存储的一枚特殊字节。
#include <stdio.h>
int main(void)
{
int character;
while ((character = getchar()) != EOF)
if (putchar(character) == EOF)
return 1;
if (ferror(stdin))
return 1;
return 0;
}必须用 int 保存 getchar 结果;先缩窄到 char 可能把一个合法字节与 EOF 混淆。循环结束后检查 ferror(stdin),因为 EOF 返回既可能表示正常文件尾,也可能表示读取错误。赋值外的括号不是装饰:!= 的优先级高于赋值,去掉会把比较结果 0/1 赋给变量。
数组、函数与值传递
字符计数程序用数组把“每个数字出现次数”映射为十个槽位。数组下标必须先证明处于范围;从字符 '0' 到 '9' 的连续性由 C 字符集规则保证,因此 character - '0' 可得 0 到 9。
#include <stddef.h>
#include <stdio.h>
int print_counts(const unsigned long counts[], size_t count)
{
size_t index;
for (index = 0; index < count; ++index)
if (printf("%lu: %lu\n", (unsigned long)index, counts[index]) < 0)
return 0;
return 1;
}
int main(void)
{
unsigned long digits[10] = { 0 };
int character;
while ((character = getchar()) != EOF)
if (character >= '0' && character <= '9')
++digits[character - '0'];
if (ferror(stdin))
return 1;
return print_counts(digits, sizeof digits / sizeof digits[0]) ? 0 : 1;
}C 的
↡函数形参接收实参值的副本;传入指针时复制的是地址值,函数可通过副本访问所指对象,但不能改变调用者指针变量本身。
适用于所有普通参数。数组形参会调整为指针,所以 print_counts
内部无法从形参恢复数组元素数,必须另传
count。这并不是“数组按引用传递”;调用者数组未被复制,但形参仍是一个指针值副本。
字符数组与完整记录
↡元素为 char 的数组;只有在可达范围内存在结尾空字符时,才满足 C 字符串协议。 可以保存文本,也可以只保存原始字符。K&R 的最长行示例把输入、长度计算和最大值更新拆成函数,揭示了容量契约:读入函数必须知道数组上限,返回长度要说明是否包含换行,以及长行是截断、分段还是失败。
#include <stdio.h>
enum LineResult {
LINE_END = 0,
LINE_COMPLETE = 1,
LINE_TOO_LONG = 2,
LINE_ERROR = -1
};
int read_line(char line[], int capacity, int *length)
{
int character;
int used = 0;
int too_long = 0;
while ((character = getchar()) != EOF && character != '\n') {
if (used + 1 < capacity)
line[used++] = (char)character;
else
too_long = 1;
}
line[used] = '\0';
*length = used;
if (character == '\n')
return too_long ? LINE_TOO_LONG : LINE_COMPLETE;
if (ferror(stdin))
return LINE_ERROR;
if (used != 0 || too_long)
return too_long ? LINE_TOO_LONG : LINE_COMPLETE;
return LINE_END;
}接口前置条件是 capacity > 0 且两个指针有效。容量耗尽后,函数继续消费到换行或文件尾,因此不会把同一逻辑行误当成下一条;LINE_TOO_LONG 告诉调用者缓冲内容只是前缀。末行没有换行但含字符时仍返回完整记录,只有文件尾且本次未读字符才返回 LINE_END。字符数组容量永远包括结尾空字符位置,拷贝和拼接也要先检查目标剩余空间。
外部变量与作用域:声明不等于定义
↡在函数外定义、具有静态存储期的对象;可通过外部链接跨翻译单元共享,也可用 static 限制为当前翻译单元内部。
帮助第一章把多个函数共享的状态连起来,但也扩大隐藏依赖。一个具有外部链接的对象只能有一个定义,其他翻译单元通过
extern 声明引用;头文件通常放声明,某个 .c 文件放定义。
↡标识符在源程序中可见的区域规则,包括块作用域、函数作用域、函数原型作用域和文件作用域;它不等同于对象生命周期或链接属性。
只回答名字在哪里可见。存储期回答对象存在多久,链接回答不同声明是否指同一实体。块内
static 对象具有静态存储期,却只有块作用域;文件作用域 static
名字具有内部链接,不会暴露给其他翻译单元。
/* counter.h */
extern unsigned long error_count;
void note_error(void);
/* counter.c */
unsigned long error_count = 0;
static unsigned long call_count = 0;
void note_error(void)
{
++call_count;
++error_count;
}全局可变状态让测试、并发和重用更难;优先把状态放进结构体并显式传入。确需跨文件共享时,通过函数维护不变量,并把不应公开的名字设为内部链接。头文件保护、兼容函数原型和单一定义共同构成模块边界。
用六层契约串联全书
第 2 章决定类型表示、转换、运算顺序;第 3 章用控制流组织状态变化;第 4 章建立函数、声明、链接、预处理和递归;第 5 章把指针、数组、字符串与回调连起来;第 6 章用结构、自引用节点、联合和 typedef 建模;第 7 章建立标准流和格式契约;第 8 章展示 UNIX 描述符、目录与分配器边界。
读一个表达式时先问值域与求值规则;读一个循环时问不变量和退出条件;读函数时问前置条件、返回状态和修改范围;读指针时问对象、边界、对齐与生命周期;读结构体时问整体不变量和资源复制;读 I/O 时问外部格式、部分进度和提交失败。
↡让代码只依赖目标标准或平台明确承诺的行为,并把实现定义、未指定和平台扩展隔离在有文档与测试的模块内。 要求把 ISO C、具体 C 版本、POSIX 和编译器扩展分开。K&R 第二版以 ANSI C 为背景;后来的 C99、C11 等增加了语法和库能力,使用时应声明项目基线,而不是反向改写原书规则。
综合练习:文本统计器的资源所有权
一个可靠的小项目可以读取文件,统计行数、字符数和最长行,再输出报告。先写状态结构和接口:打开失败不产生统计;每次读取只在成功后更新;超长行按分片或拒绝策略处理;输出和关闭失败向调用者传播。
↡明确每个动态对象、流或描述符由哪个模块负责最终释放,以及借用者可使用到何时的协议。
可用单一清理出口表达,但 goto cleanup
不是自动安全:只有已经成功取得的资源才能释放,错误码要在清理前保存,关闭失败还可能升级最终结果。
#include <stdio.h>
int count_file(const char *path)
{
FILE *stream = NULL;
char buffer[256];
unsigned long chunks = 0;
int result = 0;
stream = fopen(path, "r");
if (stream == NULL)
goto cleanup;
while (fgets(buffer, sizeof buffer, stream) != NULL)
++chunks;
if (ferror(stream))
goto cleanup;
if (printf("chunks=%lu\n", chunks) < 0)
goto cleanup;
result = 1;
cleanup:
if (stream != NULL && fclose(stream) == EOF)
result = 0;
return result;
}这里统计的是缓冲片段,不一定是逻辑行,名字刻意叫 chunks。若需求是行数,必须识别换行和无末尾换行;这体现“变量名、接口和实现必须讲同一个协议”。更完整版本还会把输出流传入,避免库函数硬编码 stdout,并返回可诊断的错误类别。
验证不是只运行一次
编译器警告可以发现缺失原型、格式不匹配、可疑转换和未使用结果;单元测试检查纯函数,集成测试模拟文件尾、长行、短写与关闭失败;AddressSanitizer、UndefinedBehaviorSanitizer 等实现工具可扩大动态观测。它们只覆盖被执行路径,不能证明所有输入都安全。
先预测结果与失败状态,再运行测试。对边界代码,既检查返回值,也检查输出内容、流状态和资源是否释放。对平台代码,在支持矩阵上实际编译运行;对实现定义行为,用编译器文档和静态断言锁定目标,而不是从一次实验外推所有系统。
小结
- 第一章从
main和 hello world 建立反馈,再用温度表、for、符号常量、字符 I/O、数组与函数逐步扩展 - 变量与表达式必须先确定计算类型;循环由不变量、范围和退出条件证明,而不是靠样例直觉
getchar返回int以容纳所有字符值和EOF;数组形参调整为指针,元素数必须另传- 字符数组以空字符满足字符串协议,但仍要另行判断记录是否完整;外部变量、作用域、存储期和链接是不同维度
- 全书可以按表示、控制、接口、内存、数据模型和系统边界六层检查契约
- 小型 C 项目也要明确资源所有权、部分 I/O、错误传播和关闭结果,并用多层证据验证
练习
问题 1 从华氏温度表解释 5 / 9 与 5.0 / 9.0 的差异,并为 for 循环写出一个范围不变量和一个终止风险。
问题 2 设计一个读取任意长度逻辑行的接口,说明 EOF、末行无换行、缓冲分片、分配失败和空字符终止如何分别表达。
问题 3 对“打开输入、生成统计、写输出”的程序列出六层契约中各一项检查,并说明动态检测为何不能替代它们。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 入门
从最小可运行程序开始,通过编译、执行和观察逐步引入变量、控制流、函数、数组与作用域的学习过程。
- main 函数
hosted C 程序的标准入口,返回状态交给宿主环境;
int main(void)是不接收命令行参数的形式之一。- 变量与算术表达式
用有类型的名字保存值并组合运算;结果受整数提升、通常算术转换、溢出和求值规则约束。
- 符号常量
为程序参数命名的常量概念,可由宏或枚举等表达;宏替换本身没有 C 类型和对象作用域。
- 循环不变量
每次迭代前成立且被循环体保持的事实,与退出条件一起用于证明范围、结果和终止。
- 字符输入输出
逐字符读取和写出的流协议,读取结果使用
int区分全部unsigned char值与EOF。- 值传递
函数形参取得实参值副本;指针参数复制地址值,数组形参则调整为指针并需要独立长度契约。
- 字符数组
由
char元素组成的数组,只有在边界内含结尾空字符时才是 C 字符串。- 外部变量
函数外定义的静态存储期对象,可具有外部链接或以
static限制为翻译单元内部。- 作用域
标识符在源程序中可见的区域规则,不等于对象存储期,也不等于跨翻译单元链接属性。
- 可移植边界
只依赖目标标准或平台承诺,并把实现定义行为和扩展隔离在有文档、有测试的适配模块内。
- 资源所有权
规定对象或 I/O 资源由谁最终释放、借用持续多久以及失败路径怎样移交或回滚的协议。