| 目录 |

9 文件输入输出

我们已经见过一些用 printf() 在控制台做输入输出的例子。

但本章我们会把这些概念再往前推进一步。

9.1 FILE* 数据类型

在 C 中进行任何形式的 I/O,都要通过一份数据来完成,它以 FILE* 类型的形式提供给你。这个 FILE* 保存着与 I/O 子系统通信所需的全部信息:你打开的是哪个文件、你位于文件中的什么位置,等等。

规范把它们称为流,也就是来自文件或任何来源的一股数据流。我会把“文件”和“流”混用,但实际上你应当把“文件”视为“流”的一种特例。除了从文件读取之外,还有别的方式可以把数据流式地送入程序。

稍后我们就会看到,如何从一个文件名得到一个为它打开的 FILE*;但首先我想提一下三个已经为你打开、随时可用的流。

FILE* 名称 说明
stdin 标准输入,默认通常是键盘
stdout 标准输出,默认通常是屏幕
stderr 标准错误,默认通常也是屏幕

事实证明,我们其实一直在隐式地使用它们。例如,下面两次调用是相同的:

printf("Hello, world!\n");
fprintf(stdout, "Hello, world!\n");  // 用 printf 写入文件

不过这个以后再细说。

你还会注意到,stdout 和 stderr 都输出到屏幕。乍看之下,这要么像是疏忽,要么像是冗余,但其实都不是。典型的操作系统允许你把这两者的输出重定向到不同的文件里,而能把错误消息与常规的非错误输出分开,往往很方便。

例如,在类 Unix 系统的 POSIX shell(如 sh、ksh、bash、zsh 等)中,我们可以运行一个程序,把非错误(stdout)输出送到一个文件,把所有错误(stderr)输出送到另一个文件。

./foo > output.txt 2> errors.txt   # 此命令仅适用于 Unix

正因如此,严重的错误消息应当发送到 stderr,而不是 stdout。

具体怎么做,后面再说。

9.2 读取文本文件

流大体上分为两类:文本和二进制。

文本流允许对数据做大量转换,最显著的是把换行符转换为其不同的表示形式75。文本文件在逻辑上是由换行符分隔的一系列行。为保证可移植性,你的输入数据应始终以换行符结尾。

但一般的判断标准是:如果你能在普通文本编辑器里编辑这个文件,它就是文本文件。否则就是二进制文件。二进制的内容后面再讲。

那就动手吧——我们怎样打开一个文件来读取,并从中取出数据呢?

我们来创建一个名为 hello.txt 的文件,里面只有这些内容:

Hello, world!

再写一个程序来打开该文件,从中读出一个字符,然后在完成后关闭文件。这就是我们的计划!

#include <stdio.h>

int main(void)
{
    FILE *fp;                      // 表示已打开文件的变量

    fp = fopen("hello.txt", "r");  // 以读取方式打开文件

    int c = fgetc(fp);             // 读取单个字符
    printf("%c\n", c);             // 把字符打印到 stdout

    fclose(fp);                    // 用完后关闭文件
}

看到没有,我们用 fopen() 打开文件时,它把 FILE* 返回给了我们,以便之后使用。

(为了简洁我省略了,但如果出了什么问题,比如文件找不到,fopen() 会返回 NULL,所以你真的应该对它做错误检查!)

另外注意我们传入的 "r"——它的意思是“以读取方式打开一个文本流”。(还有多种字符串可以传给 fopen(),带有额外含义,比如写入、追加等等。)

之后,我们用 fgetc() 函数从流中取一个字符。你可能会纳闷,我为什么把 c 声明成 int 而不是 char——先记住这个问题!

最后,用完之后我们关闭流。程序退出时所有流都会自动关闭,但在用完它们之后自己显式关闭任何文件,既是良好的风格,也是良好的管理习惯。

FILE* 会记录我们在文件中的位置。因此后续调用 fgetc() 会取到文件中的下一个字符,再下一个,直到末尾。

但那听起来很麻烦。看看我们能不能让它更简单些。

9.3 文件末尾:EOF

有一个定义成宏的特殊字符:EOF。当已到达文件末尾、而你仍试图再读一个字符时,fgetc() 返回的就是它。

现在就来分享那个“有趣的事实™”吧。EOF 正是 fgetc() 及其同类函数返回 int 而不是 char 的原因。EOF 本身严格来说并不是一个字符,它的值很可能落在 char 的范围之外。由于 fgetc() 既需要能返回任意字节,又需要能返回 EOF,它就需要一种能容纳更多值的更宽类型。于是就用了 int。不过,除非你要把返回值与 EOF 比较,否则你可以在内心深处认为它就是 char。

好!回到现实!我们可以用它在一个循环里读完整份文件。

#include <stdio.h>

int main(void)
{
    FILE *fp;
    int c;

    fp = fopen("hello.txt", "r");

    while ((c = fgetc(fp)) != EOF)
        printf("%c", c);

    fclose(fp);
}

(如果第 10 行看起来太怪,就从最内层嵌套的括号开始拆解。我们做的第一件事是把 fgetc() 的结果赋给 c,然后再拿这个结果去和 EOF 比较。我们只是把它塞进了单独一行里。这看起来可能不易读,但好好研究一下——这是地道的 C 写法。)

