バッファリング | Programming Place Plus C言語編 第43章

トップページC言語編

このページの概要

以下は目次です。


バッファリングとは

バッファリング (buffering) とは、データをいったんどこかに蓄えておき、あるタイミングでまとめて処理する方法のことを指します。「蓄えておく場所」のことを、バッファ (buffer) といいます。

たとえば、通信分野であれば、送信するデータがある程度たまってから、まとめて送信した方が、効率が高まります。送信作業を行うためには、送り主や宛先の情報の付加など、さまざまな準備が必要になるため、小さなデータを頻繁に送るよりも、ある程度まとめて送った方が効率的な訳です。

このように、バッファリングの手法によって、より効率的な処理が行える場面はいくつもあります。この章では、C言語の入出力処理におけるバッファリングについて見ていきます。

たとえば、出力を考えてみます。printf関数puts関数などを使って出力を行うとき、もしバッファリングが行われていたら、即座に画面などの具体的な場所にデータを送らず、いったん、バッファに、出力すべきデータを蓄えておくだけにします。そして、何らかのタイミングをもって、バッファに蓄えられているデータを、画面などの具体的な場所へ送ります。

入力の場合も考えてみます。たとえば、fgets関数で標準入力からの入力を受け取るとします。fgets関数は、1行分のデータを求めている訳ですが、もしバッファリングが行われていたら、バッファにすでに入力データが蓄えられていないかどうかを確認します。そこに十分なデータがあれば、そこからデータを持ってきます。バッファに十分なデータがないのなら、現実の入力装置(キーボードなど)からの入力を待ち受けます。

C言語では、ストリームをバッファリングするかどうかに関して、3つの方針のいずれかを取るようになっています。

1つ目は、フルバッファリング(完全バッファリング) (full buffering) です。この方針では、そのストリームで行われるすべての入出力が、バッファを経由して行われます。そして、バッファが一杯になった段階で、バッファに蓄えられているデータを放出するように動作します。

2つ目は、ラインバッファリング(行バッファリング) (line buffering) です。この方針では、そのストリームで行われるすべての入出力が、バッファを経由して行われます。ただし、改行文字が現れた段階で、バッファに蓄えられているデータを放出するように動作します。

3つ目は、バッファリング無し (unbuffered) です。この方針では、そのストリームで行われるすべての入出力は、バッファを経由せずに行われます。

どれを選択するかについては、処理系が決めることになっています。一般的には、標準入力と標準出力はバッファリングされていて(完全か行かは明確でない)、標準エラーはバッファリング無しであることが多いです。

バッファリングの設定変更

バッファリングの方針は処理系によって異なりますが、後から変更できるかもしれません。これには、setbuf関数、あるいは setvbuf関数を使います。

setbuf関数と、setvbuf関数は、<stdio.h> に以下のように宣言されています。

void setbuf(FILE* restrict stream, char* restrict buf);
int setvbuf(FILE* restrict stream, char* restrict buf, int mode, size_t size);

restrict については、第57章で取り上げます。動作に影響はないので、今は無視して問題ありません。

setbuf関数は、以下のように呼び出した setvbuf関数と同等です。

setvbuf(stream, buf, _IOFBF, BUFSIZ);

また、戻り値もなく結果が分からないので、setvbuf関数の方だけを使えば良いはずです。

setvbuf関数の第1引数は、対象のストリームを指定します。

第2引数は、バッファとして使用する配列のメモリアドレスか、ヌルポインタのいずれかを渡します。ただし、第3引数が、_IONBF の場合には無視されます。

第2引数にメモリアドレスを渡す場合には、BUFSIZ で表される値以上の大きさを持った配列を指定しなければなりません。ヌルポインタを指定した場合には、setvbuf関数の中で自動的に確保されます。バッファを用意して渡す場合には、ストリームを使っている間、そのバッファが存在し続けなければなりません。

第3引数は、バッファリングのタイプを指定します。これは、_IOFBF_IOLBF_IONBF のいずれかを指定します。それぞれ、フルバッファリング、ラインバッファリング、バッファリング無しを表しています。

