专栏文章
【日报】浅谈C与C++风格的文件IO及其它
科技·工程参与者 35已保存评论 41
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 41 条
- 当前快照
- 1 份
- 快照标识符
- @mhz5rukx
- 此快照首次捕获于
- 2025/11/15 01:55 4 个月前
- 此快照最后确认于
- 2025/11/29 05:25 3 个月前
前言
在 语言时代, 提供了一系列的函数用于输入输出。而在 时代,一套全新的输入输出的库被建立。由于 对 的兼容要求(即一个 编译器应当也能正常编译几乎所有 语言编写的源代码),因此同时保留了两种输入输出方案。
本文将会在简要介绍 与 两种风格的文件 的同时,着眼于这两者的差别,以及我对它们的看法。 把 提供的这套方案称为现代的、基于流的 库。下文为了叙述方便,姑且把它称作 风格的 吧。
在下文的 部分的代码中,默认使用了标准命名空间()。
从库文件组织谈起
都有哪些头文件
在 风格里,用户一般而言最多只用调用两个库:
- :提供有窄字符输入/输出能力的函数。也就是窄字符(我们平时使用的 )的 库。从名字“标准 ”,就能看出它的作用了。
- :提供有宽字符输入/输出能力的函数。宽字符是类似于 一类的占用空间可能大于一个字节的字符类型。当然,打算法竞赛的 可能不大会用到它。
在 中,包含了了 风格的文件 ( 云云)、一些可以用来输入输出字符串的函数(无格式输入输出),以及 风格 里最常用到的 ,包括它们的变体(有格式输入输出)。要注意的是,这里所说的“字符串”指的是 风格字符串(或者可以当成字符数组)。
风格字符串可以被简单理解为字符数组。例如 。因此,一系列 提供的对于“字符串”的函数(比如常用的 等一大堆函数)都是针对字符数组的。而 的“字符串”,则是一个独立于数组的字符容器()。 的相关函数大多是以 类型的子函数实现的。例如 等等。
一个重要的区别是, 风格字符串以 作为末尾( 码为 )。处理 风格字符串的函数都用它作为字符串的结尾。带来的问题,就是这些函数不能正确识别把 作为元素的字符串。而 则全无这个问题,因为 本身是一个容器,存储在一个类里。因为 风格字符串本质上还是字符数组,所以常常导致使用起来没有 方便。同时也可以解释为什么 复杂度为什么是 ,而 里的 复杂度则是 。
相较而言, 对头文件的组织则复杂得多。这里列举出一些比较常见的标准库。
- :给出了输入/输出库中所有类的前置声明(在里面,你可以看到所有标准流类型的申明,但是没有具体的定义)。
- :定义了流缓冲(通过缓冲区可以极大提高效率)。
- :包含了 库会用到的头文件(比如 )还有一些诸如错误处理等等的头文件。
- :定义了 和 。
- :定义了 。
- :定义了数个标准流对象。其中就有我们经常使用的 等。
- :针对文件输入输出,定义了该系列的缓冲区、输入流、输出流、混合流。
- :针对字符串输入输出,定义了该系列的缓冲区、输入流、输出流、混合流。
- :格式化输入与输出的辅助函数。
都有哪些不同的类
对于上述提到的一堆 等等类型的继承关系,在 上有一张比较直观的图表:
对于 以及 ,它们都是抽象的。也就是说,在加工之前它们并不能用于对实际的东西进行 操作。两个标准函数库 具体化了这些抽象的流,被具体化后的流就可以针对文件/字符串进行 了。此外, 等标准流相当于使用了特殊的方式具体化了抽象的流。
缓冲区
从概念说起
对于“缓冲区”这个概念,我们可以从平常使用的快读入手。一个比较常见的读取一个整数的快读写法如下:
CPPtemplate<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;
}
(注意:该写法存在点小问题。用它读取的数字为该整数类型下的最小值,可能会发生溢出。)
但是每次都从标准输入中一个一个地读取字符是很浪费时间的。因此,我么可以使用 ,先把一堆字符读取到一个字符数组内,然后每次从该字符数组取一个字符。这样可以缩短每次从文件里取字符带来的时间浪费。这么做就相当于缓冲了。
CPPconst 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;
}
在这里,我们使用了字符数组 作为输入时的缓冲区。同样地,我们可以设置一个输出时的缓冲区:
CPP#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;
}
在 和 两种风格的 当中,同样存在缓冲区。
对于 风格的 ,标准库里提供了 两个函数用于设置缓冲区。此外, 风格的 提供了一些不同的缓冲策略:全缓冲、行缓冲、无缓冲。全缓冲,就是当且仅当缓冲区满了,或者程序结束运行时刷新缓冲区;行缓冲,就是在发生换行时刷新缓冲区;无缓冲,也就是每个字符不经过缓冲区。如下是一个简单的例子:
CPP#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 calculating 和 End.Result = 2607003904 是被同时输出的(也就是在刷新缓存区时同时被打印到屏幕上)。 所采用的缓冲区则相对复杂一点。在抽象层面, 库定义了一个 。 库里定义了一个 继承了 ; 库里定义了一个 继承了 。具体的实现细节与上文类似,使用一块内存作为缓冲区,并使用一些指针指向头尾。
从这里看上去, 和 完全是两套不同的 系统嘛。但为什么我们可以将这两者进行混用,而不会发生任何问题呢?这就要讲到 对 进行的绑定。
- 绑定上了 。例如,当我们使用 函数对 进行重定向时, 也会被重定向到这个文件上。
- 绑定上了 。例如,当我们使用 函数对 进行重定向时, 也会被重定向到这个文件上。
- 和 绑定上了 。两者的区别在于, 会使用缓冲区,但是 不会。
事实上, 等八个标准流(即窄字符的 个和一一对应的宽字符的 个),通过一些方式(例如 里面的那个 )具体化了那些抽象的流。
可能作为 不怎么会听说过 这种东西。事实上,除了 作为标准输出以外, 标准里还定义了 。它是标准错误,与 有点类似,但是它使用的是无缓冲(这一点很容易解释。当程序发生错误需要使用 时,该程序有可能是不能正常终止了。如果不能正常终止,那么缓冲区内的东西就不能正常输出。因此标准错误不应当使用缓冲区)。
以造数据为例简单讲下它的应用。如果你需要写一个程序造一条题目的数据,在将 重定向为 文件后,你可能还会要向控制台输出一些信息(比如当前造了多少个数据、 跑了多长时间)。此时使用 就会比较方便。关于文件的操作,在下文我们还会提到。
为了防止出现 和 使用的缓冲区不同步的情况,默认情况下 的标准流,在进行 时,使用的是 流的缓冲区。
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
*/
在这个例子里,我们可以发现 的输出直接被导入了 的缓冲区 里面。尽管实际并没有输出到设备上,这么做仍然是很浪费时间的。也因此, 和 的效率往往不及 和 。但是有一个函数 可以解除绑定。解除绑定后, 等等将会使用流特有的缓冲区进行缓冲。因此,这时候混用 和 可能会导致一些问题。
此外,在 和 之间也存在绑定关系。也就是每当 要执行输入操作之前,它所绑定的输出流 会刷新缓冲区(这种绑定常见于两个流共用一个文件)。使用 ( 之前)或者 ( 及以后。感谢评论区指出)可以解除它俩的绑定。
最后再提及一下两套输入输出方案如何刷新缓冲区。在 风格 里,可以使用 函数刷新一个文件指针的缓冲区;而在 风格输入输出里,可以使用对应的流的 函数刷新,例如 。值得注意的是,当在流里使用了 时,除了输出换行,也会发生缓冲区。但除非是交互题,我不建议使用 当作换行。频繁地刷新缓冲区会大大降低 效率。还有一个注意点是,使用 刷新 风格 的输入流属于未定义行为,而 压根没有 ,从根本上杜绝了用户产生 的可能性。
输入输出不同类型
标准库内的类型
我想,大多数人在初学 的输入输出时都会接触过 和 、 和 吧。有相当多的入门语法题都会牵扯到不同类型的 问题。
风格里使用的是格式指定字符来告诉 函数将要输入的是什么类型的量。这里举几个格式指定符的简单例子:
- 使用 输入输出一个 类型的字符。
- 使用 输入输出一个 类型的带符号整数。如果要输入输出 ,那就得用 。如果你要输出的东西是不带符号的整数,那就要把 改成 (比如 )。
- 使用 输入输出一个单精度浮点数,使用 输入输出一个双精度浮点数。特别地,在 位的机子上想要使用 你就得用 或者 (这东西还取决于不同的机器,因为标准里并没有强制要求实现 )。
- 使用 输入输出一个 风格字符串。这点可能是有些人不清楚的。
我们来看看 是怎么做的:
无论是什么预先定义好的类型,直接使用 和 这样的形式就可以对它进行输入输出了。此外, 和 还支持直接输入输出一个字符串(),并且也可以直接输入输出一个 风格的数组。
-
不可否认的是,使用 的这套流的输入输出方案,要打一大堆的 和 ,相较于 风格的格式控制字符串要繁琐得多。比如,我们希望输入输出一个 位带符号整数 、一个字符 、一个双精度浮点数 ,两种写法分别如下:风格的 :CPP
scanf("%d%c%lf", &a, &b, &c); printf("%d %c %lf\n", a, b, c);而 风格的 ,需要这样写:CPPcin >> a >> b >> c; cout << a << " " << b << " " << c << '\n'; -
但是使用格式控制字符有一个坏处:如果你不知道你要输出的东西是什么类型(尤其是在一个较为复杂的式子里,可能会发生隐式转换),或者纯粹是误用了格式控制字符,那就可能出现一些问题。包括刚刚提到的 所使用的格式控制字符,由于不同编译器可能内部实现不一致(甚至有的编译器不支持 ),这么做可能会带来一系列问题。但是使用 风格的 ,类型的推导就交给了编译器。大大缩减了工作量。
自定义新类型的输入输出
谈及到对于用户自己定义的类型的 ,就能发现 风格 无法处理标准以外的类型。而 风格 ,可以通过运算符重载的方式,实现更多类型的输入输出。因为本质上, 风格 是用 对流提取、流插入运算符的重载实现的。我们当然可以添加新的重载来让其支持更多的类型。举个例子,现在要实现一种一种高精度类 。它的各种运算已经完成,我们需要对它添加输入输出的方法。
-
在 风格 里,我们别无选择,只能通过添加一些新的函数来实现。CPP
struct bignum{ int len, data[MAXN]; bool neg; // ... // 一些运算 void read(){ char c; neg = false, len = 0; bool flag = false; while(!isdigit(c = getchar())) neg = c == '-' ? true : false; if(c != '0') data[len ++] = c - '0', flag = true; while( isdigit(c = getchar())){ if(c != '0') flag = true; if(flag) data[len ++] = c - '0'; } std::reverse(data, data + len); } void write(){ if(len == 0) {putchar('0');return;} if(neg == 1) putchar('-'); for(int i = len - 1; i >= 0; --i) putchar('0' + data[i]); } };为了输入输出这个 类型,我们不得不对其调用 和 两个函数。比如,CPPint main(){ bignum a; for(int i = 0;i < 5;++ i) a.read(), a.write(), puts(""); return 0; } -
在 里,却可以通过重载运算符的方式,在输入流和输出流里直接输入输出这个 类型:CPP
struct bignum{ int len, data[MAXN]; bool neg; // ... // 一些运算 }; std::istream& operator >>(std::istream &is, bignum &a){ char c; a.neg = false, a.len = 0; bool flag = false; for(;is.get(c), !isdigit(c);){ a.neg = c == '-' ? true : false; } if(c != '0') a.data[a.len ++] = c - '0', flag = true; for(;is.get(c), isdigit(c);){ if(c != '0') flag = true; if(flag) a.data[a.len ++] = c - '0'; } std::reverse(a.data, a.data + a.len); return is; } std::ostream& operator <<(std::ostream &os, bignum a){ if(a.len == 0) {os << '0';return os;} if(a.neg == 1) os << '-'; for(int i = a.len - 1; i >= 0; --i) os << char('0' + a.data[i]); return os; }我们对 和 分别重载了关于 的流提取/流插入运算符。又因为 是 类, 是 类,因此我们可以直接使用这两者输入输出 类型:CPPint main(){ bignum a; for(int i = 0;i < 5;++ i) std::cin >> a, std::cout << a << '\n'; return 0; }事实上,任何一种继承了 的类型都可以这样读入 ;任何一种继承了 的类型都可以这样输出 。这体现了 的这套流 的可拓展性。这套系统还有一个非常大的好处:对输入输出的类型的特化已经在编译期完成。之所以 和 的这套方案具有较强的可拓展性,是因为它们使用了 可以接受所有种类的类型(包括用户自己定义的)。在编译期,编译器就已经将这种类型对应的代码找到并编译。但是在 和 中,每次调用都需要重新解析。重新解析必然会带来更大的时间开销,因为程序必须要在运行时不断地用判断语句根据格式字符串进行类型的判断。的这套方法巧妙地利用了二进制运算中的左移运算符和右移运算符(这里已经叫作流插入运算符和流提取运算符了。感谢评论区指正)。重载后,每次执行左移/右移运算符结束后,都会返回二元运算的第一个元素(也就是输入/输出流)。接着它与下一个元素操作,然后再下一个……一些格式设置的函数(比如 等),也是通过重载运算符的方式更改了流的相关属性。
然而值得注意的是,左移/右移运算符的优先级是低于很多运算符的,因此写诸如 一类的语句会发生编译错误(按照从左往右的执行顺序,会首先执行“输出 ”的操作,然后进行 和 的比较,就会编译错误)。
输入输出的格式化
想必初学者在做入门语法题时,经常会碰到这样的一些问题:
- 输出一个双精度浮点数 ,保留小数点后 位。
- 输出三个 位带符号整型 ,每个整数的宽度都是 个单位,并且 向左对齐。如果一个整数小于 ,需要带有负号;如果大于 ,需要带有正号。
- 输出一个 位无符号整型 在 进制表示下的结果。十六进制中的 采用大写。
如果使用 风格 ,通过在格式控制字符串里添加一些东西,可以这样解决上述三个问题:
CPP#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;
}
在 风格 里提供了一套相对复杂的格式控制方式用来实现这些功能。由于这部分内容过多,不一一列举,读者可以查看 上面的描述。对于初学者,这种方法需要一定时间去记忆及掌握,并且由于格式符的名称常常相当省略(比如 全称是 , 全称是 , 全称是 ),不大容易让人联想到这是什么。如果哪一天标准里想要加一些新的内容,可能会导致这里的东西更加混乱。
那么在 提供的 里,我们可以怎样解决这些问题呢?
CPP#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;
}
好吧,这套 的方法的确大大增加了码量,并且看上去比 风格的控制字符复杂太多了。但是很显然地,它的可读性更高。这是 风格 的一个优点,即通过一些文字化的叙述提高了代码的可读性。
在 里,有这样的一些格式化标志:
- :分别为 整数 使用 进制、 进制、 进制模式。此外还提供了 ,可以使用掩码的方式进行不同进制的处理。
- :左校正/右校正/内部校正。校正的方式是通过填充某种字符(可以通过 更改)。内部校正主要用于一些特殊的内容(比如输出整数的进制时输出的 左对齐、货币名称左对齐等等)。此外还提供了 用于掩码。
- :使用科学计数法/定点记法输出浮点数。定点记法就是我们日常使用的那种方法。此外还提供了 用于掩码。
- :当输出 型时,转换为对应的字符串( 和 )。
- :输出整数时在其前缀加上它所属的进制(以及输出货币时在其前缀加上它的种类)。例如前缀 表示这是个 进制数、前缀 表示这是个十六进制数。
- :无条件为浮点数输出生成小数点字符。
- :为非负数值输出生成 字符。
- :在具体输入操作前跳过前导空白符。
- :在每次输出操作后冲入输出。也就是每次输出后都刷新缓存区。
- :在具体输出的输出操作中以大写等价替换小写字符。主要用于十六进制相关输出。
特别地,对于 可以在它前边加上 ,含义与原来相反。例如 就是不为非负数值输出生成 字符。
还记得之前提到过的 库吗?这里面也添加了一些新的操作:
- :清除指定的 标志。(相当于把该标志重置了)。
- :设定指定的 标志。
- :分别用于设定整数 的进制、设定校正时的字符、设定浮点数输出的宽度、设置下一个输入输出域的宽度(然后会重置)。
文件/字符串输入输出
打开一个文件
与 风格的 ,分别独立地提供了文件/字符串输入输出的方法。
在 里,有一个叫做 的类型。不过我们不能直接创建这个类型,而是应该使用 函数产生一个以指定的文件访问方式打开指定的文件的 类型的指针()。
简要介绍一下在 里面提供的文件访问的模式,也就是 的第二个参数、 的第三个参数。
- :为了读取而打开一个文件。如果文件不存在则打开失败。
- :为了写入而创建一个文件。如果文件不存在则创建一个,否则销毁里面的内容。
- :为了写入而打开一个文件。如果文件不存在则创建一个,否则追加到末尾。
- 特别地, 里提供了一个字符 ,放置在三个字符,表示扩展的含义(扩展读、扩展写、扩展追加)。加上加号后含义都修改成了“为了写入或者读取”。也就是说,你可以在同一个文件里写入与读取。
- 此外,你还可以追加一个字符 ,含义是以二进制形式打开。二进制模式会在下文说明。
如果我们要创建一个指针 ,以输出为目的打开文件 ,那就可以 。当你创建了文件指针后,你就可以使用 等等变体函数(包括 之类,相当于在标准的那些函数前面加上了一个个字符 )对该文件进行读写。
然而,在 风格的 里,我们应当使用继承了 而诞生的 来进行针对文件 的一系列流。可以使用对应类型的 子函数打开相应文件。例如:
CPPint main(){
ofstream fio;
fio.open("test.txt");
}
当然了, 里也定义了很多文件打开标志,用来在 风格的 里给流打上标记。
- :每次写入前寻位到流结尾。
- :以二进制模式打开。
- :为读打开。
- :为写打开。
- :在打开时舍弃流的内容。
- :打开后立即寻位到流结尾。
这些标记是可以进行叠加的,就和 风格类似。这些打开标志被重载了位运算的相关运算符(与、或、异或等),因此可以使用形如 的形式进行叠加。
容易发现,它们可以与 风格的 相对应。并且比起 风格的那些不明所以的字母及记号,可读性提高了很多。此外,即使你没有写明文件打开标志,输入流标记里必有 ,输出流的标记里必有 ,混合流则是两个都有。这是因为你定义了输入/输出/混合流的变量时,已经告诉了编译器它的基础类型是什么。
打开一个字符串
同样地, 风格 定义了一些标准 函数地变体用于向一个字符串里输入输出。比如 。不同的是, 里还添加了一个 ,用来限制输出的内容的长度(因为是输出到 风格字符串里嘛,如果不限制长度可能会出现溢出之类的问题)。 被踢出标准也正是因为它没能限制读入的字符串地最大长度,导致了可能的溢出风险。
然而 则是通过 和 两个库,分别定义了针对于文件和字符串的流。在之前章节提到了两种风格对于不同类型的变量的输入输出的问题。仍然以之前的 举例,你会发现它只能从 里进行输入输出。如果你想要将它支持文件输入输出和字符串输入输出,那么就得重新实现一下,比较麻烦。但是在 风格里,你只要对于 和 重载一下流插入运算符和右移运算符,就可以任意地在标准流、文件流、字符串流里面使用,具有极高的可拓展性。
二进制模式与文本模式
有时候,流需要对文本内的一些特殊的字符进行处理。比如:
- 下的换行,应该由两个字符组成()。但是使用 风格文件输入后,所有的 应该被转换为单个字符 而被读入;同时,使用 风格文件输出后,输出时所有的 会被转换为 而被输出。同样地,在输入时 下的换行字符 也会被转换为 ,输出时 转换为 。
- 在 下 (也就是 码为 的字符)会被认为是文件的终止符,读取到它之后就会终止文件的读入并返回 (即 )了。
- 不过,在 下,文本模式不会进行任何操作(因为本来也没有任何操作)。
但是有时我们并不是以文本为目的而访问一个文件。因此,我们不希望这些函数对特殊字符进行任何的处理。要注意的是,即使你使用了二进制模式, 等等并不会以二进制数码的形式输出相关数字。
默认情况下,我们都是以文本模式进行读写的。在 风格 里,是通过文件访问模式字符 实现二进制模式,而在 风格 里则是通过文件打开标志 实现。
总结
比起 风格输入输出, 风格的这套输入输出的很大的优点就是更高的代码可读性。同时,还提高了流的可扩展性。此外, 使用特化的方式避免了在运行时不断解析格式串带来的时间开销,因此其实是一个更为高效的方案(尽管由于缓存区同步的问题有时还比 风格慢点)。
当然,这两者各有优劣。比如说,对于算法竞赛的用户,掌握 和 足矣。我们确实用不到很多 风格的优势,甚至有些优势还成为了劣势。但不可否认的是, 对 的兼容使得同时使用两套方案成为了可能,也给予了用户更多的选择。
参考资料
相关推荐
评论
共 41 条评论,欢迎与作者交流。
正在加载评论...