C++でファイルを後ろ向きに読み込むときはバッファリングに気をつけようって話

C++でファイルを読み込むとき、std::ifstream を使うのが一般的だと思います。本記事では、ifstream でファイルを後ろ向きに読み込む場合には、バッファリングに気を付けて実装しないと読み込みがとても遅くなってしまうことについて解説します。

以下、固定長バッファを使いつつ文字を一文字ずつ後ろから読み込みたい場合を想定します。

TL;DR

  • ifstream は内部でバッファリングしてくれてる。
  • 後ろ向きに読み込む場合はバッファリングがうまく機能せず激遅になる。
  • FILE など使ってちゃんとバッファリングしながら読み込もう。

目次

方法

シンプルに前から読み込み

一文字だけ読み込みたい場合は get 関数が便利です。ifstream を使ってファイルを前から一文字ずつ読み込む場合は、以下のようにループを回すだけです。

// std::ifstremによるシンプルな順方向読み込み
void read_forward(const char* filename) {
    std::ifstream ifs(filename, std::ios::binary);
    for (char c; ifs.get(c);) {
        hogehoge(c); // 何かしらの処理
    }
    ifs.close();
}

このとき ifstream は内部で streambuf がバッファリングしてくれてるので、我々は意識しなくても自動的に重たいファイル I/O を抑制してくれています。

例えば手元の AppleClang 12.0.0 ではバッファサイズが 1024 バイトでしたので、実際にファイルを参照するのは 1024 回に1 回で済みます。

シンプルに後ろ向きに読み込み

では同じように get 関数を使って、ファイルを逆から一文字ずつ読み込もうとするとき、以下のようなコードが書きたくなるかもしれません。というかググったら某QAサイトでいくつかこういう実装が見られました。

// std::ifstremによるシンプルな逆方向読み込み
void read_backward(const char* filename) {
    std::ifstream ifs(filename, std::ios::binary);
    ifs.seekg(-1, std::ios::end);  // 読み込みポインタを最後の文字に設定
    for (char c; ifs.get(c);) {  // 読み込みポインタ += 1
        hogehoge(c); // 何かしらの処理
        ifs.seekg(-2, std::ios::cur);  // 読み込みポインタ -= 2
    }
    ifs.close();
}

このコードではファイルを後ろ向きに読み込めるようにポインタを更新しつつ、素直に get で一文字ずつ読み込んでます。

では実際に 1 MiB のファイルを読み込んだときの実行時間を計測してみます。すると以下のような結果となりました。(実験環境とコードへのリンクは記事の最後にあります)

関数 実行時間
read_forward 10 ms
read_backward 3426 ms

後ろ向きの読み込みがベラボーに遅いです。

これは streambufget で参照した位置を先頭としてデータブロックをバッファに読み込むためです。一文字ずつ戻る read_backward では結局 1 MiB 回の I/O が必要となって激重となってしまったようです。

バッファをちゃんと使って後ろ向きに読み込み

ということで後ろ向きにファイルを読む込むときには、単純に ifstream に頼らずちゃんとバッファリングするように実装してあげる必要があります。

とりあえず ifstream を使わず以下のように FILE を使って書くのが最も簡単かと思います。

// FILEによるバッファを使った逆方向読み込み
void read_backward_buffering(const char* filename) {
    // バッファ
    constexpr std::streamsize bufsize = 1024;
    char buffer[bufsize];

    FILE* fp = fopen(filename, "rb");
    fseek(fp, 0L, SEEK_END);  // 読み込みポインタをファイル末尾に設定
    const int64_t filesize = ftell(fp);  // ファイルサイズ

    for (auto end = filesize; end > 0;) {
        const auto beg = std::max<int64_t>(0, end - bufsize);
        const auto readsize = end - beg;
        fseek(fp, -readsize, SEEK_CUR);  // 読み込みポインタを設定
        fread(buffer, 1, readsize, fp);  // バッファに読み込み
        fseek(fp, -readsize, SEEK_CUR);  // 読み込んだ分をリセット
        for (auto j = readsize - 1; j >= 0; --j) {
            hogehoge(buffer[j]); // 何かしらの処理
        }
        end = beg;
    }
    fclose(fp);
}

実行時間は以下のようになり、ちゃんと高速にファイルが後ろ向きに読み込めているのがわかります。read_forward よりも速いのは単純に ifstream より処理が簡潔だったからでしょう。

関数 実行時間
read_forward 10 ms
read_backward 3426 ms
read_backward_buffering 3 ms

まとめ

というわけで C++ で少しトリッキーなファイル入出力をする場合には、fstream のバッファリングに気をつけようという話でした。

今回は FILE を使って解決しましたが、もっと C++ らしく書きたい場合は、std::basic_streambuf から派生して独自の stream クラスを実装すればいいのではないかと思います。

実験環境

  • Machine: MacBook Pro (13-inch, 2019)
  • OS: macOS Catalina (version 10.15)
  • Processor: 2.4 GHz Quad-Core Intel Core i5
  • Memory: 16 GB 2133 MHz LPDDR3
  • Compiler: AppleClang (version 12.0.0)
  • Flags: -O3

実験コード

gist.github.com