第4引数は、バッファの大きさを指定します。ただし、第3引数が _IONBF の場合には無視されます。第2引数に、配列のメモリアドレスを指定したのならば、その大きさを指定し、ヌルポインタを指定したのなら、自動的に確保させる大きさを指定します。いずれにしても、BUFSIZ の値以上の大きさが必要です。

戻り値は、成功時には 0、失敗時には 0以外の値です。

それでは、実際に試してみます。

#include <stdio.h>
#include <stdlib.h>

#define BUFFERING_MODE  _IOFBF

int main(void)
{
    static char stdin_buf[BUFSIZ];
    static char stdout_buf[BUFSIZ];

    if (setvbuf(stdin, stdin_buf, BUFFERING_MODE, sizeof(stdin_buf)) != 0) {
        fputs("stdin のバッファリングを変更できませんでした。\n", stderr);
        exit(EXIT_FAILURE);
    }
    if (setvbuf(stdout, stdout_buf, BUFFERING_MODE, sizeof(stdout_buf)) != 0) {
        fputs("stdout のバッファリングを変更できませんでした。\n", stderr);
        exit(EXIT_FAILURE);
    }

    char buf[80];

    printf("文字列を入力してください"); // 改行なし
    fgets(buf, sizeof(buf), stdin);
    printf("入力内容:%s\n", buf);
}

setvbuf関数の第3引数を、_IOFBF、_IOLBF、_IONBF のそれぞれに変えたとき、実行結果は次のようになります。入力は “Hello[改行]” としています。

実行結果(_IOFBF:フルバッファリングの場合)

Hello  <-- 入力した内容
文字列を入力してください入力内容:Hello

実行結果(_IOLBF:ラインバッファリングの場合)

Hello  <-- 入力した内容
文字列を入力してください入力内容:Hello

実行結果(_IONBF:バッファリング無しの場合)

文字列を入力してくださいHello  <-- "Hello" は入力した内容
入力内容:Hello

ただし、バッファリングは環境依存の処理なので、このプログラムがすべての環境で同じ結果になる保証はありません。たとえば、Visual Studio では、ラインバッファリングを指定しても、フルバッファリングになる環境があります。

最初に呼び出している printf関数は、改行文字を含まない文字列を出力しています。バッファリングされている場合にはバッファに格納され、バッファリング無しなら即座に出力されます。そのため、バッファリングされている場合には、入力を促すメッセージが表示されないまま、fgets関数の呼び出しへ進みます。

fgets関数は、標準入力から1行分のデータを受け取ろうとします。バッファリングされている場合には、バッファからデータを得ようとしますが、何もないので、実際の入力装置からの入力を待ち受けます。バッファリング無しの場合も、実際の入力装置からの入力を待ち受けます。

“Hello[改行]” という入力を行います。バッファリングされている場合には、バッファにデータが入ります。fgets関数が必要としているのは、改行文字までの1行分のデータなので、フルバッファリングかラインバッファリングかによらず、fgets関数はこの入力データを取り出し、変数buf に格納します。

バッファリング無しの場合は、単にバッファを経由しないだけであり、同様に、変数buf に入力データが格納されます。

続いて、printf関数が “入力内容:%s\n” を出力しようとします。バッファリングされている場合には、もともとバッファに入っていた “文字列を入力してください” の末尾に追記されます。

フルバッファリングの場合、まだバッファが一杯にならないので出力されません。ラインバッファリングの場合、改行文字が現れたので、この段階で出力が行われます。バッファリング無しの場合は、即座に出力されます。

フルバッファリングの場合、バッファが一杯にならないと放出されないので、このプログラムの実行結果はおかしいように思えます。用意したバッファの大きさに比べて、出力しようとした量は少ないので、何も出力されないままプログラムは終了しないのでしょうか?

フルバッファリングであっても、きちんとすべての出力が行われているのは、main関数から抜き出すときに、バッファに取り残されていた内容を放出することが規定されているためです。

ただし、注意しなければならないのは、バッファを自前で用意して setvbuf関数に渡している場合です。そのバッファが、自動記憶域期間を持っていると、main関数を抜き出した直後には、その存在が保証されませんから、未定義の動作になってしまいます。そのため、先ほどのサンプルプログラムのように static指定子を付けるか、グローバル変数にして、静的記憶域期間を持たせておく必要があります。

