标准 I/O、格式化与文件错误
复刻 K&R 第七章:标准流、printf 与可变参数、scanf 输入契约、文件访问、错误处理和可靠行 I/O。
学习目标
- 能解释标准输入输出流的方向、缓冲、文件尾和错误状态,并判断何时需要显式刷写
- 能比较
printf与scanf的格式契约,实现边界受控的文本输出和输入解析 - 能设计完整的文件访问生命周期,区分正常文件尾、格式失败、设备错误与关闭失败
机制总览
标准 I/O、格式化与文件错误:机制路径
- 1
从“每次 I/O 都可能只完成协议的一步”开始
本节把「从“每次 I/O 都可能只完成协议的一步”开始」放回《标准 I/O、格式化与文件错误》的输入、状态变化与输出路径中理解。
- 2
标准输入输出与缓冲边界
if (fputs("continue? [y/n] ", stdout) == EOF) return EOF; if (fflush(stdout) == EOF) return EOF; answer = getchar(); return answer;
- 3
格式化输出:格式串就是类型契约
int print measurement(FILE stream, const char name, long count, double average) int written;
章级决策实验
标准 I/O、格式化与文件错误:机制与证据
切换《标准 I/O、格式化与文件错误》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 从“每次 I/O 都可能只完成协议的一步”开始
本节把「从“每次 I/O 都可能只完成协议的一步”开始」放回《标准 I/O、格式化与文件错误》的输入、状态变化与输出路径中理解。
可核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“每次 I/O 都可能只完成协议的一步”开始」的实际契约。
学完《标准 I/O、格式化与文件错误》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
标准 I/O、格式化与文件错误:失效与核验
从“每次 I/O 都可能只完成协议的一步”开始
典型失效
若把「从“每次 I/O 都可能只完成协议的一步”开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“每次 I/O 都可能只完成协议的一步”开始」的实际契约。
标准输入输出与缓冲边界
典型失效
若把「标准输入输出与缓冲边界」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「标准输入输出与缓冲边界」的实际契约。
格式化输出:格式串就是类型契约
典型失效
若把「格式化输出:格式串就是类型契约」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「格式化输出:格式串就是类型契约」的实际契约。
从“每次 I/O 都可能只完成协议的一步”开始
C 标准库用
↡由标准 I/O 库管理的流对象类型;程序通过 FILE 指针和库函数访问,其内部结构与是否使用操作系统描述符都不属于 ISO C 接口。
表示外部字符序列。流不仅有当前位置,还可能有方向、文本或二进制模式、缓冲区、文件尾指示器和错误指示器。FILE * 是访问句柄,不应解引用其内部成员,也不能假设它必然包装 Unix 文件描述符。
程序启动时可使用
↡程序启动时预定义的标准输入流宏,通常连接交互输入或重定向来源,但具体设备和底层编号由宿主环境决定。、↡程序启动时预定义的标准输出流宏,通常承载正常结果,可被宿主环境重定向。 和 ↡程序启动时预定义的标准错误流宏,用于诊断;标准只保证它在初始状态不是完全缓冲,不保证固定 Unix 描述符。。 它们是标准流宏,不是 ISO C 规定的描述符 0、1、2。终端、文件、管道与设备的连接方式属于宿主环境。
标准输入输出与缓冲边界
↡在程序和宿主环境之间经 stdin、stdout、stderr 或其他 FILE 流传送字符的标准库接口。 把调用者与具体设备隔开。库可以分批传送字符以减少外部操作次数,这称为缓冲。完全缓冲通常在缓冲区填满时传送;行缓冲通常在遇到换行等规定时机传送;不缓冲则尽快传送字符。具体默认行为受流和环境影响,不能从“当前在命令行运行”推导所有实现都相同。
程序需要输出在继续运行前对外可见时,应检查输出操作,再调用
↡请求把输出流尚未传送的字符交给宿主环境的函数;成功只表示完成标准库层操作,不等于数据已持久化到物理介质。。
换行只对行缓冲流具有特定作用,对重定向后的完全缓冲流不能替代显式同步。ISO C 的
fflush 面向输出流或满足规定状态的更新流;用 fflush(stdin)
试图丢弃输入不具可移植定义。
#include <stdio.h>
int ask_to_continue(void)
{
int answer;
if (fputs("continue? [y/n] ", stdout) == EOF)
return EOF;
if (fflush(stdout) == EOF)
return EOF;
answer = getchar();
return answer;
}这里显式刷写是因为程序马上等待输入,而提示文字没有换行。fflush 成功也不等于磁盘已经断电安全;物理持久化需要操作系统特定接口,不属于 K&R 本章的标准 I/O 保证。
格式化输出:格式串就是类型契约
↡按格式串把后续值转换成字符并写入流的操作,以 printf、fprintf 等函数为代表。
由转换说明决定如何取得每个实参。printf 是
↡固定参数之后还可接收数量和类型由调用协议决定的实参列表;额外参数经历默认实参提升且不携带运行时类型标签。
函数:float 会提升为 double,char 和 short
通常经整数提升,但指针不会自动改成另一种指针。格式说明与提升后的实际类型不匹配,行为未定义,不是“读取部分字节后输出乱码”这么可预测。
#include <stdio.h>
int print_measurement(FILE *stream, const char *name, long count,
double average)
{
int written;
written = fprintf(stream, "%s: count=%ld average=%.3f\n",
name, count, average);
if (written < 0)
return 0;
return 1;
}%ld 对应 long,%f 在 printf 家族中取得经提升的 double,精度 .3 控制小数位显示而非计算精度。fprintf 返回写入字符数,失败时返回负值;仍可能存在后续刷写或关闭才报告的延迟错误。
外部文本绝不能直接作为格式串。printf(user_text) 会让其中的 % 触发参数读取,可能造成未定义行为与安全漏洞;应写 printf("%s", user_text)。宽度和精度也要避免无界放大输出;从 * 取得的宽度与精度仍属于可变参数契约。
用 vfprintf 实现可变参数包装
自定义日志包装器应使用 <stdarg.h> 中的 va_list、va_start、va_end 与接收列表的 vfprintf,而不是猜测调用栈布局。每次 va_start 后都必须在同一函数中 va_end;一个列表被某个 v 函数消费后的状态不能随意重用。
#include <stdarg.h>
#include <stdio.h>
int report_error(const char *format, ...)
{
int result;
va_list arguments;
if (fputs("error: ", stderr) == EOF)
return -1;
va_start(arguments, format);
result = vfprintf(stderr, format, arguments);
va_end(arguments);
return result;
}编译器可能识别标准 printf 调用并发出格式警告,但语言层契约仍由调用者负责。项目应启用格式警告并把警告当错误;自定义包装器若使用编译器属性,那是额外工具能力,不是 ISO C 语法。
格式化输入:写入地址比输出更危险
↡按格式串从流中匹配字符并把转换结果写入调用者提供对象的操作,以 scanf、fscanf 等函数为代表。
取得的是目标对象指针。%d 需要 int *,%ld 需要 long *,而读取 double
必须用 %lf 和 double *;这与 printf 中 %f 取得 double
不同。指针类型不匹配会向错误大小或表示的对象写入,行为未定义。
scanf 返回成功赋值的输入项数;到达文件尾或在首次转换前发生读取错误时返回 EOF。返回 1 只证明一个转换成功,不证明整行没有尾随垃圾。转换说明中的空白会消费任意长度空白,%c 默认不跳过空白,格式串末尾的空白可能等待下一枚非空白字符。
#include <stdio.h>
int read_pair(FILE *stream, int *left, int *right)
{
int assigned;
assigned = fscanf(stream, "%d %d", left, right);
if (assigned == 2)
return 1;
if (assigned == EOF)
return feof(stream) ? 0 : -1;
return -2;
}这里把完整成功、正常文件尾、读取错误和格式不匹配分开。格式不匹配时,未匹配字符可能仍留在流中;若调用者不消费或放弃该记录,重复调用会在同一字符上再次失败。
%s 不知道数组容量,必须给字段宽度并为结尾空字符留一格,例如 char word[32]; scanf("%31s", word)。但它仍以空白分词,无法可靠读取含空格记录,也不方便判断整行是否超长。复杂输入优先先读一行,再做受控解析。
行输入输出:边界内读到的不一定是整行
↡以一行或一段字符为单位的标准 I/O;fgets 受容量限制读取并在有空间时写入结尾空字符,fputs 输出字符串但不自动补换行。
通常比直接 scanf 更容易建立记录边界。fgets(buffer, size, stream) 最多存入
size - 1
个字符,若读到换行会保留它;返回空指针表示没有读到字符。缓冲中没有换行可能表示末行无换行,也可能表示一条长行只读到一段,必须结合容量和下一次读取判断。
#include <stdio.h>
#include <string.h>
#define LINE_CAPACITY 128
int copy_lines(FILE *input, FILE *output)
{
char line[LINE_CAPACITY];
while (fgets(line, sizeof line, input) != NULL) {
if (fputs(line, output) == EOF)
return 0;
}
if (ferror(input))
return 0;
return 1;
}这段复制允许长行分多次传送,所以不丢数据;若业务要求“一次得到完整记录”,就要在未见换行时继续累积或报告超长,并消费剩余片段以恢复下一条记录边界。gets 没有容量参数,无法阻止溢出,不能使用。
先用 fgets 取得记录,再用 strtol 等函数解析,可检查无数字、范围错误和尾随字符。sscanf 虽然把读取限制在字符串内,仍需宽度和完整消费检查;“不会越过行”不等于“业务格式严格”。
先预测:128 字节缓冲调用一次
fgets成功,是否证明读到完整一行?不证明;若未读到换行,可能只是长行片段,也可能是文件末尾的最后一行。
文件访问:从 fopen 到 fclose 都要检查
↡通过 fopen 取得流、按模式传送数据、检查状态并通过 fclose 释放流的完整生命周期。
从 fopen(path, mode)
开始。失败返回空指针;成功后模式决定读、写、追加或更新权限。带 b
的二进制模式在某些系统与文本模式相同,在另一些系统会影响换行翻译、可定位位置或特殊字符解释,跨平台二进制数据应明确使用它。
#include <stdio.h>
int copy_file(const char *source_path, const char *target_path)
{
unsigned char buffer[4096];
FILE *source;
FILE *target;
size_t count;
int ok = 1;
source = fopen(source_path, "rb");
if (source == NULL)
return 0;
target = fopen(target_path, "wb");
if (target == NULL) {
fclose(source);
return 0;
}
while ((count = fread(buffer, 1, sizeof buffer, source)) != 0)
if (fwrite(buffer, 1, count, target) != count) {
ok = 0;
break;
}
if (ferror(source))
ok = 0;
if (fclose(source) == EOF)
ok = 0;
if (fclose(target) == EOF)
ok = 0;
return ok;
}fread 的短读可能是文件尾或错误,所以循环后检查 ferror(source)。fwrite 短写直接失败。目标流的最后一批数据可能直到 fclose 才交付,因此关闭结果属于写入事务,不能忽略。示例为了聚焦 I/O 返回布尔结果;实际工具还应报告是哪条路径、哪一步失败,并考虑失败时是否删除不完整目标或采用临时文件后重命名。
更新模式同时允许读写,但方向切换有同步规则:输出后读取前通常需要 fflush 或定位操作,输入后输出前通常需要定位操作,除非输入已遇文件尾。最稳妥的接口会集中管理方向切换,而不是让任意调用者交错操作同一流。
错误处理:文件尾、格式和设备错误不是一回事
↡在 I/O 操作返回失败后读取流状态或 errno,并携带操作与资源上下文向调用者传播的过程。
先从函数返回值判断失败,再检查附加状态。feof 只报告流的文件尾指示器,ferror
报告错误指示器;clearerr 可清除二者,但不会修复底层原因。errno
只有在某个文档化为设置它的函数已经报告失败时才有意义,成功调用后不能据旧值判断错误。
perror("open config") 会把调用上下文与当前 errno 描述写到 stderr。若之后的关闭或清理可能改变 errno,需要先保存它。错误消息应包含操作、路径或记录号,而不只打印“failed”;库函数通常返回错误给上层,不应在深层随意终止整个进程。
exit(status) 执行正常终止流程,包括调用已注册的终止函数并关闭流;写入仍可能失败,但 exit 没有逐个把关闭结果交回业务逻辑。因此关键输出应在调用 exit 前显式刷写或关闭并检查。异常终止、进程崩溃和断电都不能保证普通输出缓冲被提交。
字符、字符串与其他标准库接口
K&R 第 7 章还把 I/O 与常用库函数连接起来。getc/fgetc 返回 int,是为了同时表示所有 unsigned char 值和负的 EOF;若先存进 char,可能丢失区分能力。putc/fputc 成功时返回写入字符,失败返回 EOF。ungetc 只应依赖标准保证的最小回退能力,并在成功后按流状态规则继续读取。
字符分类函数如 isspace 的参数必须是 EOF 或可表示为 unsigned char 的值。普通 char 可能有符号,直接传负值会违反前置条件;应写 isspace((unsigned char)ch),但保留 int 读取结果并先排除 EOF。
字符串、数学、转换和存储管理函数各有独立失败与范围协议。学习标准库的通用方法是:先看参数对象和容量,再看返回值,再看是否设置流状态或 errno,最后确认资源释放。函数名相似不代表错误语义相同。
小结
FILE是标准库管理的流对象,内部布局、底层描述符和标准流连接设备都不是 ISO C 保证- 缓冲改变传送时机;需要可见性时检查输出并显式
fflush,不能用fflush(stdin)清输入 printf取得经默认提升的值,scanf写入精确指针类型;格式不匹配会导致未定义行为- 输入循环以操作返回值驱动,失败后再用
feof和ferror区分正常文件尾与设备错误 fgets受容量限制但一次成功不保证完整行;文件写入只有检查fclose后才算完成- 错误处理要带操作和资源上下文,
errno只能在相应函数已经报告失败后解释
练习
问题 1 为什么 printf("%d", 3.14) 不能描述成“取 double 的前四字节”?给出输出 long、double 和不可信字符串的正确调用。
问题 2 设计读取整数记录的状态:怎样区分完整成功、格式不匹配、正常文件尾和设备错误?为什么不能先检查 feof?
问题 3 文件复制已经逐次检查 fread 与 fwrite,为什么还必须检查源错误状态和两个 fclose?若目标关闭失败,应怎样向上层表达?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- FILE
标准 I/O 库管理的流对象类型;内部表示不公开,也不保证包装操作系统文件描述符。
- stdin
程序启动时可用的标准输入流宏,实际连接的设备或重定向来源由宿主环境决定。
- stdout
程序启动时可用的标准输出流宏,承载正常结果并可由宿主环境重定向。
- stderr
标准错误流宏;初始状态不是完全缓冲,但 ISO C 不为它规定 Unix 描述符编号。
- 标准输入输出
通过标准库流在程序与宿主环境之间传送字符的接口,屏蔽具体设备操作细节。
- fflush
请求传送输出流待处理字符的函数;它同步标准库缓冲,但不等同于物理介质持久化。
- 格式化输出
按格式串把值转换成字符的操作;转换说明必须匹配默认提升后的实际参数类型。
- 可变参数
固定参数后由调用协议决定数量和类型的参数列表,不携带可供被调函数自动检查的类型标签。
- 格式化输入
按格式串匹配字符并写入目标对象的操作,要求每个转换说明匹配准确的指针类型。
- 行输入输出
以一行或受限片段为单位的输入输出;
fgets保留读到的换行,fputs不自动添加换行。- 文件访问
从打开流、按模式传送与检查状态,到验证关闭结果的完整资源生命周期。
- 错误处理
在操作已经报告失败后区分文件尾、格式和设备状态,并携带上下文传播或恢复的过程。