标准 I/O、格式化与文件错误

复刻 K&R 第七章:标准流、printf 与可变参数、scanf 输入契约、文件访问、错误处理和可靠行 I/O。

学习目标

  • 能解释标准输入输出流的方向、缓冲、文件尾和错误状态,并判断何时需要显式刷写
  • 能比较 printfscanf 的格式契约,实现边界受控的文本输出和输入解析
  • 能设计完整的文件访问生命周期,区分正常文件尾、格式失败、设备错误与关闭失败

机制总览

标准 I/O、格式化与文件错误:机制路径

  1. 1

    从“每次 I/O 都可能只完成协议的一步”开始

    本节把「从“每次 I/O 都可能只完成协议的一步”开始」放回《标准 I/O、格式化与文件错误》的输入、状态变化与输出路径中理解。

  2. 2

    标准输入输出与缓冲边界

    if (fputs("continue? [y/n] ", stdout) == EOF) return EOF; if (fflush(stdout) == EOF) return EOF; answer = getchar(); return answer;

  3. 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 标准库用

表示外部字符序列。流不仅有当前位置,还可能有方向、文本或二进制模式、缓冲区、文件尾指示器和错误指示器。FILE * 是访问句柄,不应解引用其内部成员,也不能假设它必然包装 Unix 文件描述符。

程序启动时可使用

。 它们是标准流宏,不是 ISO C 规定的描述符 0、1、2。终端、文件、管道与设备的连接方式属于宿主环境。

标准输入输出与缓冲边界

把调用者与具体设备隔开。库可以分批传送字符以减少外部操作次数,这称为缓冲。完全缓冲通常在缓冲区填满时传送;行缓冲通常在遇到换行等规定时机传送;不缓冲则尽快传送字符。具体默认行为受流和环境影响,不能从“当前在命令行运行”推导所有实现都相同。

程序需要输出在继续运行前对外可见时,应检查输出操作,再调用

。 换行只对行缓冲流具有特定作用,对重定向后的完全缓冲流不能替代显式同步。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 函数:float 会提升为 doublecharshort 通常经整数提升,但指针不会自动改成另一种指针。格式说明与提升后的实际类型不匹配,行为未定义,不是“读取部分字节后输出乱码”这么可预测。

#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%fprintf 家族中取得经提升的 double,精度 .3 控制小数位显示而非计算精度。fprintf 返回写入字符数,失败时返回负值;仍可能存在后续刷写或关闭才报告的延迟错误。

外部文本绝不能直接作为格式串。printf(user_text) 会让其中的 % 触发参数读取,可能造成未定义行为与安全漏洞;应写 printf("%s", user_text)。宽度和精度也要避免无界放大输出;从 * 取得的宽度与精度仍属于可变参数契约。

用 vfprintf 实现可变参数包装

自定义日志包装器应使用 <stdarg.h> 中的 va_listva_startva_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 语法。

格式化输入:写入地址比输出更危险

取得的是目标对象指针。%d 需要 int *%ld 需要 long *,而读取 double 必须用 %lfdouble *;这与 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)。但它仍以空白分词,无法可靠读取含空格记录,也不方便判断整行是否超长。复杂输入优先先读一行,再做受控解析。

行输入输出:边界内读到的不一定是整行

通常比直接 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(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 或定位操作,输入后输出前通常需要定位操作,除非输入已遇文件尾。最稳妥的接口会集中管理方向切换,而不是让任意调用者交错操作同一流。

错误处理:文件尾、格式和设备错误不是一回事

先从函数返回值判断失败,再检查附加状态。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 成功时返回写入字符,失败返回 EOFungetc 只应依赖标准保证的最小回退能力,并在成功后按流状态规则继续读取。

字符分类函数如 isspace 的参数必须是 EOF 或可表示为 unsigned char 的值。普通 char 可能有符号,直接传负值会违反前置条件;应写 isspace((unsigned char)ch),但保留 int 读取结果并先排除 EOF

字符串、数学、转换和存储管理函数各有独立失败与范围协议。学习标准库的通用方法是:先看参数对象和容量,再看返回值,再看是否设置流状态或 errno,最后确认资源释放。函数名相似不代表错误语义相同。

小结

  • FILE 是标准库管理的流对象,内部布局、底层描述符和标准流连接设备都不是 ISO C 保证
  • 缓冲改变传送时机;需要可见性时检查输出并显式 fflush,不能用 fflush(stdin) 清输入
  • printf 取得经默认提升的值,scanf 写入精确指针类型;格式不匹配会导致未定义行为
  • 输入循环以操作返回值驱动,失败后再用 feofferror 区分正常文件尾与设备错误
  • fgets 受容量限制但一次成功不保证完整行;文件写入只有检查 fclose 后才算完成
  • 错误处理要带操作和资源上下文,errno 只能在相应函数已经报告失败后解释

练习

问题 1 为什么 printf("%d", 3.14) 不能描述成“取 double 的前四字节”?给出输出 longdouble 和不可信字符串的正确调用。

问题 2 设计读取整数记录的状态:怎样区分完整成功、格式不匹配、正常文件尾和设备错误?为什么不能先检查 feof

问题 3 文件复制已经逐次检查 freadfwrite,为什么还必须检查源错误状态和两个 fclose?若目标关闭失败,应怎样向上层表达?

名词解释

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

FILE

标准 I/O 库管理的流对象类型;内部表示不公开,也不保证包装操作系统文件描述符。

stdin

程序启动时可用的标准输入流宏,实际连接的设备或重定向来源由宿主环境决定。

stdout

程序启动时可用的标准输出流宏,承载正常结果并可由宿主环境重定向。

stderr

标准错误流宏;初始状态不是完全缓冲,但 ISO C 不为它规定 Unix 描述符编号。

标准输入输出

通过标准库流在程序与宿主环境之间传送字符的接口,屏蔽具体设备操作细节。

fflush

请求传送输出流待处理字符的函数;它同步标准库缓冲,但不等同于物理介质持久化。

格式化输出

按格式串把值转换成字符的操作;转换说明必须匹配默认提升后的实际参数类型。

可变参数

固定参数后由调用协议决定数量和类型的参数列表,不携带可供被调函数自动检查的类型标签。

格式化输入

按格式串匹配字符并写入目标对象的操作,要求每个转换说明匹配准确的指针类型。

行输入输出

以一行或受限片段为单位的输入输出;fgets 保留读到的换行,fputs 不自动添加换行。

文件访问

从打开流、按模式传送与检查状态,到验证关闭结果的完整资源生命周期。

错误处理

在操作已经报告失败后区分文件尾、格式和设备状态,并携带上下文传播或恢复的过程。

资料与写作方式声明

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

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

讨论

评论区加载中…