【日报】浅谈C与C++风格的文件IO及其它

囧仙

2021-09-09 22:39:52

Tech. & Eng.

前言

\text{C} 语言时代,\text{C} 提供了一系列的函数用于输入输出。而在 \text{C++} 时代,一套全新的输入输出的库被建立。由于 \text{C++}\text{C} 的兼容要求(即一个 \text{C++} 编译器应当也能正常编译几乎所有 \text{C} 语言编写的源代码),因此同时保留了两种输入输出方案。

本文将会在简要介绍 \text{C}\text{C++} 两种风格的文件 \textsf{IO} 的同时,着眼于这两者的差别,以及我对它们的看法。\text{cpp reference}\text{C++} 提供的这套方案称为现代的、基于流的 \textsf{IO} 库。下文为了叙述方便,姑且把它称作 \text{C++} 风格的 \textsf{IO} 吧。

在下文的 \text{C++} 部分的代码中,默认使用了标准命名空间(\verb!namespace std!)。

从库文件组织谈起

都有哪些头文件

\text{C} 风格里,用户一般而言最多只用调用两个库:

\verb!stdio.h! 中,包含了了 \text{C} 风格的文件 \textsf{IO}\verb!fopen!,\verb!freopen!,\verb!fclose! 云云)、一些可以用来输入输出字符串的函数(无格式输入输出),以及 \text{C} 风格 \textsf{IO} 里最常用到的 \verb!scanf!,\verb!printf!,包括它们的变体(有格式输入输出)。要注意的是,这里所说的“字符串”指的是 \text{C} 风格字符串(或者可以当成字符数组)。

一个重要的区别是,$\text{C}$ 风格字符串以 $\verb!\0!$ 作为末尾($\text{ASCII}$ 码为 $0$)。处理 $\text{C}$ 风格字符串的函数都用它作为字符串的结尾。带来的问题,就是这些函数不能正确识别把 $\verb!\0!$ 作为元素的字符串。而 $\text{string}$ 则全无这个问题,因为 $\text{string}$ 本身是一个容器,存储在一个类里。因为 $\text{C}$ 风格字符串本质上还是字符数组,所以常常导致使用起来没有 $\text{string}$ 方便。同时也可以解释为什么 $\verb!strlen!$ 复杂度为什么是 $\mathcal O(n)$,而 $\text{string}$ 里的 $\verb!length!$ 复杂度则是 $\mathcal O(1)$。

相较而言,\text{C++} 对头文件的组织则复杂得多。这里列举出一些比较常见的标准库。

都有哪些不同的类

对于上述提到的一堆 \verb!basic_istream!,\verb!basic_ostream! 等等类型的继承关系,在 \text{cpp reference} 上有一张比较直观的图表:

对于 \verb!basic_ios!,\verb!basic_ostream!,\verb!basic_istream!,\verb!basic_streambuf! 以及 \verb!basic_iostream!,它们都是抽象的。也就是说,在加工之前它们并不能用于对实际的东西进行 \textsf{IO} 操作。两个标准函数库 \verb!fstream!,\verb!sstream! 具体化了这些抽象的流,被具体化后的流就可以针对文件/字符串进行 \textsf{IO} 了。此外,\verb!cin!,\verb!cout! 等标准流相当于使用了特殊的方式具体化了抽象的流。

缓冲区

从概念说起

对于“缓冲区”这个概念,我们可以从平常使用的快读入手。一个比较常见的读取一个整数的快读写法如下:

template<typename Int>
Int read(Int &w){
    char c; w=0; int sgn = 1;
    while(!isdigit(c = getchar())) sgn = c =='-' ? -1 : 1;
    w = c - '0';
    while( isdigit(c = getchar())) w = w * 10 + c - '0';
    return w *= sgn;
}

(注意:该写法存在点小问题。用它读取的数字为该整数类型下的最小值,可能会发生溢出。)

但是每次都从标准输入中一个一个地读取字符是很浪费时间的。因此,我么可以使用 \verb!fread!先把一堆字符读取到一个字符数组内,然后每次从该字符数组取一个字符。这样可以缩短每次从文件里取字符带来的时间浪费。这么做就相当于缓冲了。

