格式化输入输出、流与文件
本节目标
查询 C23 格式化输入输出、流状态、文件定位和文件操作。
处理输入输出时,先回答三个问题:数据是按字段、字符、整行还是固定大小的块组织;流以什么模式打开;每一步由哪个返回值和流标志报告结果。scanf 家族适合结构明确的字段输入,任意整行文本则交给 fgets。本章以 ISO C23 的窄字符 I/O 为主线,把格式合同、更新流读写切换、EOF 和错误判断放进同一条检查路径。
FILE、标准流与流方向
FILE * 是库管理的流对象指针,不能复制其表示来复制流。程序只保存和传递这个指针,并通过 <stdio.h> 接口操作流;不要解引用、按字节复制或自行释放 FILE 对象。
程序开始时可使用三个预定义文本流:stdin 负责标准输入,stdout 负责标准输出,stderr 负责诊断输出。它们连接到什么设备、是否交互以及宿主如何呈现内容,不是 C 程序能够一概假设的。需要稳定测试时,应断言程序自己写出的文本和返回状态,而不是终端提示或系统错误文案。
| 当前任务 | 选择的流或模式 | 检查重点 |
|---|---|---|
| 读取标准输入 | stdin | 输入接口返回值、未消费字符、EOF 与错误指示 |
| 写普通结果 | stdout | 输出接口返回值、缓冲与必要的刷新 |
| 写诊断 | stderr | 输出仍可能失败,不把显示时机当作持久化保证 |
| 只读或只写文件 | 输入模式或输出模式 | 不在错误方向调用 I/O 接口 |
| 在同一文件读写 | 更新模式 | 每次方向切换满足刷新或定位要求 |
输入流、输出流和更新流描述允许的操作方向;字节定向与宽定向则是另一层属性。窄字符 I/O 和宽字符 I/O 不能在已经以相反方式定向的流上随意混用。本章代表程序只使用窄字符接口,不用复制 FILE 表示或混合宽字符 I/O。
文件打开模式与流生命周期
用 fopen(path, mode) 建立文件流,先按任务选择主模式,再决定是否需要更新模式 + 和二进制标志 b。打开失败返回空指针;成功后,每条离开路径都要让对应的 fclose 只执行一次,并检查关闭是否成功。
| 模式 | 既有文件 | 不存在的文件 | 初始位置 | 允许操作 |
|---|---|---|---|---|
r | 保留内容 | 打开失败 | 开头 | 读 |
w | 截断为空 | 创建 | 开头 | 写 |
a | 保留内容 | 创建 | 每次写入都在末尾 | 写 |
r+ | 保留内容 | 打开失败 | 开头 | 读、写 |
w+ | 截断为空 | 创建 | 开头 | 读、写 |
a+ | 保留内容 | 创建 | 读位置按实现规则,写入总在末尾 | 读、写 |
把 b 加入模式可请求二进制流,例如 rb、w+b。文本流与二进制流的表示和定位保证不同,不把本机换行或字节布局写成跨平台规则。二进制模式也不会自动解决对象填充、整数表示、字节序或版本兼容问题。
更新流在读写方向切换时必须满足刷新或定位要求:输出后转输入,先调用 fflush 或文件定位函数;输入后转输出,先调用文件定位函数,除非刚才的输入操作遇到文件末尾。代表程序以 w+ 打开,写出后依次 fflush、fseek,再读取核对。
printf 家族与格式化输出
格式化输出由格式字符串同时描述文本和后续参数类型。printf/scanf 的格式必须与实际参数类型匹配;默认实参提升不消除格式契约。即使两个类型在当前平台大小相同,错误的转换说明仍可能产生未定义行为。
| 任务 | 格式化输出接口 | 目标 | 成功结果 |
|---|---|---|---|
| 写标准输出 | printf | stdout | 写出的字符数 |
| 写指定流 | fprintf | FILE * | 写出的字符数 |
| 写定长数组 | snprintf | 字符数组及容量 | 本来需要写出的字符数,不含结尾空字符 |
返回负值表示输出或编码错误。使用 snprintf 时,返回值非负也不表示内容一定完整;把它转换成合适的无符号类型之前,先排除负值,再与目标容量比较。容量大于零时它会在规则允许的范围内写入结尾空字符;返回值大于等于容量表示发生截断。
常用判断包括:%d 对应提升后的 int,%u 对应 unsigned int,%zu 对应 size_t,%f 在 printf 中接收提升后的 double,%s 需要指向有效的空字符结尾字符串,%p 需要 void *。用户输入不能直接充当格式字符串;固定格式,把数据作为后续实参传入。
scanf 家族与格式化输入
scanf 返回成功赋值的项目数。只有返回值精确等于预期项目数,程序才能使用全部目标对象;匹配失败、输入失败和 EOF 需要结合返回值与后续状态判断。若在第一次转换前发生输入失败,返回 EOF;格式不匹配则可能返回零;完成部分转换后发生问题会返回已经成功赋值的数量。
* 紧跟在 % 后表示赋值抑制:转换仍按规则尝试读取并匹配输入,但不对应也不消耗接收指针,而且不增加返回的赋值项目数。除用 %n 间接记录已读取字符外,返回值不能直接证明被抑制转换是否成功;若后续逻辑必须知道它是否匹配,应让格式或解析流程产生可观察的检查结果。
| 任务 | 格式化输入接口 | 数据来源 | 主要检查 |
|---|---|---|---|
| 从标准输入读取字段 | scanf | stdin | 返回值是否等于目标项目数 |
| 从指定流读取字段 | fscanf | FILE * | 转换数量,再检查流状态 |
| 从字符串解析字段 | sscanf | 字符串 | 转换数量和未消费尾部 |
%s 不读取空白,并且必须设置与目标数组容量匹配的字段宽度。字段宽度限制最多消费的输入字符数,不包含库补写的结尾空字符,所以 char name[16] 对应 %15s。任意整行文本仍应使用 fgets,然后再按项目规则解析。
输入函数会留下未消费字符,正文必须说明空白指令、换行和下一次读取之间的关系。格式中的普通空白会跳过任意数量的输入空白,直到遇到下一个非空白字符;大多数数值和 %s 转换也先跳过输入空白,但 %c、%[ 和 %n 不会自动这样做。格式末尾的空白可能继续等待后续非空白输入,不应靠它“吃掉一行”。需要丢弃当前记录的剩余内容时,应按项目定义读取并判断到换行或 EOF,不能使用 fflush(stdin) 清空输入。
字符、字符串与行输入输出
根据记录边界选择接口,不要先用字段读取破坏边界,再猜测剩余内容。
| 数据单位 | 输入 | 输出 | 关键合同 |
|---|---|---|---|
| 单个字节字符 | fgetc | fputc | 输入结果保存在 int,先与 EOF 比较 |
| 空字符结尾字符串 | — | fputs | 不自动追加换行;失败返回 EOF |
| 有容量上限的一段行文本 | fgets | — | 最多读容量减一字符并补 \0 |
fgets 在遇到换行且容量允许时保留换行;若数组已装满,得到的可能只是当前行的一部分。区分完整行、分段行和最后一行时,要检查数组中是否出现换行,并结合后续读取结果与流标志判断。fputs 不追加换行,需要时显式输出 \n。已从 C 标准删除的 gets 没有容量参数,不能使用。
fgetc 返回的是转换为 unsigned char 后再表示为 int 的字符值,或特殊值 EOF。若先缩窄存入 char,可能失去区分普通字符与 EOF 的能力。
fread、fwrite 与二进制块读写
块 I/O 按“元素大小 × 元素数量”传递数据,返回值按完整元素数量解释。
| 任务 | 块 I/O 接口 | 返回值 | 使用判断 |
|---|---|---|---|
| 从流读入对象数组 | fread | 成功读入的完整元素数 | 少于请求数量后检查 feof 与 ferror |
| 把对象数组写入流 | fwrite | 成功写出的完整元素数 | 少于请求数量就是未完整写入 |
fread/fwrite 返回成功处理的元素数量,不把部分元素自动解释为完整成功。返回值小于请求数量时,先保留实际完成数量,再分别判断文件结束和错误;不能把“短读”一律写成错误,也不能忽略短写。
直接写出结构体的对象表示通常不是可移植文件格式:填充字节、整数表示、字节序、浮点表示和版本布局都可能变化。跨程序或跨平台存储时,应定义字段编码、范围、顺序和版本,再逐字段序列化。对象表示与字符类型访问的语言边界见限定符、对象访问与程序行为边界。
缓冲、刷新与交互式输出
缓冲决定数据何时从库缓冲区交给宿主环境,不改变输出接口的格式合同,也不保证数据已经持久化到存储设备。
| 任务 | 接口或方式 | 约束 |
|---|---|---|
| 在流建立后选择缓冲方式 | setvbuf | 在该流执行其他操作前调用,并保持用户缓冲区有效 |
| 把输出缓冲交给宿主环境 | fflush | 检查返回值;失败返回 EOF |
| 交互提示后等待输入 | fflush(stdout) | 提示无换行或时机重要时显式刷新 |
fflush 的可移植用途是处理输出流或满足标准条件的更新流,不能使用 fflush(stdin) 清空输入。对输入流调用它不构成可移植的残留输入处理方法。从 main 的初始调用返回或调用 exit 时,标准规定刷新并关闭所有打开的流、移除 tmpfile 创建的文件。quick_exit 转入 _Exit;这两条路径是否刷新或关闭流、移除临时文件由实现定义。错误分支仍应在拥有资源的位置显式检查写入、刷新和关闭结果。
不要把“终端上已经看到文字”当作所有输出都已成功,也不要依赖某种本机默认缓冲模式决定协议时序。需要严格交互顺序时,程序明确输出、检查并刷新。
文件位置与重新定位
定位接口适合重读、跳过或恢复一个已记录的位置,但文本流和二进制流拥有不同保证。
| 任务 | 定位接口 | 结果与边界 |
|---|---|---|
| 相对开头、当前位置或末尾移动 | fseek | 成功返回零;文本流偏移选择受限 |
取得可供 fseek 使用的位置 | ftell | 失败返回 -1L;值不必是字节数 |
| 回到开头并清除流错误指示 | rewind | 无返回值,不能直接报告定位失败 |
| 保存不透明位置 | fgetpos | 成功返回零,写入 fpos_t 对象 |
| 恢复不透明位置 | fsetpos | 使用同一流先前保存的有效位置 |
对文本流,fseek 的非零偏移只应使用先前由 ftell 为同一流取得的值并配合 SEEK_SET;偏移零可配合标准起点。不要把 ftell 在文本流上的值当作通用字节索引。二进制流更适合按已定义记录布局定位,但仍要检查算术范围和接口失败。
查询位置的 ftell 和 fgetpos 不清除 EOF 指示。成功的 fseek 和 fsetpos 会清除 EOF 指示;rewind 还会清除错误指示。这些重新定位操作可满足更新流的方向切换要求。若必须获知失败,应选择有返回值的重新定位接口并显式检查。
EOF、错误标志与状态清理
EOF 指示和错误指示是流状态,不是“最后一次读取结果”的同义词。读取接口先通过返回值报告当前操作没有取得所需数据;随后才用 feof 和 ferror 区分到达文件末尾、发生读取错误或两者相关的状态。
| 要判断的状态 | 查询接口 | 何时使用 | 如何清理 |
|---|---|---|---|
| 已设置文件结束指示 | feof | 读取返回 EOF 或发生短读之后 | clearerr 或成功定位 |
| 已设置错误指示 | ferror | I/O 返回失败或短结果之后 | clearerr;同时处理根因 |
| 同时清除 EOF 与错误指示 | clearerr | 已经处理原因且确实要继续操作时 | 接口本身无返回值 |
EOF 是负值整型常量宏,不是文件中存储的一个字符。循环应先调用读取函数,再处理成功取得的数据;读取失败后检查流标志。while (!feof(stream)) 会在真正读取之前猜测未来状态,容易多处理一次旧数据。
清除标志不修复文件、设备或参数问题。只有程序已经识别并处理根因,而且流合同允许继续时,才调用 clearerr 后重试。
文件重命名、删除与临时文件
文件名操作作用于宿主环境中的名字,不等同于对已打开 FILE * 的读写。路径语法、权限模型以及目标已存在时的细节可能依赖实现或宿主,程序必须检查接口结果。
| 文件管理任务 | 接口 | 成功结果 | 主要边界 |
|---|---|---|---|
| 改变文件名 | rename | 返回零 | 失败保持非零结果;目标处理规则需查实现 |
| 删除文件名 | remove | 返回零 | 打开文件被删除时的宿主行为不可一概假设 |
| 建立自动管理的临时二进制更新流 | tmpfile | 返回 FILE * | 失败为空指针;关闭或 exit 路径(含初始 main 返回)按标准移除,quick_exit/_Exit 不作同等保证 |
| 生成临时名字 | tmpnam | 按接口规则返回名字 | 存在名字竞争风险,不作为安全创建流程 |
优先让安全创建机制以一次操作建立并打开临时资源;仅生成名字再打开会留下竞争窗口。tmpnam 只作为风险接口说明,tmpnam 不进入推荐方案或代表程序。代表程序接收测试提供的路径,用 fopen 建立文件,成功和已打开后的错误路径都会尝试 remove。
流与文件操作陷阱
排查顺序是:调用前提与模式 → 当前读写方向 → 接口返回值 → EOF/错误指示 → 已获取资源 → 关闭与文件名清理。
| 症状 | 常见根因 | 修正方式 |
|---|---|---|
%s 写越界 | 未设置字段宽度,或宽度等于数组容量 | 为 char array[N] 使用至多 N - 1 的宽度 |
| 后续读取立即得到换行 | 前一转换留下未消费字符 | 明确记录边界,用字符或行读取处理剩余内容 |
| 更新流读不到刚写的数据 | 写后直接转读 | 先刷新或定位,再开始读取 |
用 fflush(stdin) 后“偶尔有效” | 依赖实现扩展 | 按输入协议读取到边界并检查 EOF/错误 |
| 把短读视为完整记录 | 只判断返回值非零 | 与请求元素数比较,再检查流状态 |
| 文本位置在另一平台失效 | 把 ftell 当通用字节偏移 | 只在同一流按文本流定位规则复用 |
| 文件内容已写但最终仍损坏 | 忽略刷新或关闭失败 | 检查写、刷新、关闭的每个返回值 |
下面的代表程序从标准输入按 %15s %d 读取两个字段,把它们写入测试提供的 w+ 文件流,刷新并重新定位后读回核对。随后它用 fgetc 观察记录换行和文件末尾,并用 feof、ferror 区分流标志;所有正常路径都关闭并删除文件。输入 pears 7 时得到固定输出:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int fail_after_open(FILE *stream, const char *path) {
if (stream != NULL) {
(void)fclose(stream);
}
(void)remove(path);
fputs("stream-error\n", stderr);
return EXIT_FAILURE;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fputs("usage-error\n", stderr);
return EXIT_FAILURE;
}
char input_name[16];
int input_quantity = 0;
if (scanf("%15s %d", input_name, &input_quantity) != 2) {
fputs("input-error\n", stderr);
return EXIT_FAILURE;
}
FILE *stream = fopen(argv[1], "w+");
if (stream == NULL) {
fputs("file-open-error\n", stderr);
return EXIT_FAILURE;
}
if (fprintf(stream, "%s %d\n", input_name, input_quantity) < 0 ||
fflush(stream) == EOF || fseek(stream, 0L, SEEK_SET) != 0) {
return fail_after_open(stream, argv[1]);
}
char file_name[16];
int file_quantity = 0;
if (fscanf(stream, "%15s %d", file_name, &file_quantity) != 2) {
return fail_after_open(stream, argv[1]);
}
int character = fgetc(stream);
if (character != '\n' || fgetc(stream) != EOF || !feof(stream) || ferror(stream)) {
return fail_after_open(stream, argv[1]);
}
if (strcmp(input_name, file_name) != 0 || input_quantity != file_quantity) {
return fail_after_open(stream, argv[1]);
}
if (fclose(stream) == EOF) {
(void)remove(argv[1]);
fputs("file-close-error\n", stderr);
return EXIT_FAILURE;
}
if (remove(argv[1]) != 0) {
fputs("file-remove-error\n", stderr);
return EXIT_FAILURE;
}
printf("input=%s,%d\n", input_name, input_quantity);
printf("file=%s,%d\n", file_name, file_quantity);
puts("records=1");
puts("status=ok");
return EXIT_SUCCESS;
}
输出:
input=pears,7
file=pears,7
records=1
status=ok