なお、exit関数を呼び出してプログラムを終えた場合も、バッファの内容は放出されます。

main関数の初回の呼び出しから戻るときの動作は、return文で指定する戻り値と同じものを、exit関数の実引数に指定したときの動作と同じであると規定されています。そして、exit関数は、バッファに取り残された内容を放出し、開かれたままのストリームを閉じることを保証しています。
なお、main関数の「初回」の呼び出しとしたのは、C言語では、main関数が再帰呼び出し第53章)できるからです。

一方、assertマクロによって、プログラムが強制停止させられた場合には、バッファ内容の放出が行われる保証がありません。これは、assertマクロがプログラムを停止させる際に呼び出される、abort関数の仕様です。

バッファリングの方法を切り替える処理は、基本的にはあまり使うことはないと思われます。しかし、知識としてバッファリング処理については知っておく必要はあります。入力や出力の関数を呼び出すプログラムを書いていて、実際にその関数が呼び出されていることも確実なのに、実際の入出力結果に現れないという現象に遭遇することがあるかもしれません。そのようなとき、バッファリングを思い出してください。バッファに蓄えられたデータを放出するタイミングはいつでしょう?

フラッシュ

バッファに蓄えられたデータは、フルバッファリングならバッファが一杯になったとき、ラインバッファリングなら改行文字が現れたときに、実際の入出力装置へ放出されます。これら以外のタイミングであっても、強制的に放出させる方法があります。このような操作を、フラッシュ (flush) といいます。ここでのフラッシュは「光 (flash)」ではなく、「押し流す (flush)」です。

フラッシュを行うには、fflush関数を使用します。fflush関数は、<stdio.h> に以下のように宣言されています。

int fflush(FILE* stream);

引数には、対象のストリームを指定します。ヌルポインタを指定した場合には、現在開かれているすべての出力のストリームが対象になります。

戻り値は、成功したら 0 、失敗すると EOF が返されます。

では、試してみます。

#include <stdio.h>
#include <stdlib.h>

#define BUFFERING_MODE  _IOFBF

int main(void)
{
    static char stdin_buf[BUFSIZ];
    static char stdout_buf[BUFSIZ];

    if (setvbuf(stdin, stdin_buf, BUFFERING_MODE, sizeof(stdin_buf)) != 0) {
        fputs("stdin のバッファリングを変更できませんでした。\n", stderr);
        exit(EXIT_FAILURE);
    }
    if (setvbuf(stdout, stdout_buf, BUFFERING_MODE, sizeof(stdout_buf)) != 0) {
        fputs("stdout のバッファリングを変更できませんでした。\n", stderr);
        exit(EXIT_FAILURE);
    }

    char buf[80];

    printf("文字列を入力してください"); // 改行なし
    if (fflush(stdout) == EOF) {
        fputs("フラッシュに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }
    fgets(buf, sizeof(buf), stdin);
    printf("入力内容:%s\n", buf);
}

実行結果:

文字列を入力してくださいHello  <-- "Hello" は入力した内容
入力内容:Hello

前の項のサンプルプログラムと同じ形ですが、今回は fflush関数を使って、強制的にフラッシュしています。setvbuf関数の第3引数を、_IOFBF、_IOLBF、_IONBF のいずれにしても、同じ結果になります。

ところで、fflush関数が動作するのは、最後の操作が入力でなかったストリームの場合に限られます。これら以外のストリームに対する動作は未定義です。つまり、書き込み用か、読み書き両用でオープンされたファイルや、stdout、stderr でなければなりません。

よく、以下のように、入力のストリームをフラッシュしようとするプログラムを見かけますが、これは未定義の動作になるので、使用してはいけません。

fflush(stdin);  // 未定義の動作

fgets関数が残した文字列を捨てる方法

入力のストリームをフラッシュするコードを書きたくなる場面がいくつかあります。たとえば、fgets関数を使って標準入力から読み取るとき、第2引数で指定した文字数の制限によって、行の途中で入力が打ち切られてしまう場合です。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char buf[5];
    unsigned int line = 0;

    for (;;) {
        if (fgets(buf, sizeof(buf), stdin) == NULL) {
            fputs("読み込み中にエラーが発生しました。\n", stderr);
            exit(EXIT_FAILURE);
        }

        // 末尾が改行文字であれば、'\0' で上書きする
        char* p = strchr(buf, '\n');
        if (p != NULL) {
            *p = '\0';
        }

        if (buf[0] == '\0') {
            break;
        }

        printf("%u: %s\n", line, buf);
        line++;
    }
}

実行結果:

abcdefghijklmn     (<-- 入力した内容)
0: abcd
1: efgh
2: ijkl
3: mn
                   (<-- 入力した内容)

改行までを1つの文字列として入力を受け取り、その都度、標準出力へ出力します。1回の入力を1行とみなし、1行の文字数は最大5文字(‘\0’ を含む)にしたいのです。

しかし、「実行結果」がそうであるように、一気に大量の文字列が入力されてしまうことがあります。fgets関数の第2引数に渡した値は 5 ですから、‘\0’ の分を省いた4文字分だけが、変数buf に格納され、残りはバッファリングされます。次回の fgets関数の呼び出しでは、バッファリングされたものが残っているので、そこから文字列を取り出してきます。結果、新たな入力を求められず、立て続けに4行分の出力が行われています。

このような挙動で構わないのであればいいのですが、予定以上の入力があったときに、余分なところは捨ててしまいたいこともあります。つまり、次のような実行結果を望む場合です。

abcdefghijklmn     (<-- 入力した内容)
0: abcd
efghijklmn         (<-- 入力した内容)
1: efgh
ijklmn             (<-- 入力した内容)
2: ijkl
mn                 (<-- 入力した内容)
3: mn
                   (<-- 入力した内容)

この結果を得るために、fgets関数の呼び出しの後、fflush(stdin); として、バッファリングされている内容を消そうとするのですが、これは未定義の動作になってしまいます]{.s}。

