LoginSignup
4
1

More than 5 years have passed since last update.

Cで行指向処理をする場面でバイナリを読む可能性がある時の傾向と対策

Last updated at Posted at 2017-08-21

例えば、行指向処理を目的としているfgetws()はテキストファイルを改行に遭遇するか、バッファが満たされるまで読み込む関数ですが、文字列終端となるL'\0'も何のエラーもなく素通ししてしまうため、「バッファがL'\0'以外で(指定バイト数-1)まで満たされた」「文字列がL'\n', L'\0'で終端された」という場合を除いて関数が本当に何文字読んだのかがわからなくなってしまいます。

ということでL'\0'に遭遇してもあくまでテキスト処理を続けるための一環としてバイナリを識別し文字列を読み込んでいく方法。

バイナリ対応fgetws()を作ろう

fgetwsを改良したものを使いたいという要求の元、次のような仕様を備えたfgetws2()を実装する方法を考えます。

  • プロトタイプは size_t fgetws2(wchar_t *buf, size_t len, FILE *fp);
  • 関数はfpからワイド文字を最大len - 1文字改行に出会うまで読み、bufに格納する。読み込んだワイド文字列はL'\0'で終端される。
  • 返り値はfpから読み込んだ文字数を返す。ファイル終端かエラーが発生した時は(size_t)-1を返す。fgetwsと同じようにエラーかファイル終端かは呼び出し側でferror()feof()を調べる必要がある。

文字数を返すので、途中にL'\0'が入っていても文字列(バイト列)が続いていることを把握できるということです。世の中には恐ろしいファイルが撒き散らされており、あわよくばバグらせてやろうとソフトに食べさせる人がゴマンといます。そのような脅威からは自分で身を守っていきましょう。

fgetwc() で1文字ずつ読む

まぁ普通に思いつく方法。無難of無難。

size_t fgetws2(wchar_t *buf, size_t len, FILE *fp) {
    wint_t c = fgetwc(fp);
    if (c == WEOF) {
        return (size_t)-1;
    }
    if (len <= 1) {
    // 書けるバッファ無くない?
        ungetwc(c, fp);
        if (len) {
            *buf = L'\0';
        }
        return 0;
    }
    *buf = c;
    size_t rtn = 1;
    len--;
    while (rtn < len) {
        c = fgetwc(fp);
        if (c == WEOF) {
            break;
        }
        buf[rtn++] = c;
        if (c == L'\n') {
            break;
        }
    }
    buf[rtn] = L'\0';
    return rtn;
}

いくらバッファリングされているとはいえIO操作を1文字ずつ読んでいくのは重い処理になりそうなので避けたいと思うのがプログラマの性ではないでしょうか。

事前にL'\0'でない文字でバッファを埋めてからfgetws()

fgetws()は取り敢えず読んだ後の末尾にL'\0'を付け足しますから、後ろの方からL'\0'を探せば良いですよね。ちなみにこの文章の後半で困るので関数名はfgetws3にしときます。

size_t fgetws3(wchar_t *buf, size_t len, FILE *fp) {
    if (len <= 1) {
        // なぜバッファもないのに呼び出した?
        wint_t c = fgetwc(fp);
        if (c == WEOF) {
            return (size_t)-1;
        }
        ungetwc(c, fp);
        if (len) {
            *buf = L'\0';
        }
        return 0;
    }
    memset(buf, 1, len * sizeof(*buf));
    if (!fgetws(buf, len, fp)) {
        return (size_t)-1;
    }
    while (buf[--len]) {}
    return len;
}

入力に関しては1文字ずつ読むやつよりはだいぶ良さそうですね。ただ、memset()でバッファを全部初期化することと、L'\0'の存在を舐めていくという動作が、読み込んだ文字数とバッファ長の差が大きくなった時に気になります。wcslenとやってることは同じだけどなんかね…的な。

fwscanf()

そんな中意外な伏兵がいました。fwscanf()です。

fwscanf()というか、scanf()といえば初心者向け参考書で「"%d %s"ってすれば数字と文字列をキーボード(?)から一度に読み込めるやつです。すごい!」なんて触れ込みで使われるのが定番ですが、なんでintの変数に&をつけるのか、逆にchar配列にはなんで付けないのかよくわからない、そしてちょっとわかった気になってくると、あれ、これ普通に文字列で取り込んできた後に自分で文字列解析したほうが良くねとか、これバッファを溢れさせないように使うの難しいよねとかとなって次第に全く使われなくなる関数ですね!? 私も普通は使わないです。

が、バイナリファイルを読むにあたって意外にも有用なフォーマット指定子がありました。%[^...]です。

%[^...]の詳しい動作は仕様書を読んでもらうとして、これは文字種を制限しながら読むところを除いてだいたい%sと同じような感じで使えます。だから%20[^...]ってすればバッファ長を制限できますし、早い話、fwscanf(fp, L"%20l[^\n]%zn", buf, &len)ってすれば「改行を除くワイド文字を最大20文字読み、それまでに読み込んだ文字数を格納」なんていうバイナリを扱うのにちょうどいい形で値を得られるわけです。やったね! というわけで、

  • バッファを取り扱うためのフォーマット指示子を作る
  • そのフォーマットに則りfwscanf()
  • 止まった所でfgetwc()して改行なりなんなりを読む

って関数を作ると良さそうです。で、出来たのが以下。

