关于 C++ 的文件操作

· · 个人记录

C++ 中的读入

嗯,就是介绍 C++ 中的读入函数和文件机制这些。

我的计算机配置,所有用时在该计算机配置下计量,插电:

文件指针和文件操作函数

文件指针

这里的文件指针是指的读写文件的时候,一个指向数据的东西,可以通过移动这个东西来实现重复读入,对一个文件既读入又写入等等。

C++ 语言从标准输入或者文件中读写数据时,都会有一个文件指针,指向当前需要读写的位置,例如读入一个文件,内容为:

114
514

最开始,指针指向文件开头,也就是 1,你使用一次 int n;cin>>n; 之后,文件指针来到第一个 \n

关于换行的问题下面会讲,这里默认换行是 '\n'。

你现在用了一个 char c;c=getchar();,读入了当前文件指针指向的 \n,于是文件指针来到了 5

再例如写一个文件,写入文件时,文件指针指向上一次写入的最后一个字节的下一个字节,并写入,指向的位置具体是哪里是由操作系统决定,在软件层面上,我们可以认为最后一个字节的下一个字节永远是可以写的。

例如,你执行了 cout<<"1234",就会向标准输出文件追加写入 "1234",文件指针指向 4 的下一个位置并等待接下来的操作,再执行 putchar(' '),就向下一个字节写入 0b00001010 表示空格,然后再次移动文件指针。

文件操作函数

stdin 是一个指向文件的指针,指向标准输入流,cin 是一个 istream 对象,从 stdin 读入数据。

为什么标准输入流是一个文件:因为 “文件” 是计算机操作系统对数据的抽象,标准输入流也是一种数据,因此可以是一个文件。

关于换行和回车的问题

Windows 系统中一个换行由两个字符组成 '\r' 和 '\n',因此,一个换行本质上是 "\r\n",具体的你可以看文件属性中的大小。

C 和 C++ 读入函数,如果能够读取换行,那么都会自动将 "\r\n" 处理为 '\r',因此考试时不需要担心。

如果你希望正常读取 '\r',可以使用二进制方式打开文件。

例如可以使用 ifstream 或者 freopen(file, "rb", stdin);,或者 fopen(file, "rb")

同时对一个文件读写

你可以同时读写一个文件,例如你可以打开一个文件 1.in,里面有两个数,末尾跟一个换行,你把两个数的和算出来并追加到后面,输入文件是这样的(1.in):

1919 810

可以用 fstream 或者可以读写的 FILE 来完成这个操作(1.cpp):

#include <iostream>
#include <fstream>

int main() {
    std::fstream file("1.in", std::ios::in | std::ios::out);
    if (!file) {
        std::cerr << "打开文件失败!" << std::endl;
        return 1;
    }
    int num1, num2;
    file >> num1 >> num2;
    int sum = num1 + num2;
    file << std::endl << sum;
    file.close();
    std::cout << "已将两数之和追加到文件末尾。" << std::endl;
    return 0;
}

运行这个程序后,1.in 会变成这样(1.out):

1919 810
2729

假设最开始文件是这样的(2.in):

1919 810
114514

然后它变成了这样(2.out):

1919 810
27294

为什么 1 不见了呢,还是同样的输入文件,我们将第 13 行改为 file << sum;(2.cpp),猜猜会发生什么?

变成了这样(3.out):

1919 8102729514

如果第 13 行改为 file << '\n' << sum; (3.cpp)呢,它是这样(4.out):

1919 810
27294

好了,我们来分析一下代码是怎么执行的,对于 1.cpp, 1.in, 1.out,首先读入两个数,文件指针指向了 \n,此时 endl 输出了一个 \r\n(我使用 Windows 系统),覆写了 \n,然后接着写 2729,很正常。

对于 1.cpp, 2.in, 2.out\r 替换了 \n 占用的字节,\n 替换了 1,接着 2729 替换下面的字节,最后剩下一个 4