const int SIZ = 1 << 20;
char buf[SIZ], *p1, *p2;
char readc(){
    if(p1 == p2) p1 = buf, p2 = buf + fread(buf, 1, SIZ, stdin);
    return p1 == p2 ? EOF : *p1 ++;
}
template<typename Int>
Int read(Int &w){
    char c; w=0; int sgn = 1;
    while(!isdigit(c = getchar())) sgn = c =='-' ? -1 : 1;
    w = c - '0';
    while( isdigit(c = getchar())) w = w * 10 + c - '0';
    return w *= sgn;
}

在这里,我们使用了字符数组 \verb!buf! 作为输入时的缓冲区。同样地,我们可以设置一个输出时的缓冲区:

#include <stdio.h>
#include <string.h>
const int SIZ = 1 << 10;
char buf[SIZ], *p1, *p2;

void init(){
    p1 = buf, p2 = buf + SIZ;
}
void flush(){
    fwrite(buf, 1, p1 - buf, stdout);
    p1 = buf, p2 = buf + SIZ;
}
void writec(const char c){
    if(p1 == p2) flush();
    *p1++ = c;
}
void writes(const char s[]){
    for(int i = 0, l = strlen(s); i < l; ++ i)
        writec(s[i]);
}
struct FileIO{
     FileIO(){init ();}
    ~FileIO(){flush();}
}__fileio;
int main(){
    writes("114514\n"), writes("1919810\n");
    return 0;
}

\text{C}\text{C++} 两种风格的 \textsf{IO} 当中,同样存在缓冲区。

对于 \text{C} 风格的 \textsf{IO} ,标准库里提供了 \verb!setbuf!,\verb!setvbuf! 两个函数用于设置缓冲区。此外,\text{C} 风格的 \textsf{IO} 提供了一些不同的缓冲策略:全缓冲、行缓冲、无缓冲。全缓冲,就是当且仅当缓冲区满了,或者程序结束运行时刷新缓冲区;行缓冲,就是在发生换行时刷新缓冲区;无缓冲,也就是每个字符不经过缓冲区。如下是一个简单的例子:

#include<stdio.h>
const int SIZ = 1 << 10;
char buf[SIZ];
int main(){
    unsigned a=0;
    setbuf(stdout, buf);
    printf("Begin calculating\n");
    for(int i = 0;i < 1e5; ++ i)
        for(int j = 0;j < 1e5; ++ j)
            a += 1u * i * j;
    printf("End.Result = %u\n", a);
    return 0;
}

当运行该程序时,可以发现经过一段时间后, Begin calculatingEnd.Result = 2607003904 是被同时输出的(也就是在刷新缓存区时同时被打印到屏幕上)。