size_t fgetws4(wchar_t *buf, size_t len, FILE *fp) {
    if (len <= 1) {
        // バッファが無い件
        wint_t c = fgetwc(fp);
        if (c == WEOF) {
            return (size_t)-1;
        }
        ungetwc(c, fp);
        if (len) {
            *buf = L'\0';
        }
        return 0;
    }

    // ファイル読み込みではなく、フォーマット指示子を作っている。
    // バッファ末尾の2文字前まで読み、その後fgetwc()、L'\0'と埋めたい。
    // 512文字のバッファだったらfwscanfでは510文字読ませたいので
    // "%510l[^\n]%zn" という文字列を作りたい。%の組み合わせがややこしい
    wchar_t fmt[32];
    swprintf(fmt, 32, L"%%%zul[^\n]%%zn", len - 2);

    // readlenの0初期化は必要。
    // 変換が行われなかった場合、すなわちすぐさま改行に出会って
    // 文字列が読み込まれなかった時は引数を変化させないので。
    // (その時はretが0になる)
    size_t readlen = 0;
    int ret = fwscanf(fp, fmt, buf, &readlen);
    // 上記2ステップはC99を対象にしていますが、C11ならfwscanf_s()でもっと楽できるっぽいです

    if (ret == EOF) {
        return (size_t)-1;
    }
    wint_t c = fgetwc(fp);
    if (!ret && c == WEOF) {
        return (size_t)-1;
    }
    if (c != WEOF) {
        buf[readlen++] = c;
    }
    buf[readlen] = L'\0';
    return readlen;
}

以上fgetws()の代替を3つ作りました。もしfgetws()を使ったアプリケーションが野に放たれることになり、信頼できないデータをバカバカ食わされるハメになっても、少なくともバイナリデータであることは簡単に判別できるようになりました。良かったですね。

性能

さて、実装を3種類作ったところですし性能の違いも検討したいところです。テキストファイルというのは可変長のデータであり用意したバッファが常に満杯になるように使われるというものではありません。そんなわけで次の4つの状況での差を測るテストを作ってみました。

  • 長いバッファを用意され短い文字列しか書き込まれない
  • 長いバッファを用意されいつも満杯に書き込まれる
  • 短いバッファを用意され短い文字列しか書き込まれない
  • 短いバッファを用意されいつも満杯に書き込まれる

ここで、長いバッファは(1 << 17) * sizeof(wchar_t)、短いバッファは(1 << 10) * sizeof(wchar_t)としました。
短い文字列を送り込むものとして以下のコマンドを使いました。

yes 01234567890123456789012345678901234567890123456789012345678901234567890123456789

バッファをいつも満杯にするものとして以下のコマンドを使いました。

tr \\0 @ </dev/zero

L'\0'を扱うテストになってないが今はその話じゃないので気にするな。…これを標準入力に対して単純にfgetws2()を繰り返すバイナリに繋ぎ、テキトーにtimeで時間を測ります。テキトーにね。4つの状況でかなり差が出ておもしろかったです。

以下のテストはDebian Buster amd64の上で行いました。

長いバッファを用意され短い文字列しか書き込まれない

yes ... に fgetws*(buf, 1 << 17, stdin); を10,000,000回

fgetws2 fgetws3 fgetws4
real 7.865s 計測中断 7.644s
user 7.548s 計測中断 7.364s
sys 1.185s 計測中断 1.139s

fgetw3()はこのテストに対しては時間が掛り過ぎたのでやめました。やっぱ2^17個の文字を舐めるのはつらいよねー。もうちょっと繰り返しを減らしてみたらfgetws2に対して250倍ぐらい時間掛かってました。つらい。

長いバッファを用意されいつも満杯に書き込まれる

tr ... に fgetws*(buf, 1 << 17, stdin); を10,000回

fgetws2 fgetws3 fgetws4
real 12.749s 3.133s 8.231s
user 14.276s 4.085s 9.621s
sys 1.506s 1.202s 1.531s

短いバッファを用意され短い文字列しか書き込まれない

yes ... に fgetws*(buf, 1 << 10, stdin); を5,000,000回

fgetws2 fgetws3 fgetws4
real 3.986s 9.754s 3.819s
user 3.850s 9.545s 3.639s
sys 0.563s 0.750s 0.621s

短いバッファを用意されいつも満杯に書き込まれる

tr ... に fgetws*(buf, 1 << 10, stdin); を500,000回

fgetws2 fgetws3 fgetws4
real 4.988s 1.275s 3.453s
user 5.592s 1.685s 4.022s
sys 0.590s 0.449s 0.575s

*

fgetws3()が実装的にイマイチだなぁと感じていた所を計測で爆弾になっていたことを確認できましたね。大体、バッファは想定されるデータに対して保険で少し長めに取っておくことが多いですから、それが余計にアダになるなぁと。あと、データを連続して読み込むようになっているfwscanf()に対してfgetwc()を繰り返すほうが1.5倍程度の時間で済んでるって、fgetwc()すごくね?

余談 getline()

バイナリを含むデータに対しても行指向編集をしたい! という要求に応えてかは知りませんが、glibcにはgetline()という関数があり、行の長さが事前に用意したバッファの長さに満たなければライブラリで拡幅してもらい、さらに行の長さと拡幅した分の長さも伝えてくれるという至れり尽くせりなイカスやつです。今やPOSIX標準にも含まれるようになりました。

しかし、自動でメモリ拡幅を行う関数、これは世に放たれたソフトで使っていいものなのか? 任意のファイルをこれで受け付けていたら悪意があってもなくても</dev/zeroとでもすれば簡単にメモリ領域を埋めるDoSが効いてシステムは死んでしまいますよね? 私はこういう関数は苦手だなぁ……。

で、私にはワイド文字が必要なので、しかしgetline()に対応するワイド文字版関数が無いから自分でmbsnrtowcs()する必要があるが、'\0'に引っ掛かる度にいちいち変換が止まってバッファを移し替える作業が面倒臭えんだよ!!!!

まとめ

CはもっとマトモなAPIを用意しろ。

4
1
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1