对于 2.cpp 2.in 3.out,我们把 endl 删了,2 替换了 \n729 替换了 114,然后就这样了。

对于 3.cpp 2.in 4.out,我们加了一个 \n,但是现在是 Windows 系统,所以这个 \n 会被 C++ 变成 \r\n,因此和 endl 一样的。

换成 Linux 系统,如果是 1.cpp 2.in,那么输出 5.out 又是什么呢?

自然的,应该是:

1919 810
272914

我们同样可以使用 C 风格的 FILE 指针来操作,所以让我们 GPT4 写一下 C 风格的代码(4.cpp):

#include <stdio.h>
#include <iostream>
int main() {
    FILE *file;
    int num1, num2, sum;
    file = fopen("1.in", "r+");
    if (file == NULL) {
        printf("无法打开文件 1.in\n");
        return 1;
    }
    fscanf(file, "%d", &num1);
    auto offset = ftell(file);
    std::cerr << offset << '\n';
    fscanf(file, "%d", &num2);
    offset = ftell(file);
    std::cerr << offset << '\n';
    sum = num1 + num2;
//    fseek(file, 0, SEEK_CUR);
    std::cerr << ftell(file) << '\n';
    std::cerr << fprintf(file, "%d", sum);
    return 0;
}

我注释掉了 fseekfseek 是移动文件指针的操作,它把文件指针移动到了文件当前位置以追加写入,实际上它似乎没有做任何事情。但是如果注释掉它,输入为 1.in 的话,你会发现它根本没有输出,如果看一下 fprintf 的返回值,可以发现是 -1。更神秘的是,我们不改变代码,将它放到 Linux 下运行,它却可以按我们的预期输出。

Windows 下 4.cpp 1.in 的输出(5.out):

1919 810

Linux 下 4.cpp 1.in 的输出是 (6.out):

1919 8102729

这是神秘原因导致的,下面的章节中我们会分析这个神秘原因,不过我们暂时可以先显式的移动一下指针

如果我们把 fseek 的注释取消掉,会得到和 fstream 函数相同的结果。

当然,如果使用 rb 方式打开,那么 C++ 不会帮我们处理 \r,这个时候 \r 就需要考虑了。

将 4.cpp 18 行的注释取消,并将第 6 行的 r+ 改为 rb+,输入为 2.in,输出为(7.out):

1919 81027294514

当然,如果只把第 18 行的注释取消,输出和 Linux 下 4.cpp 1.in 的输出一样,为 6.out。

stream 基类提供了类似 fseek 的函数,是成员函数,以下是 GPT4.0 的解释:

seekg():该函数用于移动输入流指针(get pointer),即用于读取数据的指针。

该函数的语法为 seekg(offset, direction),其中 offset 表示偏移量,direction 表示移动方向。例如,要将指针从当前位置向后移动 5 个字节,可以调用 seekg(5, std::ios_base::cur)

好的,现在感谢野兽先辈和 GPT4.0 教会了我们 C++ 的文件读写操作以及处理不同操作系统换行的问题。

缓冲区

上一小节提到 4.cpp 中的问题是神秘原因导致的,这个神秘原因就是缓冲区!

缓冲区是什么呢,缓冲区(Buffer)是计算机中用于临时存储数据的一块内存区域。它通常被用于处理输入输出操作,以减少不必要的数据交换和提高程序性能。

在计算机中,数据通常需要在不同的组件之间传输,例如从硬盘读取文件、从网络接收数据等等。这些数据通常需要被存储在内存中以进行处理,但是数据传输速度和处理速度往往不匹配,这就会导致数据传输和处理之间出现瓶颈。为了解决这个问题,计算机通常使用缓冲区作为中介,将数据暂时存储在缓冲区中,以便在传输和处理之间平衡数据的流动。