--- 从这里看上去,$\text{C}$ 和 $\text{C++}$ 完全是两套不同的 $\textsf{IO}$ 系统嘛。但为什么我们可以将这两者进行混用,而不会发生任何问题呢?这就要讲到 $\text{C++}$ 对 $\text{stdio}$ 进行的**绑定**。 - $\verb!cin!$ 绑定上了 $\verb!stdin!$。例如,当我们使用 $\verb!freopen!$ 函数对 $\verb!stdin!$ 进行重定向时,$\verb!cin!$ 也会被重定向到这个文件上。 - $\verb!cout!$ 绑定上了 $\verb!stdout!$。例如,当我们使用 $\verb!freopen!$ 函数对 $\verb!stdout!$ 进行重定向时,$\verb!cout!$ 也会被重定向到这个文件上。 - $\verb!clog!$ 和 $\verb!cerr!$ 绑定上了 $\verb!stderr!$。两者的区别在于,$\verb!clog!$ 会使用缓冲区,但是 $\verb!cerr!$ 不会。 事实上,$\verb!cin!,\verb!cout!$ 等八个标准流(即窄字符的 $4$ 个和一一对应的宽字符的 $4$ 个),通过一些方式(例如 $\verb!ios_base!$ 里面的那个 $\verb!Init!$)具体化了那些抽象的流。 > 可能作为 $\text{OIer}$ 不怎么会听说过 $\verb!stderr!$ 这种东西。事实上,除了 $\verb!stdout!$ 作为标准输出以外,$\text{C}$ 标准里还定义了 $\verb!stderr!$。它是标准错误,与 $\verb!stdout!$ 有点类似,但是它使用的是**无缓冲**(这一点很容易解释。当程序发生错误需要使用 $\verb!stderr!$ 时,该程序有可能是不能正常终止了。如果不能正常终止,那么缓冲区内的东西就不能正常输出。因此标准错误不应当使用缓冲区)。 > 以造数据为例简单讲下它的应用。如果你需要写一个程序造一条题目的数据,在将 $\verb!stdout!$ 重定向为 $\verb!.out!$ 文件后,你可能还会要向控制台输出一些信息(比如当前造了多少个数据、$\text{std}$ 跑了多长时间)。此时使用 $\verb!stderr!$ 就会比较方便。关于文件的操作,在下文我们还会提到。 为了防止出现 $\verb!scanf!,\verb!printf!$ 和 $\verb!cin!,\verb!cout!$ 使用的缓冲区不同步的情况,默认情况下 $\text{C++}$ 的标准流,在进行 $\textsf{IO}$ 时,使用的是 $\text{C}$ 流的缓冲区。 ```cpp #include<stdio.h> #include<string.h> #include<iostream> const int SIZ = 1 << 10; char buf[SIZ], tmp[SIZ]; int main(){ setbuf(stdout, buf); std::cout << "114514\n"; memcpy(tmp, buf, strlen(buf)); std::cout << tmp << std::endl; return 0; } /* 程序输出为: 114514 114514 */ ``` 在这个例子里,我们可以发现 $\verb!cout!$ 的输出直接被导入了 $\verb!stdout!$ 的缓冲区 $\verb!buf!$ 里面。尽管实际并没有输出到设备上,这么做仍然是很浪费时间的。也因此,$\verb!cin!$ 和 $\verb!cout!$ 的效率往往不及 $\verb!scanf!$ 和 $\verb!printf!$。但是有一个函数 $\verb!ios_base::sync_with_stdio!$ 可以**解除绑定**。解除绑定后,$\verb!cin!,\verb!cout!$ 等等将会使用流特有的缓冲区进行缓冲。因此,这时候混用 $\verb!scanf!,\verb!printf!$ 和 $\verb!cin!,\verb!cout!$ 可能会导致一些问题。 此外,在 $\verb!cin!$ 和 $\verb!cout!$ 之间也存在绑定关系。也就是每当 $\verb!cin!$ 要执行输入操作之前,它所绑定的输出流 $\verb!cout!$ 会刷新缓冲区(这种绑定常见于两个流共用一个文件)。使用 $\verb!cin.tie(NULL)!$($\text{C++11}$ 之前)或者 $\verb!cin.tie(nullptr)!$($\text{C++11}$ 及以后。感谢评论区指出)可以解除它俩的绑定。 最后再提及一下两套输入输出方案如何刷新缓冲区。在 $\text{C}$ 风格 $\textsf{IO}$ 里,可以使用 $\verb!fflush!$ 函数刷新一个文件指针的缓冲区;而在 $\text{C++}$ 风格输入输出里,可以使用对应的流的 $\verb!flush!$ 函数刷新,例如 $\verb!cout.flush()!$。值得注意的是,当在流里使用了 $\verb!endl!$ 时,除了输出换行,**也会发生缓冲区**。但除非是交互题,我不建议使用 $\verb!endl!$ 当作换行。频繁地刷新缓冲区会大大降低 $\textsf{IO}$ 效率。还有一个注意点是,使用 $\verb!fflush!$ 刷新 $\text{C}$ 风格 $\textsf{IO}$ 的输入流属于**未定义行为**,而 $\verb!ifstream!$ 压根没有 $\verb!flush!$,从根本上杜绝了用户产生 $\textsf{UB}$ 的可能性。 ## 输入输出不同类型 ### 标准库内的类型 我想,大多数人在初学 $\text{C++}$ 的输入输出时都会接触过 $\verb!scanf!$ 和 $\verb!printf!$、$\verb!cin!$ 和 $\verb!cout!$ 吧。有相当多的入门语法题都会牵扯到不同类型的 $\textsf{IO}$ 问题。 $\text{C}$ 风格里使用的是**格式指定字符**来告诉 $\textsf{IO}$ 函数将要输入的是什么类型的量。这里举几个格式指定符的简单例子: - 使用 $\verb!c!$ 输入输出一个 $\verb!char!$ 类型的字符。 - 使用 $\verb!d!$ 输入输出一个 $\verb!int!$ 类型的带符号整数。如果要输入输出 $\verb!long long!$,那就得用 $\verb!lld!$。如果你要输出的东西是不带符号的整数,那就要把 $\verb!d!$ 改成 $\verb!u!$(比如 $\verb!u!,\verb!llu!$)。 - 使用 $\verb!f!$ 输入输出一个单精度浮点数,使用 $\verb!lf!$ 输入输出一个双精度浮点数。特别地,在 $64$ 位的机子上想要使用 $\verb!long double!$ 你就得用 $\verb!Lf!$ 或者 $\verb!I64f!$(这东西还取决于不同的机器,因为标准里并没有强制要求实现 $\verb!long double!$)。 - 使用 $\verb!s!$ 输入输出一个 $\text{C}$ 风格字符串。这点可能是有些人不清楚的。 - $\cdots