この結果を得たいのであれば、バッファに残された文字列を空読みして捨てるのが適切な方法です。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char buf[5];
    unsigned int line = 0;

    for (;;) {
        if (fgets(buf, sizeof(buf), stdin) == NULL) {
            fputs("読み込み中にエラーが発生しました。\n", stderr);
            exit(EXIT_FAILURE);
        }

        // 末尾が改行文字であれば、'\0' で上書きする
        char* p = strchr(buf, '\n');
        if (p != NULL) {
            *p = '\0';
        }
        else{
            // バッファに残された文字列を空読みする
            for (;;) {
                if (getchar() == '\n') { break; }
                if (feof(stdin)) { break; }
                if (ferror(stdin)) { return false; }
            }
        }

        if (buf[0] == '\0') {
            break;
        }

        printf("%u: %s\n", line, buf);
        line++;
    }
}

実行結果:

abcdefghijklmn     (<-- 入力した内容)
0: abcd
efghijklmn         (<-- 入力した内容)
1: efgh
ijklmn             (<-- 入力した内容)
2: ijkl
mn                 (<-- 入力した内容)
3: mn
                   (<-- 入力した内容)

fgets関数において、バッファに入力が取り残される状況のときは、受け取った文字列に改行文字が含まれていないはずです(改行文字が現れないまま、制限文字数に達したとき)。改行文字を取り除くコードを入れているのなら、改行文字が見つからなかったときに、空読みするコードを実行すればいいです。

空読みを行うには、getchar関数を使うことが多いです。getchar関数は、、<stdio.h> に以下のように宣言されています。

int getchar(void);

標準入力から1文字を受け取って返すだけのシンプルな関数です。

getchar関数は、関数ではなく関数形式マクロになっている可能性があります。置換のされ方によるトラブルを防ぐため、シンプルな呼び出し方を心がけましょう。

空読みを行うには、getchar関数の戻り値が改行文字かどうかを確かめるだけにして、どこにも保存しなければいいです。改行文字が現れたなら、そこまでが本来の1行なので、ここで空読みを止めます。

また、終端に達した場合や、何らかのエラーの発生も考慮して、feof関数ferror関数によるチェックも入れておきます。

fgets関数を使うたびにこんなに長いコードを書くのも辛いので、すべてをまとめた関数を作っておくといいかもしれません。