运行一下,会看到:

Hello, world!

但仍然是一次只处理一个字符,而很多文本文件在行的层面上更有意义。我们改成按行处理吧。

9.3.1 逐行读取

那么怎样才能一次取得一整行呢?fgets() 来救场!它的实参有:一个指向用于存放字节的 char 缓冲区的指针、要读取的最大字节数,以及一个用于读取的 FILE*。它在文件末尾或出错时返回 NULL。fgets() 还很贴心,完成时会给字符串加上 NUL 终止符76。

我们来写一个和前面类似的循环,只不过这次用一个多行文件,每次读入一行。

这是 quote.txt 文件:

A wise man can learn more from
a foolish question than a fool
can learn from a wise answer.
                  --Bruce Lee

下面这段代码逐行读取该文件,并在每一行前面打印出行号:

#include <stdio.h>

int main(void)
{
    FILE *fp;
    char s[1024];  // 足够容纳本程序会遇到的任意一行
    int linecount = 0;

    fp = fopen("quote.txt", "r");

    while (fgets(s, sizeof s, fp) != NULL) 
        printf("%d: %s", ++linecount, s);

    fclose(fp);
}

输出为:

1: A wise man can learn more from
2: a foolish question than a fool
3: can learn from a wise answer.
4:                   --Bruce Lee

9.4 格式化输入

你知道可以用 printf() 得到格式化输出吧(因此,正如我们将在下面看到的,fprintf() 也可以)?

用 fscanf() 也能做同样的事。

开始之前要提醒你:处理不可信的输入时,使用 scanf() 一类的函数可能有危险。如果你不给 %s 指定字段宽度,就可能让缓冲区溢出。更糟的是,非法的数值转换会导致未定义行为。处理不可信输入的安全做法是:给 %s 配上字段宽度,然后用 strtol() 或 strtod() 这类函数来做转换。

我们用一个包含一系列数据记录的文件。这回记录的是鲸,包含名字、以米计的长度和以吨计的重量。whales.txt:

blue 29.9 173
right 20.7 135
gray 14.9 41
humpback 16.0 30

是的,我们可以用 fgets() 读入,再用 sscanf() 解析字符串(那样对损坏的文件更有韧性),不过这里我们就直接用 fscanf() 把数据拉进来。

fscanf() 函数在读取时会跳过前导空白,并在文件末尾或出错时返回 EOF。

#include <stdio.h>

int main(void)
{
    FILE *fp;
    char name[1024];  // 足够容纳本程序会遇到的任意一行
    float length;
    int mass;

    fp = fopen("whales.txt", "r");

    while (fscanf(fp, "%s %f %d", name, &length, &mass) != EOF)
        printf("%s whale, %d tonnes, %.1f meters\n", name, mass, length);

    fclose(fp);
}

结果如下:

blue whale, 173 tonnes, 29.9 meters
right whale, 135 tonnes, 20.7 meters
gray whale, 41 tonnes, 14.9 meters
humpback whale, 30 tonnes, 16.0 meters

9.5 写入文本文件

正如我们可以用 fgetc()、fgets() 和 fscanf() 读取文本流,我们也可以用 fputc()、fputs() 和 fprintf() 写入文本流。

为此,我们必须以写入模式 fopen() 该文件,也就是把 "w" 作为第二个实参传入。以 "w" 模式打开一个已存在的文件,会立刻把该文件截断为 0 字节,以便完全覆盖。

我们来写一个简单的程序,用各种输出函数生成一个文件 output.txt。

#include <stdio.h>

int main(void)
{
    FILE *fp;
    int x = 32;

    fp = fopen("output.txt", "w");

    fputc('B', fp);
    fputc('\n', fp);   // 换行符
    fprintf(fp, "x = %d\n", x);
    fputs("Hello, world!\n", fp);

    fclose(fp);
}

这会生成一个文件 output.txt,内容如下:

B
x = 32
Hello, world!

有趣的事实:既然 stdout 也是一个文件,你可以把第 8 行替换成:

fp = stdout;

那样程序就会输出到控制台,而不是输出到文件。试试看!

9.6 二进制文件 I/O

到目前为止,我们谈的都还只是文本文件。但早先还提到过另一种猛兽,叫做二进制文件,或者叫二进制流。

它们与文本文件的用法非常相似,只不过 I/O 子系统不会像处理文本文件那样对数据做任何转换。使用二进制文件时,你得到的就是原始的字节流,仅此而已。

打开文件时最大的区别在于,必须在模式中加上一个 "b"。也就是说,读取二进制文件要用 "rb" 模式打开,写入文件要用 "wb" 模式打开。

因为它是字节流,而字节流中可能含有 NUL 字符,而 NUL 字符在 C 中是字符串的结束标记,所以人们很少用 fprintf() 及其同族函数来操作二进制文件。

相反,最常用的函数是 fread() 和 fwrite()。这两个函数从流中读取或向流中写入指定数量的字节。