我们来看看 \text{C++} 是怎么做的:

无论是什么预先定义好的类型,直接使用 \verb!cin>>x!\verb!cout<<x! 这样的形式就可以对它进行输入输出了。此外,\verb!cin!\verb!cout! 还支持直接输入输出一个字符串(\verb!string!),并且也可以直接输入输出一个 \text{C} 风格的数组。

自定义新类型的输入输出

谈及到对于用户自己定义的类型的 \textsf{IO},就能发现 \text{C} 风格 \textsf{IO} 无法处理标准以外的类型。而 \textsf{C++} 风格 \textsf{IO},可以通过运算符重载的方式,实现更多类型的输入输出。因为本质上,\textsf{C++} 风格 \textsf{IO} 是用 \verb!istream!,\verb!ostream! 对流提取、流插入运算符的重载实现的。我们当然可以添加新的重载来让其支持更多的类型。举个例子,现在要实现一种一种高精度类 \verb!bigint!。它的各种运算已经完成,我们需要对它添加输入输出的方法。

输入输出的格式化

想必初学者在做入门语法题时,经常会碰到这样的一些问题:

如果使用 \text{C} 风格 \textsf{IO},通过在格式控制字符串里添加一些东西,可以这样解决上述三个问题:

#include<stdio.h>
int a, b, c; double f; unsigned long long x;
int main(){
    a = 100, b = 200, c = -300;
    f = 1.114514;
    x = 1145141919810ull;
    printf("%.5lf\n", f);
    printf("%+-8d%+8d%+8d\n", a, b, c);
    printf("%llX\n", x);
    return 0;
}

\text{C} 风格 \textsf{IO} 里提供了一套相对复杂的格式控制方式用来实现这些功能。由于这部分内容过多,不一一列举,读者可以查看\text{cpp reference} 上面的描述。对于初学者,这种方法需要一定时间去记忆及掌握,并且由于格式符的名称常常相当省略(比如 \verb!d! 全称是 \text{decimalism}\verb!o! 全称是 \text{octal number}\verb!h! 全称是 \text{hexadecimal}),不大容易让人联想到这是什么。如果哪一天标准里想要加一些新的内容,可能会导致这里的东西更加混乱。

那么在 \text{C++} 提供的 \textsf{IO} 里,我们可以怎样解决这些问题呢?

#include<iostream>
#include<iomanip>
using namespace std;
int a, b, c; double f; unsigned long long x;
int main(){
    a = 100, b = 200, c = -300;
    f = 1.114514;
    x = 1145141919810ull;
    cout << std::fixed << setprecision(5) << f << '\n';
    cout << std::showpos <<
        setw(8) << left  << a <<
        setw(8) << right << b <<
        setw(8) << right << c << '\n';
    cout << uppercase << hex << x << '\n';
    return 0;
}

好吧,这套 \text{C++} 的方法的确大大增加了码量,并且看上去比 \text{C} 风格的控制字符复杂太多了。但是很显然地,它的可读性更高。这是 \text{C++} 风格 \textsf{IO} 的一个优点,即通过一些文字化的叙述提高了代码的可读性。

