流与标准流、重定向
文本文件与二进制文件
打开文件: fopen 与打开模式
格式化读写: fprintf / fscanf
关闭文件: fclose
字符与行读写: fgetc / fputc、fgets / fputs
块读写: fread / fwrite
文件定位: fseek / ftell / rewind
案例: 文件复制、修改零件记录
数据只存在内存里, 程序一关就没了——文件让数据持久化, 流就是进出程序的通道。
C 里, 流就是任意输入的来源或输出的目的地。
许多小程序从一个流(键盘)获取所有输入, 并将其所有输出写入另一个流(屏幕).
较大的程序可能需要额外的流.
流通常表示存储在不同介质上的文件.
但也很容易与网络端口和打印机等设备相关联.
访问流是通过文件指针完成的, 该指针具有FILE *类型.
FILE类型在<stdio.h>中声明.
某些流由具有标准名称的文件指针表示.
需要时可以声明更多文件指针:
FILE *fp1, *fp2;
<stdio.h> 自带三个标准流, 可以直接使用——不需要声明, 也不打开或关闭:
| 文件指针 | 流 | 默认含义 |
|---|---|---|
| stdin | 标准输入 | 键盘 |
| stdout | 标准输出 | 屏幕 |
| stderr | 标准错误 | 屏幕 |
前面章节中讨论的 I/O 函数从 stdin 获取输入, 并把输出发送到 stdout.
许多操作系统允许通过称为重定向的机制更改这些默认含义.
输入重定向——让程序从文件而不是键盘读入:
demo <in.dat
输出重定向——所有写入 stdout 的数据进入 out.dat 文件, 而不是出现在屏幕上:
demo >out.dat
输入、输出重定向可以一起用:
demo <in.dat >out.dat
<、> 和文件名之间有没有空格都行, 多个重定向文件的顺序也无所谓:
demo <in.dat> out.dat
等价于:
demo >out.dat <in.dat
输出重定向要注意: 所有写到 stdout 的内容都会进同一个文件.
将错误消息写入 stderr 而不是 stdout 可以保证即使 stdout 已被重定向, 错误信息也会出现在屏幕上.
<stdio.h> 支持两种文件: 文本文件和二进制文件.
用文本模式 (模式串不带 b) 读写时, C 库可能在 '\n' 与系统行尾之间转换: Windows 上写文件时 '\n' 变成 \r\n, 读文件时相反. 二进制模式 (模式串带 b) 不做这种转换, 读写的字节与文件中的字节一一对应.
文本文件中的字节代表字符, 允许人们检查或编辑文件.
C 程序的源代码存储在文本文件中.
在二进制文件中, 字节不一定代表字符.
字节组可能代表其他类型的数据, 例如整数和浮点数.
可执行的 C 程序存储在二进制文件中.
文本文件具有二进制文件不具备的两个特征.
文本文件分为若干行. 文本文件中的每一行通常以一个或两个特殊字符结尾.
Windows: 回车符('\x0d')后跟换行符('\x0a')
UNIX 和较新版本的 Mac OS: 换行符
旧版本的 Mac OS: 回车符
文本文件可以包含一个特殊的"文件结束"标记.
在 Windows 中, 标记是 '\x1a' (Ctrl+Z), 但这不是必需的.
大多数其他操作系统, 包括 UNIX, 没有特殊的文件结束字符.
在二进制文件中, 没有行尾或文件结束标记; 所有字节都被平等对待.
数据写入文件时, 可以选择文本格式或二进制格式存储. 以数字 32767 为例, 按文本写入字符 3、2、7、6、7:
按二进制存, 只要两个字节:
以二进制存储数字通常可以节省空间.
读取或写入文件的程序必须考虑它是文本文件还是二进制文件.
在屏幕上显示文件内容的程序可能会假设它是文本文件
文件复制程序不能这样假设: 按文本方式复制, 包含文件结尾字符的二进制文件不会被完全复制
无法确定文件是文本形式还是二进制形式时, 假设它是二进制文件更安全
简单性是重定向的魅力之一, 但它在许多应用中受到限制:
程序依赖重定向时, 无法控制自己的文件, 甚至不知道文件的名字
如果程序需要同时读取两个文件或写入两个文件, 重定向做不到
这时使用 <stdio.h> 提供的文件操作. 要把文件用作流, 打开时需要调用 fopen:
FILE *fopen(const char * restrict filename,
const char * restrict mode);
filename 是要打开的文件的名称, 可以带路径信息 (比如盘符或目录).
mode 是模式字符串, 指定打算对文件执行的操作.
restrict 是 C99 关键字, 表示 filename 和 mode 不共享内存单元; 它对 fopen 的行为没有影响, 通常可以忽略.
fopen 调用中的文件名包含 \ 字符时要小心.
调用 fopen("c:\project\test1.dat", "r") 将失败:
\t 被当作转义字符 (制表符), \p 也不是合法的转义序列, 编译器通常会报告 unknown escape sequence.
避开这个问题的一种写法: 用 \\ 代替 \:
fopen("c:\\project\\test1.dat", "r")
另一种写法: 用 / 代替 \:
fopen("c:/project/test1.dat", "r")
fopen 返回一个文件指针, 程序通常把它保存在变量中:
fp = fopen("in.dat", "r");
/* 打开 in.dat 以供阅读 */
无法打开文件时, fopen 返回空指针, 所以打开文件后必须检查返回值:
if ((fp = fopen("in.dat", "r")) == NULL) {
/* 处理错误 */
}
模式字符串由两个因素决定: 要对文件执行的操作; 数据是文本形式还是二进制形式.
用于文本文件的模式字符串:
| 字符串 | 含义 |
|---|---|
| "r" | 打开文件读 (文件必须存在) |
| "w" | 打开文件写 (不存在则创建, 存在则截断为 0 字节) |
| "a" | 打开文件追加 (不存在则创建, 写入总在末尾) |
| "r+" | 打开文件读写 (文件必须存在), 从文件头开始 |
| "w+" | 打开文件读写 (不存在则创建, 存在则截断为 0 字节) |
| "a+" | 打开文件读写 (不存在则创建, 写入总在末尾) |
二进制模式在文本模式字符串中加一个 b; 读写更新模式则再加一个 + (如 "rb"、"r+b"/"rb+").
写数据和追加数据有不同的模式字符串.
当数据写入文件时, 它通常会覆盖以前存在的内容.
当打开文件进行追加时, 写入文件的数据将添加到末尾.
当打开文件进行读写时, 适用特殊规则.
除非读取操作遇到文件末尾, 否则不先调用后面讲的文件定位函数 (fseek 等) 就无法从读模式切换到写模式.
如果既没有调用 fflush 函数也没有调用后面讲的文件定位函数 (fseek 等), 则无法从写模式切换到读模式.
/* grades.txt 里已经存了一学期的成绩 */
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("grades.txt", "w"); /* 打开瞬间, 旧内容没了! */
if (fp == NULL) {
return 1;
}
fprintf(fp, "new line\n");
fclose(fp);
return 0;
}
"w" 打开文件时, 已有内容立即被清空 (截断为 0 字节), 不询问、不提醒
想接着写用 "a" (追加, 内容保留); "r+" 只适合原地改写, 要从末尾写需先 fseek(fp, 0L, SEEK_END)
教训: 写代码前想清楚是"覆盖重写"还是"追加"——"w" 与 "a" 差一个字母, 后果天壤之别
int fprintf(FILE * restrict stream,
const char * restrict format, ...);
int fscanf(FILE * restrict stream,
const char * restrict format, ...);
fprintf / fscanf 与 printf / scanf 用法相同, 只是第一个参数指定流.
fprintf(fp, "%d %s\n", n, name); 把格式化数据写入 fp
fscanf(fp, "%d", &n); 从 fp 按格式读取, 存入 n
返回值: fprintf 返回写入的字符数, 出错时返回负值; fscanf 返回成功匹配并赋值的项数, 在第一次转换前遇到输入结束或错误时返回 EOF.
#include <stdio.h>
#include <stdlib.h>
#define FILE_NAME "example.dat"
int main(void)
{
FILE *fp;
fp = fopen(FILE_NAME, "r");
if(fp == NULL) {
printf("Can't open %s\n", FILE_NAME);
exit(EXIT_FAILURE);
}
…
fclose(fp);
return 0;
}
FILE *fp = fopen(FILE_NAME, "r");
if (fp == NULL) {
/* 处理错误 */
}
fclose 函数允许程序关闭不再使用的文件, 参数必须是从调用 fopen 获得的文件指针.
如果文件成功关闭, fclose 返回零
否则, 它返回错误代码 EOF (在 <stdio.h> 中定义的宏)
打开文件的框架可以更紧凑: 把 fopen 的调用与 fp 的声明写在一起, 再单独判断返回值 (见左边第二段代码).
不要写成 if (FILE *fp = ...)——C 的 if 条件里不能声明变量.
现场演示: 写一个程序 canopen, 判断命令行参数给出的文件能不能打开
前情: 命令行参数由 int main(int argc, char *argv[]) 接收——argc 是参数个数, argv[0] 是程序名, 其余的是参数. 从命令行取文件名比写死更灵活.
样例:
$ ./canopen demo.txt
demo.txt 可以打开
$ ./canopen 不存在.txt
不存在.txt 打不开
⚡ 在 VS Code 中打开 canopen.c
提示: fopen 失败会返回 NULL——判断它; 参数个数不对要提示用法
/* Checks whether a file can be opened for reading */
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
FILE *fp;
if(argc != 2) {
printf("用法: canopen 文件名\n");
exit(EXIT_FAILURE);
}
if((fp = fopen(argv[1], "r")) == NULL) {
printf("%s 打不开\n", argv[1]);
exit(EXIT_FAILURE);
}
printf("%s 可以打开\n", argv[1]);
fclose(fp);
return 0;
}
argc != 2 先检查参数个数——命令行参数的第一个完整例子
fopen(..., "r") 失败返回 NULL——检查返回值是否为空是常规做法
失败用 exit(EXIT_FAILURE) 直接退出, 不再往下走
fputc(ch, fp); /* 将 ch 写入 fp */
putc(ch, fp); /* 将 ch 写入 fp */
ch = fgetc(fp);
ch = getc(fp);
putchar(ch); /* 将 ch 写入标准输出流 */
下一组库函数读写单个字符, 文本流与二进制流通用.
字符被当作 int 而非 char——输入函数用返回 EOF (负整数常量) 指示文件结束或错误
putchar 等价于 putc(c, stdout), 本身通常也定义为宏
putc 通常实现为宏(以及函数), 而 fputc 仅实现为函数; C 标准允许 putc 宏对 stream 参数多次求值, 而 fputc 不可以
写入出错时, 上述 3 个函数都会为流设置错误指示器并返回 EOF; 否则返回写入的字符
getchar 从 stdin 读取一个字符:
ch = getchar();
fgetc 和 getc 从任意流中读取一个字符:
ch = fgetc(fp);
这三个函数都将字符视为 unsigned char 类型的值(返回之前转换为 int 类型).
因此, 它们不会返回 EOF 以外的负值.
现场演示: 写一个程序 fcopy, 把源文件复制到目标文件 (二进制安全)
样例:
$ ./fcopy a.txt b.txt
复制完成
⚡ 在 VS Code 中打开 fcopy.c
提示: 用 "rb"/"wb" 二进制模式; 逐个字符读、逐个写——前面讲的 fgetc/fputc (代码中用等价的 getc/putc)
/* Copies a file */
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
FILE *source_fp, *dest_fp;
int ch;
if(argc != 3) {
fprintf(stderr, "用法: fcopy 源文件 目标文件\n");
exit(EXIT_FAILURE);
}
if((source_fp = fopen(argv[1], "rb")) == NULL) {
fprintf(stderr, "打不开 %s\n", argv[1]);
exit(EXIT_FAILURE);
}
if((dest_fp = fopen(argv[2], "wb")) == NULL) {
fprintf(stderr, "打不开 %s\n", argv[2]);
fclose(source_fp);
exit(EXIT_FAILURE);
}
while((ch = getc(source_fp)) != EOF)
putc(ch, dest_fp);
fclose(source_fp);
fclose(dest_fp);
printf("复制完成\n");
return 0;
}
"rb"/"wb" 二进制模式——文本和二进制文件都能复制
逐个字符 fgetc/fputc, 直到 EOF——文件循环的标准写法
三个参数、两个 fopen 都判错——错误处理是文件程序的重要部分
下一组中的库函数能够读取和写入行. 这些函数主要用于文本流, 尽管它们也能用于二进制流.
puts 将字符串写入 stdout, 在写完字符后总是添加一个换行符:
puts("Hi, there!"); /* writes to stdout */
fputs 是 puts 的更通用版本, 第二个参数指示输出应写入的流:
fputs("Hi, there!", fp); /* writes to fp */
与 puts 不同, fputs 不会写入换行符, 除非字符串中本身存在换行符.
如果发生写入错误, 这两个函数都返回 EOF; 否则, 它们返回一个非负数.
char line[100];
while (fgets(line, sizeof(line), fp) != NULL) {
for (int i = 0; line[i] != '\0'; i++)
if (line[i] == '\n') {
line[i] = '\0';
break;
}
printf("%s\n", line);
}
gets 函数从 stdin 读取一行输入 (C11 已从标准中移除, 不要使用):
gets(str); /* reads a line from stdin */
fgets 是更通用、更安全的版本, 可以从任意流中读取, 并限制存储的字符数.
fgets 最多读 n-1 个字符; 读到换行符时把它保留在字符串中 (gets 会丢弃)
需要去掉行尾 '\n' 时, 可像左边那样找到后覆盖为 '\0'
sizeof(line) 只对数组有效; 如果 line 是指针, 应显式传入缓冲区大小
现代 C 代码应使用 fgets(str, sizeof(str), fp), 并检查返回值是否为 NULL
fwrite(a, sizeof(a[0]), sizeof(a) / sizeof(a[0]), fp);
n = fread(a, sizeof(a[0]),
sizeof(a) / sizeof(a[0]), fp);
fread/fwrite 一次读写大块数据 (主要用于二进制流, 也可用于文本流).
fwrite 把数组从内存复制到流, 参数: 数组地址、元素大小 (字节)、元素个数、文件指针; 返回实际写入的元素数 (出错时小于第三个参数).
fread 从流读数组元素, 返回值是实际读取的元素数 (除非到达文件末尾或出错, 否则应等于第三个参数). a 必须是数组 (或指向连续内存的指针), 元素个数类型为 size_t.
如果要求 fscanf 函数读取并存储 n 个数据项, 那么期望它的返回值为 n. 如果返回值小于 n, 则出现问题:
文件末尾: 在完全匹配格式字符串之前遇到文件末尾
读取错误: 无法从流中读取字符
匹配失败: 数据项的格式错误
每个流都有两个与之关联的指示器: 错误指示器和文件末尾指示器.
打开流时, 这些指示器会被清除
遇到文件末尾就设置文件末尾指示器, 读取错误就设置错误指示器
当输出流上发生写错误时, 也会设置错误指示器
匹配失败不会改变任意一个指示器
一旦设置了错误或文件末尾指示器, 它就会保持该状态直到被显式清除, 可能通过调用 clearerr 函数.
clearerr 清除文件末尾和错误指示器:
clearerr(fp);
/* clears eof and error indicators for fp */
并不需要经常使用 clearerr, 因为其他一些库函数的副作用可以清除指示器.
feof 和 ferror 函数可用于测试流的指示器, 以确定先前对流的操作失败的原因.
如果为与 fp 关联的流设置了文件末尾指示器, 则调用 feof(fp) 将返回一个非零值.
如果设置了错误指示器, 调用 ferror(fp) 会返回一个非零值.
当 fscanf 返回小于预期的值时, 可以使用 feof 和 ferror 来判断原因.
如果 feof 返回非零值, 则已到达输入文件的末尾
如果 ferror 返回非零值, 则在输入期间发生读取错误
如果两者都没有返回非零值, 则一定发生匹配失败
fscanf 的返回值表示在问题发生之前成功匹配并赋值的项数.
注意: 不要用 while (!feof(fp)) 控制循环. feof 只有在一次读取尝试碰到文件末尾后才变为真, 循环体会多执行一次, 处理上一次留下的旧数据. 正确做法是检查读取函数的返回值:
while (fgets(line, sizeof(line), fp) != NULL) { … }
while ((ch = fgetc(fp)) != EOF) { … }
find_int 函数是一个示例, 它显示了如何使用 feof 和 ferror.
find_int 在文件中搜索以整数开头的行: n = find_int("foo");
find_int 返回它找到的整数的值或错误代码: -1 文件无法打开; -2 读取错误; -3 没有行以整数开头. 核心循环 (简化):
int n, ch;
while (fscanf(fp, "%d", &n) != 1) {
if (feof(fp)) return -3; /* 没有找到 */
if (ferror(fp)) return -2; /* 读取出错 */
/* 匹配失败: 跳过本行剩余字符, 读下一行 */
while ((ch = fgetc(fp)) != '\n' && ch != EOF)
;
}
return n;
每个流都有一个文件位置。
打开文件时, 文件位置在开头。
"追加"模式下, 初始位置可能在开头也可能在结尾, 看具体实现。
每次读写后, 文件位置自动前进——所以是顺序访问。
尽管顺序访问对许多应用程序来说都很好, 但有些程序需要能够在文件中跳转. 如果文件包含一系列记录, 我们可能希望直接跳转到特定记录.
<stdio.h> 中常用的文件定位函数是 fseek / ftell / rewind.
ftell(fp) 返回当前文件位置 (距文件开头的字节数); rewind(fp) 把文件位置移到开头, 等价于 fseek(fp, 0L, SEEK_SET).
fseek 修改第一个参数 (文件指针) 的文件位置, 第三个参数是三个宏之一:
SEEK_SET: 文件开头
SEEK_CUR: 当前文件位置
SEEK_END: 文件结尾
第二个参数类型为 long int, 是一个(可能是负数)字节计数. 出错时 (比如请求的位置不存在), fseek 返回非零值.
使用 fseek 移动到文件的开头:
fseek(fp, 0L, SEEK_SET);
使用 fseek 移动到文件末尾:
fseek(fp, 0L, SEEK_END);
使用 fseek 往回移动 10 个字节:
fseek(fp, -10L, SEEK_CUR);
/* Modifies a file of part records by setting the quantity
on hand to zero for all records */
#include <stdio.h>
#include <stdlib.h>
#define NAME_LEN 25
#define MAX_PARTS 100
struct part {
int number;
char name[NAME_LEN+1];
int on_hand;
} inventory[MAX_PARTS];
int num_parts;
int main(void)
{
FILE *fp;
if((fp = fopen("inventory.dat", "rb+")) == NULL) {
fprintf(stderr, "Can't open inventory file\n");
exit(EXIT_FAILURE);
}
num_parts = fread(inventory, sizeof(struct part),
MAX_PARTS, fp);
for(int i = 0; i < num_parts; i++)
inventory[i].on_hand = 0;
rewind(fp);
fwrite(inventory, sizeof(struct part), num_parts, fp);
fclose(fp);
return 0;
}
问题: 把 inventory.dat 中每条 struct part 记录的 on_hand 清零.
invclear.c 程序执行的操作:
打开包含 struct part 结构的二进制文件
将结构读入数组, 把每个结构的成员 on_hand 设置为 0
将结构写回文件
"rb+" 模式打开文件, 允许读写. 样例: 文件中原有两条记录, 运行前后:
运行前: 1 Widget 10 2 Gadget 25
运行后: 1 Widget 0 2 Gadget 0
⚡ 在 VS Code 中打开 invclear.c
现场演示 (在 skeleton.c 中完成):
int count_lines(const char *filename): 用 fgetc 统计文件中的换行符个数; 文件打不开返回 -1
void print_numbered(const char *filename): 用 fgets 逐行读文件, 每行前打印行号 (样例: 1 abc)
int sum_ints(const char *filename): 用 fscanf 读文件中的所有整数并求和
提示: 用读取函数的返回值判断循环是否结束, 不要用 while (!feof(fp))
1. 概念: fopen("data.txt", "r") 与 "w" 有什么区别?各自返回什么?
2. 概念: fgetc / fputc 的返回值为什么是 int 而不是 char?
3. 代码阅读: in.txt 的内容为两行 abc、def, 下面的循环输出什么?
char line[100];
while (fgets(line, sizeof(line), fp) != NULL)
fprintf(stdout, "%s", line);
4. 纠错: 下面的循环有什么问题?应如何修改?
while (!feof(fp)) {
ch = fgetc(fp);
putchar(ch);
}