举一个例子,你现在要从硬盘中读入一个文件 1.in,里面有 10^6 个正整数,你使用 cin 读取,访问 SSD 硬盘的延迟高达 5us,如果读取 10^6 个正整数需要足足 5s,那为什么实际上用不到 5s,可能只需要 100ms 左右便能完成读写呢,就是缓冲区拯救了我们。

具体的,当我们尝试读取文件的时候,计算机先读取 2^{16} 个字节的数据到内存中,接下来的操作均在内存中完成,如果先前读在内存中的数据读完了,那么就再读取 2^{16} 字节的数据,这样就可以有效加速读写操作。

当读取的文件来自延迟可能高达 100ms 的网络时,这样的策略会更加有效的加速。

加速写入

cout<<endl; 会在输出一个换行后立刻刷新缓冲区,以写入为例,这样的代码消耗了足足 11.5s(这说明我的 SSD 平均延迟约为 10us)(5.cpp):

#include <bits/stdc++.h>
using namespace std;
mt19937 rd(time(NULL));
int main() {
    ofstream outfile("test.in");
    if (!outfile.is_open()) {
        cout << "Error: failed to open the file" << endl;
        return 1;
    }
    const int N = 1000000;
    for (int i = 0; i < N; i++) {
        short num = rd() % (SHRT_MAX + 1);
        outfile << num << endl;
    }
    outfile.close();
    return 0;
}

嗯,如果你不信的话,我们用 RAMDISK 划一块内存盘出来,看看用时多长吧(DRAM 的平均延时为 75ns)。

结果是 5S,为什么,因为清理缓冲区本身这个操作也是系统调用,效率也很低下,那这样看起来我的 SSD 延时确实是 5us。

我们不让它强制刷新缓冲区,将第 13 行的 endl 改为 '\n',重新运行,用时 0.32s,好多了,它在 RAMDISK 上用了 0.2s。

我们尝试手动设置缓冲区大小,设置的比较小,看看用时(6.cpp):

#include <bits/stdc++.h>
using namespace std;
mt19937 rd(time(NULL));
int main() {
    cerr << "Default Buffer Size:" << BUFSIZ;
    char buffer[4096];
    ofstream outfile;
    outfile.rdbuf()->pubsetbuf(buffer, sizeof(buffer));
    outfile.open("test.in");
    if (!outfile.is_open()) {
        cout << "Error: failed to open the file" << endl;
        return 1;
    }
    const int N = 1000000;
    for (int i = 0; i < N; i++) {
        short num = rd() % (SHRT_MAX + 1);
        outfile << num << '\n';
    }
    outfile.close();
    return 0;
}
buffer 大小 * 磁盘 = 用时 SSD RAM_DISK
32Byte 3525ms 1928ms
1024Byte 192ms 114ms
32768Byte 78ms 61ms
1048576Byte 71ms 60ms

如果把中间输出去掉,并且计算一个和输出(防止 O2 优化掉),那么发现 RAM_DISK 和 SSD 的用时是一样的,因此以上差异都来自 SSD 和 RAM 的延迟差异。

通过标准错误流的输出,我们可以看到默认的缓冲区大小为 512Byte。

缓冲区对于输入的加成,是一样的,我们现在可以测试一下 cin 的真实读入速度,以下代码 SSD 用时 472ms,RAM_DISK 用时(7.cpp):

#include <bits/stdc++.h>
using namespace std;

void solve() {
    freopen("test.in", "r", stdin);
    const int N = 1e6;
    int s = 0, t;
    for (int i = 0; i < N; i++) {
        cin >> t;
        s += t;
    }
    cerr << s << '\n';
}

int main() {
    solve();
    return 0;
}

stdinstdout 的缓冲区大小是由系统决定的,设置起来相当麻烦(你可能需要改库函数以至于最后不得不重装 mingw),默认大小为 512Byte,但是 cin 每次读入后都会尝试与 stdio 同步(不是刷新缓冲区),因此很慢,关闭与 stdio 的同步后会很快:

ios_base::sync_with_stdio(false);

关闭同步后耗时 105ms,如果关闭了同步,那么你不能混用 cin 和 C 风格的读入函数,例如 getcharscanf 等,否则由于它们各自拥有缓冲区,因此会导致读入错误。

scanf 耗时 300ms,因此关同步的 cinscanf 更快。

当然,如果你用 ifstream 打开文件,并设置一个很大的缓冲区,ifstream 的缓冲区必须在打开文件之前设置,读取速度会更快(7.cpp):

#include <bits/stdc++.h>
using namespace std;
void solve() {
    char buffer[32768];
    ifstream in;
    in.rdbuf()->pubsetbuf(buffer, sizeof(buffer));
    in.open("test.in");
    const int N = 1e6;
    int s = 0, t;
    in >> t; s += t;
    for (int i = 1; i < N; i++) {
        in >> t;s += t;
    }
    cerr << s << '\n';
}

int main() {
    solve();
    return 0;
}

用时 85ms,手写一个 getchar 的输入优化(8.cpp),用时 77ms

#include <bits/stdc++.h>
using namespace std;
template<typename T>void read(T &x){
    x=0;char ch=getchar();int f=1;
    while(!isdigit(ch)&&ch!=45)ch=getchar();
    if(ch==45)ch=getchar(),f=-1;
    while(isdigit(ch))x=x*10+ch-'0',ch=getchar();
    x*=f;
}
void solve() {
    freopen("test.in","r",stdin);
    const int N = 1e6;
    int s = 0, t;
    read(t); s += t;
    for (int i = 1; i < N; i++) {
        read(t);s += t;
    }
    cerr << s << '\n';
}

int main() {
    solve();
    return 0;
}

ifstream 其实和输入优化的效率其实差不多了,当然,有更快的 输入优化,使用链接中的输入优化,用时 20ms,其原理是直接从被定向输入文件(stdin)以一个很大的缓冲区读取很多个字符,并直接处理这些字符,我们的大缓冲区版本的 ifstream 较慢是因为 ifstream 读入实现本身不优秀,瓶颈并非真正的 IO 操作,以下为代码(9.cpp):

#include <bits/stdc++.h>
using namespace std;
struct IO {
    static const int bufSize = 1 << 17;
    char inBuf[bufSize], outBuf[bufSize], *in1, *in2, *out;
    inline __attribute((always_inline))
    int read() {
        if(in1 > inBuf + bufSize - 32) [[unlikely]] {
            auto len = in2 - in1;
            memcpy(inBuf, in1, len);
            in1 = inBuf, in2 = inBuf + len;
            in2 += fread(in2, 1, bufSize - len, stdin);
            if(in2 != inBuf + bufSize) *in2 = 0;
        }
        int res = 0;
        char c;
        while((c = *in1++) < 48);
        while(res = res * 10 + c - 48, (c = *in1++) >= 48);
        return res;
    }
    inline __attribute((always_inline))
    IO() {
        in1 = inBuf, out = outBuf;
        in2 = in1 + fread(in1, 1, bufSize, stdin);
    }
    ~IO() {}
};
void solve() {
    freopen("test.in","r",stdin);
    IO IO;  
    const int N = 1e6;
    int s = 0, t;
    s += IO.read();
    for (int i = 1; i < N; i++) {
        s += IO.read();
    }
    cerr << s << '\n';
}

int main() {
    solve();
    return 0;
}

另外有一个非常坑的地方是即使你使用了 cout<<'\n' 以及关闭了同步的 cin,在一些非常阴间的情况下还是会出问题,例如(10.cpp):