\text{C++} 里,有这样的一些格式化标志:

特别地,对于 \verb!boolalpha!,\verb!showbase!,\verb!showpoint!,\verb!showpos!,\verb!skipws!,\verb!unitbuf!,\verb!uppercase! 可以在它前边加上 \verb!no!,含义与原来相反。例如 \verb!noshowpos! 就是不为非负数值输出生成 \verb!+! 字符。

还记得之前提到过的 \verb!iomanip! 库吗?这里面也添加了一些新的操作:

文件/字符串输入输出

打开一个文件

在 $\verb!stdio.h!$ 里,有一个叫做 $\verb!FILE!$ 的类型。不过我们不能直接创建这个类型,而是应该使用 $\verb!fopen!$ 函数产生一个以指定的文件访问方式打开指定的文件的 $\verb!FILE!$ 类型的**指针**($\verb!FILE*!$)。 > 简要介绍一下在 $\text{C}$ 里面提供的文件访问的模式,也就是 $\verb!fopen!$ 的第二个参数、$\verb!freopen!$ 的第三个参数。 > - $\verb!r!$:为了读取而**打开**一个文件。如果文件不存在则打开失败。 > - $\verb!w!$:为了写入而**创建**一个文件。如果文件不存在则创建一个,否则**销毁**里面的内容。 > - $\verb!a!$:为了写入而**打开**一个文件。如果文件不存在则创建一个,否则**追加**到末尾。 > - 特别地,$\text{C}$ 里提供了一个字符 $\text{``\texttt{+}''}$,放置在三个字符,表示**扩展**的含义(扩展读、扩展写、扩展追加)。加上加号后含义都修改成了“为了写入或者读取”。也就是说,你可以在同一个文件里写入与读取。 > - 此外,你还可以追加一个字符 $\verb!b!$,含义是以二进制形式打开。二进制模式会在下文说明。 如果我们要创建一个指针 $\mathit{iof}$,以输出为目的打开文件 $\text{test.txt}$,那就可以 $\verb!FILE *iof=fopen("test.txt","w")!$。当你创建了文件指针后,你就可以使用 $\verb!fscanf!,\verb!fprintf!$ 等等变体函数(包括 $\verb!fgetc!,\verb!fgets!$ 之类,相当于在标准的那些函数前面加上了一个个字符 $\verb!f!$)对该文件进行读写。 然而,在 $\text{C++}$ 风格的 $\textsf{IO}$ 里,我们应当使用继承了 $\verb!basic_istream!,\verb!basic_ostream!,\verb!basic_iostream!$ 而诞生的 $\verb!ifstream!,\verb!ofstream!,\verb!fstream!$ 来进行针对文件 $\textsf{IO}$ 的一系列流。可以使用对应类型的 $\verb!open!$ 子函数打开相应文件。例如: ```cpp int main(){ ofstream fio; fio.open("test.txt"); } ``` 当然了,$\verb!ios_base!$ 里也定义了很多文件打开标志,用来在 $\text{C++}$ 风格的 $\textsf{IO}$ 里给流打上标记。 - $\verb!app!$:每次写入前寻位到流结尾。 - $\verb!binary!$:以二进制模式打开。 - $\verb!in!$:为读打开。 - $\verb!out!$:为写打开。 - $\verb!trunc!$:在打开时舍弃流的内容。 - $\verb!ate!$:打开后立即寻位到流结尾。 这些标记是可以进行叠加的,就和 $\text{C}$ 风格类似。这些打开标志被重载了位运算的相关运算符(与、或、异或等),因此可以使用形如 $\verb!ios_base::in | ios_base::out | ios_base::ate!$ 的形式进行叠加。 容易发现,它们可以与 $\text{C}$ 风格的 $\textsf{IO}$ 相对应。并且比起 $\text{C}$ 风格的那些不明所以的字母及记号,**可读性**提高了很多。此外,即使你没有写明文件打开标志,输入流标记里必有 $\verb!ios_base::in!$,输出流的标记里必有 $\verb!ios_base::out!$,混合流则是两个都有。这是因为你定义了输入/输出/混合流的变量时,已经告诉了编译器它的基础类型是什么。 ### 打开一个字符串 同样地, $\text{C}$ 风格 $\textsf{IO}$ 定义了一些标准 $\textsf{IO}$ 函数地变体用于向一个字符串里输入输出。比如 $\verb!sscanf!,\verb!sprintf!$。不同的是,$\text{C++11}$ 里还添加了一个 $\verb!snprintf!$,用来限制输出的内容的长度(因为是输出到 $\text{C}$ 风格字符串里嘛,如果不限制长度可能会出现溢出之类的问题)。$\verb!gets!$ 被踢出标准也正是因为它没能限制读入的字符串地最大长度,导致了可能的溢出风险。 然而 $\text{C++}$ 则是通过 $\verb!fstream!$ 和 $\verb!sstream!$ 两个库,分别定义了针对于文件和字符串的流。在之前章节提到了两种风格对于不同类型的变量的输入输出的问题。仍然以之前的 $\verb!bignum!$ 举例,你会发现**它只能从** $\verb!stdin!,\verb!stdout!$ 里进行输入输出。如果你想要将它支持文件输入输出和字符串输入输出,那么就得重新实现一下,比较麻烦。但是在 $\text{C++}$ 风格里,**你只要**对于 $\verb!istream!$ 和 $\verb!ostream!$ 重载一下流插入运算符和右移运算符,就可以任意地在标准流、文件流、字符串流里面使用,具有极高的可拓展性。 ### 二进制模式与文本模式 有时候,流需要对**文本内的**一些特殊的字符进行处理。比如: - $\text{Windows}$ 下的换行,应该由两个字符组成($\verb!\r\n!$)。但是使用 $\text{C}$ 风格文件输入后,所有的 $\verb!\r\n!$ 应该被转换为单个字符 $\verb!\n!$ 而被读入;同时,使用 $\text{C}$ 风格文件输出后,输出时所有的 $\verb!\n!$ 会被转换为 $\verb!\r\n!$ 而被输出。同样地,在输入时 $\text{Mac}$ 下的换行字符 $\verb!\r!$ 也会被转换为 $\verb!\n!$,输出时 $\verb!\n!$ 转换为 $\verb!\r!$。 - 在 $\text{Windows}$ 下 $\verb!\x1A!$(也就是 $\text{ASCII}$ 码为 $26$ 的字符)会被认为是文件的终止符,读取到它之后就会终止文件的读入并返回 $\verb!EOF!$(即 $\text{End Of File}$)了。 - 不过,在 $\text{Linux}$ 下,文本模式不会进行任何操作(因为本来也没有任何操作)。 但是有时我们并不是以文本为目的而访问一个文件。因此,我们**不希望**这些函数对特殊字符进行任何的处理。要注意的是,即使你使用了二进制模式,$\verb!scanf!,\verb!printf!,\verb!cin!,\verb!cout!$ 等等并不会以二进制数码的形式输出相关数字。 默认情况下,我们都是以文本模式进行读写的。在 $\text{C}$ 风格 $\textsf{IO}$ 里,是通过文件访问模式字符 $\verb!b!$ 实现二进制模式,而在 $\text{C++}$ 风格 $\textsf{IO}$ 里则是通过文件打开标志 $\verb!ios_base::binary!$ 实现。 ## 总结 比起 $\text{C}$ 风格输入输出,$\text{C++}$ 风格的这套输入输出的很大的优点就是更高的代码可读性。同时,还提高了流的可扩展性。此外,$\text{C++}$ 使用特化的方式避免了在运行时不断解析格式串带来的时间开销,因此其实是一个**更为高效**的方案(尽管由于缓存区同步的问题有时还比 $\text{C}$ 风格慢点)。 当然,这两者各有优劣。比如说,对于算法竞赛的用户,掌握 $\verb!scanf!$ 和 $\verb!printf!$ 足矣。我们确实用不到很多 $\text{C++}$ 风格的优势,甚至有些优势还成为了劣势。但不可否认的是,$\text{C++}$ 对 $\text{C}$ 的兼容使得同时使用两套方案成为了可能,也给予了用户更多的选择。 ## 参考资料 - $\text{cpp reference}$ - <https://zh.cppreference.com/>。