为了演示,我们来写两个程序。一个把一串字节值一次性写到磁盘上。第二个程序则一次读一个字节,并把它们打印出来77。

#include <stdio.h>

int main(void)
{
    FILE *fp;
    unsigned char bytes[6] = {5, 37, 0, 88, 255, 12};

    fp = fopen("output.bin", "wb");  // wb 模式代表 "write binary"!

    // 在 fwrite 的调用中,各实参依次是:
    //
    // * 指向要写入数据的指针
    // * 每"片"数据的大小
    // * 每"片"数据的数量
    // * FILE*

    fwrite(bytes, sizeof(char), 6, fp);

    fclose(fp);
}

fwrite() 中间那两个实参相当古怪。但基本上我们想告诉函数的是:“我们有这么大的数据项,要写那么多个。”如果你有一批固定长度的记录,又把它们放在一个数组里,这就很方便了。你只要告诉它一条记录的大小和要写多少条即可。

在上面的示例中,我们告诉它每条记录的大小是一个 char,并且有 6 条。

运行这个程序会得到一个文件 output.bin,但用文本编辑器打开它,看到的可不是什么友好的内容!这是二进制数据——不是文本。而且还是我随随便便编出来的二进制数据!

如果我用一个十六进制转储78程序来处理它,就能以字节形式看到输出:

05 25 00 58 ff 0c

许多 Unix 系统都自带一个叫 hexdump 的程序来做这件事。你可以像下面这样,用 -C(“canonical”,规范)选项来获得漂亮的输出:

$ hexdump -C output.bin
00000000  05 25 00 58 ff 0c                              |.%.X..|

00000000 是这一行输出在文件中的起始偏移量。05 25 00 58 ff 0c 是字节值(如果文件里的字节更多,这一行还会更长,每行最多 16 个字节)。右边竖线(|)符号之间,是 hexdump 尽力打印出的与这些字节对应的字符。如果字符不可打印,它就打印一个句点。在这个例子里,我们打印的只是随机的二进制数据,所以这部分输出全是乱码。但如果我们往文件里写的是一个 ASCII 字符串,就能在那里看到它。

这些十六进制值确实与我们写出的(十进制)值对得上。

但现在我们换个程序把这些数据读回来。这个程序会以二进制读取方式("rb" 模式)打开文件,并在一个循环里一次读一个字节。

fread() 有一个很妙的特点:它返回读到的字节数,遇到 EOF 时返回 0。所以我们可以一直循环到看见这个值为止,边循环边打印数字。

#include <stdio.h>

int main(void)
{
    FILE *fp;
    unsigned char c;

    fp = fopen("output.bin", "rb"); // rb 代表 "read binary"!

    while (fread(&c, sizeof(char), 1, fp) > 0)
        printf("%d\n", c);

    fclose(fp);
}

运行它,我们看到了最初的那些数字!

5
37
0
88
255
12

好耶!

9.6.1 struct 与数字的注意事项

正如我们在讲 struct 的那一节里所见,编译器可以随意给 struct 添加填充。不同的编译器做法可能不同。同一个编译器在不同的架构上做法可能不同。同一个编译器在相同的架构上做法还可能不同。

我想说的是:在你不知道填充会落在哪里的时候,直接把整个 struct 用 fwrite() 写到文件里,是不具备可移植性的。

怎么解决这个问题呢?先记住这一点——等我们看完另一个相关问题,再来看看有哪些办法。

数字!

原来并非所有架构在内存中表示数字的方式都一样。

来看一个简单的例子:用 fwrite() 写一个 2 字节的数。我们用十六进制来表示,这样每个字节都一目了然。最高有效字节的值是 0x12,最低有效字节的值是 0x34。

unsigned short v = 0x1234;  // 两个字节,0x12 和 0x34

fwrite(&v, sizeof v, 1, fp);

流里最终会是什么呢?

嗯,看起来应该是 0x12 后面跟着 0x34,对吧?

但如果我在自己的机器上运行它,并对结果做十六进制转储,得到的是:

34 12

它们反过来了!这是怎么回事?

这与所谓的架构字节序79有关。有些架构先写最高有效字节,有些则先写最低有效字节。

这意味着,如果你直接把一个多字节的数从内存里写出去,就无法以可移植的方式完成80。

浮点数也有类似的问题。大多数系统对浮点数采用相同的格式,但有些系统不是。没有任何保证!

那么……我们怎样才能解决数字和 struct 的这些全部问题,让数据以可移植的方式写出去呢?

概括来说,就是要序列化数据。这是一个通用术语,意思是把全部数据取出来,以一种你掌控的、众所周知的格式写出去,并且这种格式经编程可在所有平台上以相同方式工作。

如你所料,这个问题早已有解。有一堆序列化库可以拿来用,比如 Google 的 protocol buffers81,现成可用。它们会替你处理所有繁琐的细节,甚至还能让你 C 程序里的数据与支持相同序列化方法的其他语言互操作。

帮自己也帮大家一个忙!把二进制数据写入流时,请先序列化!这样一切都会保持良好且可移植,即使你把数据文件从一种架构传到另一种架构。


| 目录 |