K&R 全书契约式总复习

以第一章教程导论闭环全书:main、温度表、字符 I/O、数组与函数、作用域,并串联类型、指针、结构体和系统边界。

学习目标

  • 能复现第一章从 main、温度表、for、字符 I/O 到数组与函数的教程链,并解释每步新增的契约
  • 能分析类型、控制流、函数、指针、结构体、标准 I/O 与 UNIX 接口之间的依赖和可移植边界
  • 能设计一个带输入边界、状态不变量、错误传播和资源清理的小型 C 程序,并用多层证据验证

机制总览

K&R 全书契约式总复习:机制路径

  1. 1

    从第一章的可运行反馈环开始

    K&R 的第一章 A Tutorial Introduction 不是缩小版参考手册,而是

  2. 2

    变量与算术表达式:先确定计算域

    define LOWER 0 define UPPER 300 define STEP 20

  3. 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 程序把源文件、编译器、链接器、运行时和宿主环境连起来。标准入口之一是

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 虽允许声明与语句交错,计算类型规则并未因此改变。

LOWERUPPERSTEP

。 宏替换不做类型检查,也没有作用域对象;简单整数集合可考虑枚举,需要地址或明确类型时可用适当对象。不要把“全大写”当成语言规则,它只是常见命名约定。

for 语句:用不变量解释循环

for 语句适合把初始化、继续条件和推进放在一起,但正确性来自

。 温度表中,不变量包括 fahrenheit 始终位于当前待输出刻度并按 STEP 推进;还要确认加法不会在到达上界前溢出。对不可信上界,不能只凭“每次增加”就断言终止。

数组循环常用 0 <= index && index < count 作为范围不变量。修改循环条件或步进时要同时检查空数组、单元素、最大边界和溢出。breakcontinue 或函数返回会改变控制流证明,应明确它们离开时哪些资源已取得、哪些结果仍有效。

先预测:把整数温度公式改为 celsius = 5 / 9 * (fahrenheit - 32) 会得到什么?5 / 9 在整数域先截断为 0,所以所有结果为 0;赋给浮点变量不会让之前的计算重做。

字符输入输出: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。这并不是“数组按引用传递”;调用者数组未被复制,但形参仍是一个指针值副本。

字符数组与完整记录

可以保存文本,也可以只保存原始字符。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。字符数组容量永远包括结尾空字符位置,拷贝和拼接也要先检查目标剩余空间。

外部变量与作用域:声明不等于定义

帮助第一章把多个函数共享的状态连起来,但也扩大隐藏依赖。一个具有外部链接的对象只能有一个定义,其他翻译单元通过 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 / 95.0 / 9.0 的差异,并为 for 循环写出一个范围不变量和一个终止风险。

问题 2 设计一个读取任意长度逻辑行的接口,说明 EOF、末行无换行、缓冲分片、分配失败和空字符终止如何分别表达。

问题 3 对“打开输入、生成统计、写输出”的程序列出六层契约中各一项检查,并说明动态检测为何不能替代它们。

名词解释

本章出现的专业名词,用大白话再讲一遍。

入门

从最小可运行程序开始,通过编译、执行和观察逐步引入变量、控制流、函数、数组与作用域的学习过程。

main 函数

hosted C 程序的标准入口,返回状态交给宿主环境;int main(void) 是不接收命令行参数的形式之一。

变量与算术表达式

用有类型的名字保存值并组合运算;结果受整数提升、通常算术转换、溢出和求值规则约束。

符号常量

为程序参数命名的常量概念,可由宏或枚举等表达;宏替换本身没有 C 类型和对象作用域。

循环不变量

每次迭代前成立且被循环体保持的事实,与退出条件一起用于证明范围、结果和终止。

字符输入输出

逐字符读取和写出的流协议,读取结果使用 int 区分全部 unsigned char 值与 EOF

值传递

函数形参取得实参值副本;指针参数复制地址值,数组形参则调整为指针并需要独立长度契约。

字符数组

char 元素组成的数组,只有在边界内含结尾空字符时才是 C 字符串。

外部变量

函数外定义的静态存储期对象,可具有外部链接或以 static 限制为翻译单元内部。

作用域

标识符在源程序中可见的区域规则,不等于对象存储期,也不等于跨翻译单元链接属性。

可移植边界

只依赖目标标准或平台承诺,并把实现定义行为和扩展隔离在有文档、有测试的适配模块内。

资源所有权

规定对象或 I/O 资源由谁最终释放、借用持续多久以及失败路径怎样移交或回滚的协议。

资料与写作方式声明

本章以The C Programming Language, Second Edition, Chapters 1-8合法公开试读核定可见范围,并以目录限定未公开部分,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…