#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/*
    fgets関数をラップしたもの。

    fgets関数の場合に、末尾につく改行文字を取り除く。
    また、バッファに文字を取り残さず、読み捨てる。

    引数:
        buf:   fgets関数の第1引数と同じ
        size:  fgets関数の第2引数と同じ
        fp:    fgets関数の第3引数と同じ
    戻り値:
        正常終了したら true、エラーが発生したら false
*/
bool my_fgets(char* buf, int size, FILE* fp)
{
    assert(buf != NULL);
    assert(fp != NULL);

    if (fgets(buf, size, fp) == NULL) {
        return false;
    }

    // 末尾が改行文字であれば、'\0' で上書きする
    char* p = strchr(buf, '\n');
    if (p != NULL) {
        *p = '\0';
    }
    else{
        // バッファに残された文字列を空読みする
        for (;;) {
            if (getchar() == '\n') { break; }
            if (feof(stdin)) { break; }
            if (ferror(stdin)) { return false; }
        }
    }

    return true;
}

int main(void)
{
    char buf[5];
    unsigned int line = 0;

    for (;;) {
        if (! my_fgets(buf, sizeof(buf), stdin)) {
            fputs("読み込み中にエラーが発生しました。\n", stderr);
            exit(EXIT_FAILURE);
        }

        if (buf[0] == '\0') {
            break;
        }

        printf("%u: %s\n", line, buf);
        line++;
    }
}

入力を押し戻す

ungetc関数を使って、文字をストリームへ押し戻せます。ただし、入力のストリームに限られます。押し戻された文字は、次回、そのストリームから読み取りを行ったときに、最初に取り出されます。

押し戻しは、そのストリームのバッファリングの有無とは無関係に行えますが、保証されているのは、1文字分だけです。2文字以上押し戻すことができるかどうかは、環境に依存します。

ungetc関数は、<stdio.h> に以下のように宣言されています。

int ungetc(int c, FILE* stream);

第1引数に押し戻す文字を、第2引数にストリームを指定します。

戻り値は、押し戻す操作に成功した場合はその文字を、失敗した場合は EOF を返します。

なお、押し戻された文字を取り出さないまま、fseek関数などを使ってシークさせた場合、その文字は失われます。また、押し戻したからといって、実際の入力装置やファイルの内容にも反映されるわけではありません

動作を確認してみましょう。

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE* fp = fopen("test.txt", "r");
    if (fp == NULL) {
        fputs("ファイルオープンに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }


    int c;

    // [!] エラーチェックは省いています

    // 1文字目を読み込む
    c = fgetc(fp);
    printf("%c", c);

    // 2文字目を読み込む
    c = fgetc(fp);
    printf("%c", c);

    // 2文字目を押し戻す
    ungetc(c, fp);

    // 押し戻した文字が読み込まれる
    c = fgetc(fp);
    printf("%c", c);


    if (fflush(stdout) == EOF) {
        fputs("フラッシュに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

    if (fclose(fp) == EOF) {
        fputs("ファイルクローズに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }
}

入力ファイル (test.txt)

abc

実行結果

abb

ungetc関数は、ある文字が現れたら(あるいは、ある文字でない文字が現れたら)、他の処理を行わなければならないときに利用できます。入力のストリームから、シーケンシャルアクセスで読み込みを行う場合、実際に文字を読み取ってみないことには、次にどんな文字が現れるのか分からない訳ですから、読み取ってから判断するしかありません。すると、今読み取った文字は、今はいらないからキャンセルしたいというケースが出てきます。この「読み過ぎをキャンセルする」という目的で、ungetc関数が利用できます

別に ungetc関数を利用しなくとも、読み過ぎた文字をどこかの変数に退避させておくという手段での解決も図れる訳ですから、必須というものでもありませんが、プログラムの種類によっては便利に使えることもあるかもしれません。


練習問題

問題① 標準出力へ書き出す内容がバッファに残されている状態で、main関数の終了、exit関数による終了、abort関数による終了がそれぞれどのような結果になるか確認してください。


解答ページはこちら

参考リンク


更新履歴

≪さらに古い更新履歴を展開する≫



前の章へ (第42章 バイナリファイルの読み書き)

次の章へ (第44章 ファイルに対する操作)

C言語編のトップページへ

Programming Place Plus のトップページへ



はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る