#include <bits/stdc++.h>
using namespace std;
void solve() {
    freopen("test.in","r",stdin);
    freopen("test.out","w",stdout);
    ios_base::sync_with_stdio(false);
    const int N = 1e6;
    int s = 0, t;
    cin >> t;s += t;
    for (int i = 1; i < N; i++) {
        cin >> t;
        s += t;
        cout << rand() << '\n';
    }
    cerr << s << '\n';
}

int main() {
    solve();
    return 0;
}

用时 10s!!!

这是因为默认情况下,使用标准输入时会自动刷新标准输出的缓冲区,因此效率非常低下,我们可以加上 cin.tie(0) 来禁止这个刷新。

关于上一节的神秘原因

实际上,Linux 的读写切换会自动的刷新缓冲区并将文件指针移动到上一次读入的位置的下一个字节,而 Windows 则需要手动使用 fseek 函数定位指针以手动刷新缓冲区,当输入缓冲区中仍然有内容时,无法执行输出操作,我们也可以将输入缓冲区中的内容全部读取出来以继续操作。

C++ 给我们提供 fflush 函数来刷新一个 FILE 的缓冲区,我们调用 fflush 时,会清空当前缓冲区中的内容,由于文件本身已经有一部分内容被读入了缓冲区,清空读入的缓冲区相当于抛弃了缓冲区中还未读入的数据, fflush 会将文件指针移动到缓冲区的末尾准备下一次读写操作。

输入是 1000 行 1919 810,以下是代码:

#include <stdio.h>
#include <iostream>
int main() {
    FILE *file;
    int num1, num2=0, sum;
    file = fopen("1.in", "r+");
    if (file == NULL) {
        printf("无法打开文件 1.in\n");
        return 1;
    }
    fscanf(file, "%d", &num1);
    auto offset = ftell(file);
    std::cerr << offset << '\n';
    offset = ftell(file);
    std::cerr << ftell(file) << '\n';
    sum = num1 + num2;
    fflush(file);
    std::cerr << ftell(file) << '\n';
    fseek(file, 0, SEEK_CUR);
    std::cerr << fprintf(file, "%d", sum + 10);
    return 0;
}

标准错误流输出:

4
4
4096
4

1.in:

也可以发现在 Windows 下,虽然读入函数 getcharscanf 等函数将 \r\n 处理成了一个 \n,但是实际打开文件计算大小的时候仍然被计算为两个字符。

也就是说,实际上 \r 仍然存在于数据中,只是读取 \r 的时候判断了下一个位置是否为 \n,如果是,那么就将两个字符一起读进来,这个特性只在 Windows 系统下存在,如果是 Linux 系统,\r\n 一定会被处理为两个字符。

可以看一下以下代码的输出(12.cpp):

#include <stdio.h>
#include <iostream>
#include <string>
#include <fstream>
using namespace std;
int main() {
    char _c;int s=0;
    fstream srm("1.in");
    auto stream_begin = srm.tellg();
    srm.seekg(0, ios_base::end);
    cerr << "stream:" << srm.tellg() - stream_begin << '\n';
    FILE* file=fopen("1.in", "r");
    auto file_begin = ftell(file);
    fseek(file, 0, SEEK_END);
    cerr << "file:" << ftell(file) - file_begin << '\n';
    fseek(file, 0, SEEK_SET);
    while(~fscanf(file, "%c", &_c)){
        s++;
    }
    cerr << "count:" << s << '\n';
    return 0;
}
stream:122880
file:122880
count:110593

C++ 读入简介

C 风格

C++ 风格

其中,cin 本身是一个 ifstream 类,所以定义的 ifstream 类可以像 cin 一样使用。

cin 读入字符可以看成忽略空白字符的 getchar

管道

管道则是计算机为进程通信提供的一种抽象概念。

在 OI 中,通常用于实现交互题或者通信题。

管道是先进先出的,和队列的机制一样,很容易理解,先将一个东西塞进管道里它一定先出来。

未命名管道

Windows 系统的管道很难用,这里稍稍提一下 Linux 中的管道,Linux 的命令行中可以用 | 创建一个匿名管道,用于向子进程发送数据,例如你希望以排序的方式列出文件,就可以

