テキストファイルの読み書き | Programming Place Plus C言語編 第40章

トップページC言語編

このページの概要 🔗

以下は目次です。


読み書き両用のオープンモード 🔗

前章で、fputs関数を使って、“Hello, World” という文字列をファイルへ書き込むプログラムを試しました。また、fgets関数を使って、ファイルから文字列を読み取れることも確認しました。

ここでは、同じファイルに対して、書き込みと読み込みを両方とも行うプログラムを考えてみます。つまり、“Hello, World” という文字列を書き込んだ後、続けざまに、そのファイルの内容を読み取ってみます。

読み書きの両方を行うためには、読み書き両用のオープンモードでファイルを開く必要があります。前章のオープンモードの一覧表にあるように、読み書き両用でファイルを開くことができるオープンモードは多数あります。書き出してみると、以下のものがあります。

【上級】まず “w” で開いて書き込みを行った後、いったんファイルを閉じて、再度 “r” で開きなおすことでも実現できますが、厳密にいえば、ファイルを閉じて開きなおす隙間の時間ができますから、余計なトラブルを招く恐れがあります。たとえば、隙間の時間のあいだに、ファイルが削除されたり、ほかのプログラムに書き換えられたりするかもしれません。

オープンモード 意味
r+ テキストファイルを読み書き両用に開く
w+ テキストファイルを読み書き両用に開く
a+ テキストファイルを読み書き両用で追加あるいは作成する
rb+ または r+b バイナリファイルを読み書き両用に開く
wb+ または w+b バイナリファイルを読み書き両用に開く
ab+ または a+b バイナリファイルを読み書き両用で追加あるいは作成する

fputs関数や fgets関数を使うのなら、行単位での操作ですから、テキストファイルとして開けるものを選択します。そのため、“r+”、“w+”、“a+” のいずれかを使います。このうち、“a+” は追記書き込みのためのモードであり、目的に合わないので候補から外します(追記書き込みについては、第41章で取り上げます)。

残りの “r+” と “w+” の違いは、以下の2点です。

“r+” の方は基本的に読み込み操作に主眼を置いており、いったん読み込みを行った後で、書き込み処理も行うことを想定しています。“w+” の方は、書き込み操作に主眼を置いており、いったん書き込みを行った後で、読み込み処理も行うことを想定しています。

今回は、“Hello, World” という文字列を書き込んだ後で、それを読み込もうとしていますから、“w+” の方が適切です。

“w+”モードを使って、プログラムを書いてみます。

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