ls | sort

管道和缓冲区

做交互题的时候题面往往会提醒我们刷新缓冲区,这是因为我们输出的数据会被写进一个管道里供 judger 使用,前面提到缓冲区是用来加速读写的,是内存里的一块区域,如果我们写入数据但是没有刷新缓冲区,那么这些数据仍然会停留在内存中,而没有进入管道,judger 自然也无法读取到数据而最后 TLE 了

在通信题中,如果我们使用了 cin 这类基于空白分隔的读入函数时,也一定要小心,比如你的本意是要发送两个数,假设是 114 和 514,期望是先写 114,然后刷新缓冲区,另一个程序读入 114,向原先的程序发送一个已经接受的信号,原先的程序继续写入 514。

但是假设你写了 114 后没有写空格,只是刷新缓冲区,那么第二个程序的 cin一直找不到停止标志而一直等待,最后 TLE

我们读入文件的时候最后会有一个 EOF 标志,这也是 cin 结束标志之一,所以不会出问题,但是换到管道中,刷新缓冲区之后却没有任何结束标志,所以第二个程序会一直等待。

某个奇怪的问题

在 C++ 中,用 system 函数启动一个新进程的时候,新进程会继承父进程的标准输入输出,如果父进程的标准输入输出被重定向了,那么新进程的标准输入输出也会被重定向。

使用屏幕输入的时候,默认的缓冲机制是行缓冲(使用屏幕输出时采用无缓冲),即遇到换行符时进行缓冲区操作,以下有两个程序:

//1.cpp
#include<cstdio>
#include<iostream>
#define L(i,l,r) for(int i=l;i<=r;++i)
#define R(i,l,r) for(int i=l;i>=r;--i)
int main(){
    int a=-1,b=-1;
    std::cin>>a>>b;
    printf("%d+%d=%d\n",a,b,a+b);
    return 0;
}
//2.cpp
#include<bits/stdc++.h>
using namespace std;
int main(){
//    freopen("c.in","r",stdin);
    char s[100];
    cin>>s;
    cerr << "OK\n";
    system(strcat(s,".exe"));
    int x,y;
    cin>>x>>y;
    cout<<"S:"<<x+y<<'\n';
    return 0;
}

标准输入输入以下内容时:

1 2 3
4 5

标准输出为:

4+5=9
S:5

因为 1.cpp 将第一行所有内容都读进了它的缓冲区,把第二行留给了子进程 2.cpp。另外就是我们复制长文本进命令行时(例如一行 n 个正整数),会发现它复制到一半会卡住,这就是因为行缓冲的缓冲区大小为 4096Byte,因此一行最长只能有 4096 个字符。

更反直觉的一件事情是,如果我把 2.cpp 的 freopen 注释取消,c.in 里放和标注输入相同的文件,你会发现 1.cpp 根本没有读入成功,直接输出了 -1+-1=-2,这是因为开子进程时,父进程先将自身标准输入文件的文件指针移动到末尾,再移交文件指针给子进程。

也就是说,当采用 freopen 时,开子进程的时候文件指针已经到 EOF 了,子进程自然无法读取内容。

子进程结束之后,文件指针仍然存在于文件末尾,因此父进程也无法处理剩余的输入。

但是,我们还有缓冲区!父进程标准输入缓冲区内的数据仍然可以正常读写,用两个实际例子吧:

c1.in:

1 
2 3
4 5

stdout:

-1+-1=-2
S:5

c2.in:

1

[此处省略 10^4 个换行]

2 3 
4 5

stdout:

-1+-1=-2
S:268501009

根本没有成功读入 xy,所以用的局部变量初始的随机值。

为了解决可能的问题,最理想的方案就是不对标准输入输出尝试重定向,而是用 fopen 或者 fstream 执行文件操作。