int main(void)
{
    // ファイルを読み書き両用でオープン
    FILE* fp = fopen("hello.txt", "w+");
    if (fp == NULL) {
        fputs("ファイルオープンに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }


    // ファイルへ書き出す
    if (fputs("Hello, World\n", fp) == EOF) {
        fputs("ファイルへの書き込みに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

    // ファイルから読み込む
    char buf[80];
    if (fgets(buf, sizeof(buf), fp) == NULL) {
        fputs("ファイルからの読み込みに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

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

    // 読み込んだ文字列を、標準出力へ出力
    puts(buf);
}

ところが、このプログラムではうまく動作しません。ここで問題なのは、fputs関数を呼び出した直後に、そのまま fgets関数を呼び出してしまっている点にあります。何が問題なのか、そしてどう解決するかは、次の項で説明します。

ファイルポジション 🔗

前の項からの続きです。

fputs関数にせよ、fgets関数にせよ、読み書きの対象がファイル内のどの部分(どの位置)なのかという情報が必要です。この情報は、ファイルポジション (file position) と呼ばれており、FILEオブジェクトの中で管理されています。ファイルに対する読み書きを行う標準ライブラリ関数は、ファイルポジションの位置に対して処理を行います。

FILEオブジェクトの内容は実装依存になっており、直接中身を使うべきではありません。

ファイルポジションは、読み書きを行うたびに移動します。たとえば、fputs関数による書き込みでは、最後に書き込まれた文字の直後の位置が、新しいファイルポジションです。ですから、再度 fputs関数で書き込みを行うと、その続きの位置へ書き込まれます。

前の項のサンプルプログラムの問題は、fputs関数によってファイルポジションが移動した後、その位置を対象にして fgets関数を呼び出してしまっている点にあります。fputs関数の呼び出しの後、ファイルポジションは書き込んだ最後の文字の直後にあるので、この位置に対して fgets関数を呼び出しても、そこには有効なデータが何もない訳です。

そこで、fgets関数を呼び出す前に、ファイルポジションをファイルの先頭に戻してやる必要があります。この操作には、fseek関数を使います。

ファイルポジションを先頭に戻す標準ライブラリ関数には、rewind関数もありますが、こちらはエラーの確認がまったくできないという問題があります。fseek関数を使うようにしましょう。

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

int fseek(FILE* stream, long int offset, int origin);

fseek関数はファイルポジションを(ある程度の制約の中で)自由に移動させる関数です。先頭に戻すことは、使い方の1つに過ぎません。詳しいことは、第41章であらためて取り上げることにして、ここでは、ファイルポジションを先頭に戻す例だけを説明します。

fseek関数を使って、ファイルポジションを先頭に戻すには、次のように呼び出します。

if (fseek(fp, 0L, SEEK_SET) != 0) {
    // エラー発生時の処理
}

第1引数は FILEオブジェクトへのポインタです。第2、第3引数はつねに 0L と SEEK_SET を指定します。戻り値は、成功した場合は 0、失敗した場合は 0以外です。

それでは、先ほどのサンプルプログラムに rseek関数を追加してみましょう。

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

int main(void)
{
    // ファイルを読み書き両用でオープン
    FILE* fp = fopen("hello.txt", "w+");
    if (fp == NULL) {
        fputs("ファイルオープンに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }


    // ファイルへ書き出す
    if (fputs("Hello, World\n", fp) == EOF) {
        fputs("ファイルへの書き込みに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

    // ファイルポジションを先頭に戻す
    if (fseek(fp, 0L, SEEK_SET) != 0) {
        fputs("ファイルポジションの移動に失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

    // ファイルから読み込む
    char buf[80];
    if (fgets(buf, sizeof(buf), fp) == NULL) {
        fputs("ファイルからの読み込みに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

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

    // 読み込んだ文字列を、標準出力へ出力
    puts(buf);
}

実行結果(標準出力):

Hello, World

実行結果(hello.txt):

Hello, World

今度は正しく動作しました。

ファイルの終わりの検出 🔗

今度は、複数の行で構成されているテキストファイルの内容をすべて読み込んで、標準出力に出力することを考えてみましょう。

fgets関数を使う方法と、1文字ずつ読み込める fgetc関数を使う方法が考えられます。後者はまだ扱っていない関数ですので、後の項で取り上げることにして、まずは使い慣れた fgets関数でやってみます。

fgets関数は、正常にデータを読み取ったときには、第1引数に指定したポインタと同じものを返します。一方、読み取れなかった場合には、ヌルポインタを返します。しかし、これまでに見てきたとおり、fgets関数はエラー時にもヌルポインタを返します。

そのため、fgets関数がヌルポインタを返したら、ファイルの終わりに到達しているという判定では不適切です。ファイルの終わりに到達することは正常な動作であり、エラーが発生することは異常な動作のはずですから、区別を付けるべきです。

ファイルの終わりに達したかどうかを判定するには、feof関数を使います。feof関数は、<stdio.h> に以下のように宣言されています。

int feof(FILE* stream);

feof関数の引数には、FILEオブジェクトへのポインタを渡します。ファイルポジションが、ファイルの終わりに達していたら 0以外の値を返し、達していなければ 0 を返します。

プログラムを作成してみます。

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

int main(void)
{
    // 読み取り用にテキストファイルをオープン
    FILE* fp = fopen("test.txt", "r");
    if (fp == NULL) {
        fputs("ファイルオープンに失敗しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

    // 各行を読み取りながら、標準出力へ出力
    for (;;) {

        // fgets関数を呼び出して、1行分読み取る。
        // NULL が返されたら、ファイルの終わりかエラー発生。
        char buf[80];
        if (fgets(buf, sizeof(buf), fp) == NULL) {

            // feof関数を呼び出して、ファイルの終わりかどうか問い合わせる。
            // 戻り値が 0以外ならファイルの終わり、そうでなければエラー発生。
            if (feof(fp)) {
                break;
            }
            else {
                fputs("エラーが発生しました。\n", stderr);
                exit(EXIT_FAILURE);
            }
        }

        fputs(buf, stdout);
    }

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

入力ファイル(test.txt)

1行目
2行目
3行目

実行結果(標準出力):

1行目
2行目
3行目

あらかじめ、test.txt を準備してから実行してください。

まず fgets関数がヌルポインタを返してきたことを確認した後、feof関数を呼び出しています。feof関数が 0以外の値を返したのならファイルの終わりに達したのであり、0 を返したのならエラーが発生しています。

【上級】ferror関数という標準ライブラリ関数があり、これでもチェックできそうに思えますが、その保証はありません。ferror関数は、FILEオブジェクトの中にあるエラー表示子というフラグのようなものを参照して、エラー発生の有無を返します。fgetc関数などのいくつかの関数がエラー発生時にエラー表示子をセットすることになっていますが、fgets関数にはそのような規定がありません。

ところで、標準出力への出力時に、puts関数でなく、fputs関数を使っています。fgets関数は行末の改行文字まで受け取ります。改行文字を付加する puts関数を使うと、さらに改行されてしまうので、改行文字を付加しない fputs関数を使っています。

これでもいいのですが、今後、柔軟に fgets関数を使えるように、末尾についてきてしまう改行文字を取り除く方法を知っておきましょう。

fgets関数が読み込む改行文字を取り除く 🔗

fgets関数は行末の改行文字も読み取ってしまうため、場合によっては邪魔になることがあります。これを取り除く方法を説明します。

気を付けなければならないことが2点あります。

まず1つは、必ずしも改行文字が付いてくるとは限らないということです。なぜなら、fgets関数の第2引数で指定した値によって、読み取り処理が打ち切られるため、行の途中で読み取りが止まる可能性があるからです。

そのため、fgets関数が読み取ってきた文字列の末尾(‘\0’ の手前)に改行文字があると決めつけたコードは書かないほうが安全です。たとえば次のコードは、誤って、改行文字でない文字を上書きしてしまう恐れがあります。

char buf[80];
if (fgets(buf, sizeof(buf), fp) != NULL) {

    // 末尾の改行文字を '\0' で上書きしているつもり
    buf[strlen(buf) - 1] = '\0';
}

strlen関数を使って文字数を調べ、そこから -1 することで、末尾の添字を得ています。その添字が示す位置に改行文字があるという前提のもとで、‘\0’ を書き込んで、改行文字を消し去ろうという意図です。

実はこの方法は、最初に2つあると書いた注意点の2つ目にも違反してしまいます。2つ目の注意点は、fgets関数がヌルポインタを返さなかったからといって、1文字以上の文字列を受け取れているとは限らないことです。

そのような事態はあまりないことではありますが、読み取った最初の文字が ‘\0’ と一致する文字だった場合、読み取られた文字列は “” になります。これは0文字の文字列(空文字列)です。

すると「strlen(buf) - 1」が問題になります。空文字列に対して strlen関数が返す結果は 0 ですし、size_t型は符号無し整数ですから、-1 した結果は巨大な正の数です。buf の範囲外を書き換えるコードになってしまい、未定義の動作となります。

そこで、strchr関数を使います。strchr関数は、<string.h> に次のように宣言されています。

char* strchr(const char* s, int c);

第1引数に指定した文字列を先頭から調べ、第2引数に指定した文字を探します。見つかれば、見つけた文字へのポインタを返し、見つからなければヌルポインタを返します。

strchr関数を使うと、次のように書けます。

char buf[80];
if (fgets(buf, sizeof(buf), fp) != NULL) {

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

こうすれば、改行文字でなければ上書き処理は実行されませんし、添字が登場しませんから、strlen関数を使ったときのように、不正な書き込みを行う問題も起こりません。


fputc関数と fgetc関数 🔗

次は、1文字ずつの読み書きをしてみます。ここで使うのは、fputc関数fgetc関数です。

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

int fputc(int c, FILE* stream);
int fgetc(FILE* stream);

fputc関数は、第1引数に書き出したい文字を指定し、第2引数に FILEオブジェクトへのポインタを指定します。

正常に書き込めた場合は書き込んだ文字を返します。失敗した場合は EOF を返します。また、失敗の有無は ferror関数での確認も可能です。

fgetc関数は、引数に FILEオブジェクトへのポインタを指定します。

正常に読み取れた場合はその文字を返します。ファイルの終わりに達していたり、エラーが発生したりした場合は EOF を返します。

fgetc関数が EOF を返したときに、その原因を調べるには、feof関数や ferror関数を使わなければなりません。このとき、ごくわずかな可能性ではありますが、feof関数と ferror関数がいずれも 0 を返すこともありえるので注意しましょう。このケースは、読み取った文字が EOF と一致してしまうときに起こります。

【上級】EOF はつねに int型の -1 に置換されます。ここで、sizeof(char) == sizeof(int) になる環境があるとします。int型で表現できなければならない値の範囲が定められているため、実質 int型は最低 16bit です。16bit であると仮定すると、sizeof(char) も 16bit です。このような環境で、ある有効な文字が 65535 という値で表現されると(これまた、そのような文字コードを使っているという仮定の下)、これは 16bit の int型では -1 と一致しますから、EOF と区別がつきません。そのため、fgetc関数の戻り値が EOF と一致したとき、それは (65535 という値をもつ) 有効な文字だったのかもしれません。その場合、ファイルの終わりに達したわけではないので feof関数は 0 を返しますし、エラーが発生したわけでもないので ferror関数も 0 を返します。

読み書き両用モード “w+” を使って、書き込みを行った後、その内容を読み取ってみることにします。多少それらしくするため、今回は、書き込み部分と読み込み部分を関数化してみます。

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

static bool write_file(FILE* fp, const char* text);
static bool read_file_and_puts(FILE* fp);

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

    if (!write_file(fp, "Hello, World")) {
        fputs("書き込み中にエラーが発生しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

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

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

/*
    ファイルに文字列を書き込む。
    引数:
        fp:   書き込み先のストリーム
        text: 書き込む文字列
    戻り値:
        成功したら真、失敗したら偽
*/
static bool write_file(FILE* fp, const char* text)
{
    // 1文字ずつ書き込む
    for (const char* p = text; *p != '\0'; ++p) {
        fputc(*p, fp);
    }

    // エラーの有無を返す
    return !ferror(fp);
}

/*
    ファイルの内容を読み取って、標準出力へ出力する
    引数:
        fp:   読み込み先のストリーム
    戻り値:
        成功したら真、失敗したら偽
*/
static bool read_file_and_puts(FILE* fp)
{
    // ファイルポジションを先頭に巻き戻す
    if (fseek(fp, 0L, SEEK_SET) != 0) {
        return false;
    }

    bool ret = false;

    for (;;) {
        // fgetc関数を呼び出して、1文字読み取る。
        // EOF が返されたら、ファイルの終わりかエラー発生。
        int c = fgetc(fp);
        if (c == EOF) {

            // feof関数を呼び出して、ファイルの終わりかどうかを、
            // ferror関数を呼び出して、エラー発生かどうかを問い合わせる。
            // どちらでもない場合は、EOF と同じ値の文字を読み取っている。
            if (feof(fp)) {
                ret = true;
                break;
            }
            else if (ferror(fp)) {
                ret = false;
                break;
            }
            else {
                // 有効な文字なので、そのまま続行
            }
        }
        printf("%c", (char)c);
    }

    printf("\n");
    return ret;
}

実行結果(標準出力):

Hello, World

実行結果(hello.txt):

Hello, World

fputc関数は失敗すると EOF を返すので、エラーチェックにはこれを使ってもいいのですが、ここでは ferror関数を使いました。fputc関数のエラーを戻り値で調べると、呼び出し回数によっては膨大な数の条件判定を行うことになるため、ひととおりの書き込み作業を終えてから、1度の確認で済ませるためです。

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

int ferror(FILE* stream);

ferror関数は、引数に FILEオブジェクトへのポインタを指定します。戻り値は、エラーが発生していたら 0以外、発生していなければ 0 です。厄介なことに、ferror関数がファイルに関するエラーを確実にすべて報告してくれるわけではありません。fgets関数のときに使わなかったのはそのためです。

一方、読み込みには fgetc関数を使っています。注意すべき点として、fgetc関数の戻り値を char型の変数で受け取らないようにしてください。文字を扱っているのでついやってしまいがちですが、char型で受け取ると、EOF との比較が正しく行えない可能性があります。

EOF の置換結果は int型の -1 です。これを char型として扱うと、char型が 8bit で符号無しの環境(第19章)では 255 に変換されます。しかし、255 で表される有効な文字が存在する可能性があるため、有効な文字としての 255 なのか、EOF としての 255 なのかを区別できないことがあります。int型で扱うようにしていれば、有効な文字は 255、EOF は -1 なので、確実に正しい比較が行えます。


fprintf関数と fscanf関数 🔗

これまでに1行単位の読み書き、1文字単位の読み書きが登場しました。最後に、printf関数や scanf関数のような、変換指定を伴う文字列の扱いです。

これは簡単で、printf関数や、scanf関数に、ストリームの指定が加わった、fprintf関数fscanf関数を使うだけです。

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

int fprintf(FILE* restrict stream, const char* restrict format, ...);
int fscanf(FILE* restrict stream, const char* restrict format, ...);

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

「…」は初めて見るかもしれませんが、0個以上の仮引数がそこに並ぶことを意味しています。printf関数や sscanf関数の使い方を考えれば、その意味が分かると思います。この記法の詳しい話題は、第52章で取り上げます。

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

struct Data_tag {
    char text[32];
    int  value;
};

static bool write_file(FILE* fp, const struct Data_tag* data, size_t size);
static bool read_file_and_puts(FILE* fp);

int main(void)
{
    const struct Data_tag data[] = {
        "abc", 10,
        "defg", 20,
        "hijkl", 30
    };

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

    if (!write_file(fp, data, sizeof(data)/sizeof(data[0]))) {
        fputs("書き込み中にエラーが発生しました。\n", stderr);
        exit(EXIT_FAILURE);
    }

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

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

static bool write_file(FILE* fp, const struct Data_tag* data, size_t size)
{
    for (size_t i = 0; i < size; ++i) {
        if (fprintf(fp, "%s %d\n", data[i].text, data[i].value) < 0) {
            return false;
        }
    }

    return true;
}

static bool read_file_and_puts(FILE* fp)
{
    // ファイルポジションを先頭に戻す
    if (fseek(fp, 0L, SEEK_SET) != 0) {
        return false;
    }

    for (;;) {
        struct Data_tag data;

        if (fscanf(fp, "%31s %d", data.text, &data.value) == EOF) {
            if (feof(fp)) {
                return true;
            }
            else {
               return false;
            }
        }
        printf("%s %d\n", data.text, data.value);
    }
}

実行結果(標準出力):

abc 10
defg 20
hijkl 30

実行結果(test.txt):

abc 10
defg 20
hijkl 30

fprintf関数、fscanf関数のどちらも、第1引数に FILEオブジェクトへのポインタの指定が加わっているだけであり、それ以外に違いはありません。fprintf関数であれば stdout を、fscanf関数であれば stdin を指定すれば、それぞれ printf関数や scanf関数と同じ結果になります。

printf系の関数は、成功した場合には出力された文字数を、失敗した場合には負数を返します。

scanf系の関数は、成功した場合には受け取ることができた値の個数を、失敗した場合には EOF を返します。

fscanf関数の場合、ファイルの終わりに達した場合にも、エラーが発生した場合にも EOF を返しますから、いつものように区別が必要です。fgets関数のときと同じく、ファイルの終わりを feof関数で確認します。feof関数が 0以外を返すならファイルの終わりに達しており、0 を返すならエラーが発生しています。


練習問題 🔗

問題① 標準入力から受け取った内容を、テキストファイルへ書き出すプログラムを作成してください。細かい仕様はお任せします。

問題② 標準入力から、テキストファイルのファイルパスを受け取り、そのファイルの内容を標準出力へ出力するプログラムを作成してください。

問題③ 問題②のプログラムを改造して、標準入力から指示された行の内容だけを、標準出力へ出力するようにしてください。

問題④ 1文字ずつ読み書きする標準関数を使って、既存のテキストファイルのコピーを作り出すプログラムを作成してください。

問題⑤ テキストファイルのデータ中に含まれているタブ文字を、半角空白に置き換えた結果を、別のファイルへ出力するプログラムを作成してください。 ただし、タブの幅は自由に決めてください。


解答ページはこちら

参考リンク 🔗


更新履歴 🔗

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



前の章へ (第39章 ファイルの利用)

次の章へ (第41章 ランダムアクセス)

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

Programming Place Plus のトップページへ



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