ランダムアクセス

ファイル処理6

ランダムアクセスとは

今までのファイル処理は、先頭から順に読み書きするだけでした。
これでも一応ファイルを扱うことはできますが、巨大なファイルの場合に毎回先頭から処理をしていては効率が良くありません。
読み書きしたいファイル内の位置が分かっているならば、ダイレクトにその場所を指定できたほうが良いでしょう。

ファイル内の位置を指定して読み書きする方法をランダムアクセスといいます。
先頭から順に処理する方法をシーケンシャルアクセスといいます。

ファイル位置(ファイル位置指示子)を移動させるにはfseek関数を使用します。

#include <stdio.h>

void Write(const char *file)
{
    FILE *fp;

    //fp = fopen(file, "wb");
    fopen_s(&fp, file, "wb");

    if (fp == NULL)
    {
        printf("%sのオープンに失敗しました。\n", file);
        return;
    }

    int intArr[] = { 1, 2, 3, 4 , 5,
                    6, 7, 8, 9, 10 };

    fwrite(intArr, sizeof(int), 10, fp);

    fclose(fp);

    printf("%sに保存しました。\n", file);
}

void Read(const char *file)
{
    FILE *fp;

    //fp = fopen(file, "rb");
    fopen_s(&fp, file, "rb");

    if (fp == NULL)
    {
        printf("%sのオープンに失敗しました。\n", file);
        return;
    }

    int integer;
    size_t pos = sizeof(int) * 2;

    fseek(fp, pos, SEEK_SET);
    fread(&integer, sizeof(int), 1, fp);
    printf("先頭から%dバイト目のデータ: %d\n", pos, integer);

    pos = sizeof(int) * 0;
    fseek(fp, pos, SEEK_CUR);
    fread(&integer, sizeof(int), 1, fp);
    printf("前回から%dバイト後のデータ: %d\n", pos, integer);

    pos = sizeof(int) * 3;
    fseek(fp, pos, SEEK_CUR);
    fread(&integer, sizeof(int), 1, fp);
    printf("前回から%dバイト後のデータ: %d\n", pos, integer);

    pos = sizeof(int) * 4;
    fseek(fp, 0 - pos, SEEK_CUR);
    fread(&integer, sizeof(int), 1, fp);
    printf("前回から%dバイト前のデータ: %d\n", pos, integer);

    fclose(fp);
}

int main()
{
    const char *file = "C:\\test.dat";

    Write(file);
    Read(file);

    getchar();
}

今回からバイナリファイルの拡張子は「.dat」とすることにします。

C:¥test.datに保存しました。
先頭から8バイト目のデータ: 3
前回から0バイト後のデータ: 4
前回から12バイト後のデータ: 8
前回から16バイト前のデータ: 5

サンプルコードで作成されるバイナリファイルは1~10の整数を順番に書き込んだだけのものです。

fseek関数

int fseek(
 FILE *stream,
 long offset,
 int origin
);
ファイルストリームstreamのファイル位置指示子をoriginを基準にoffsetバイト移動する。
戻り値は、正常終了時に0。
エラー時は0以外を返す。

fseek関数の第三引数には以下の定数を指定します。

SEEK_SET
ファイルの先頭。
SEEK_CUR
現在の位置。
SEEK_END
ファイルの終端。
バイナリモードでは指定してはならない。

読み取り側の処理では、まずfseek関数の第三引数に「SEEK_SET」をセットし、ファイル先頭から8バイト目のデータを読み取っています。
先頭から0バイト目は一番最初の値、つまり「1」となります。
int型変数のサイズは4バイトですから(32bit Windowsの場合)、先頭から8バイト目は三つ目の値、つまり「3」となります。

次のfseek関数では第三引数に「SEEK_CUR」をセットしています。
freadでデータを読み取ると、ファイル位置は読み取ったデータの直後にセットされます。
前回から0バイト後ろ、つまりファイル位置を動かさない状態でデータを読むと次のデータが読み取られます。
(つまり、47行目のfseek関数は無意味ということです)

「4」を読み取った後、つまり「5」の位置から12バイト後ろのデータは「8」になります。
(12 ÷ 4 = 3)
「9」の位置から16バイト前のデータは「5」となります。
(16 ÷ 4 = 4)

シーケンシャルアクセスの場合、不要なデータを読み捨てることで「ファイル位置を後ろに移動させる」ことはできますが、「ファイル位置を前に戻す」ということはできませんでした。
前の位置にあるデータが欲しい場合は先頭から読み直すしかないのですが、fseek関数を使えば「今の場所から〇バイト前/後」のデータを簡単に読み取ることができます。

ただし、ファイルの構造が分からないと正しくデータを読み取ることはできません。
ファイルのどこにどのようなデータが何バイト入っているかを把握しておく必要があります。

バイナリモードのSEEK_ENDについて

バイナリモードでファイルを開いている場合、fseek関数で「SEEK_END」を指定してはいけません。
コンパイラによっては上手く動くかもしれませんが、C言語では定義されていない動作となるので意図しない動作となる可能性があります。

テキストモードでのfseek関数

ファイルをテキストモードで開いているとき、fseek関数のoffset(第二引数)とorigin(第三引数)には制限があり、以下のどちらかの条件に従う必要があります。

offsetは0を指定する

バイナリモードではファイル位置を調節するためにoffsetにバイト数を指定していましたが、このような調節はできません。
つまり、ファイル位置を先頭にするか末尾にするかのどちらかの操作しかできません。
(SEEK_CURを指定しても意味がありません)

offsetはftell関数の戻り値を指定する

もう一つ、offsetにはftell関数の戻り値を指定することができます。

long ftell(
 FILE *stream
);
ファイルストリームstreamのファイル位置子を返す。

ftell関数は、現在のファイル位置を返す関数です。
この関数によって取得した値であればoffsetに指定することができます。
この場合、originには必ず「SEEK_SET」を指定します。

//読み込むテキストファイルには
//ABCDEFGHI...の文字列があるものとする

FILE *fp;
fopen_s(&fp, "C:\\test.txt", "r");
//エラー処理
//...

char str1[3];
char str2[3];
char str3[3];

fread(str1, sizeof(char), 2, fp);
str1[2] = '\0';

//「AB」まで読み込んだ時点のファイル位置を保存しておく
long pos = ftell(fp);

fread(str2, sizeof(char), 2, fp);
str2[2] = '\0';

//ファイル位置を戻す
fseek(fp, pos, SEEK_SET);

fread(str3, sizeof(char), 2, fp);
str3[2] = '\0';

printf("%s\n", str1); //AB
printf("%s\n", str2); //CD
printf("%s\n", str3); //CD

通常、fread関数を使用する毎にファイル位置は読み取った分だけ後ろに移動するので、取得される値はそれぞれ「AB」「CD」「EF」となるはずです。
しかし23行目のfseek関数により、ファイル位置を「AB」の読み込取り直後に戻しているため、三回目のfread関数でも「CD」が読み取られることになります。

なお、fread関数はファイルの現在位置から指定したバイト数のデータを読み取る、という関数に過ぎません。
文字列を読み取った場合、文字列配列の最後にNULL文字が付くことは保障されません。
読み取ったデータの末尾にNULL文字が存在しないことが分かっているのであれば、サンプルコードのように手動でNULL文字を付加する必要があります。

ちなみにfgets関数は文字列を取得するための関数なので、自動的に末尾にNULL文字が付加されます。

改行コードの取り扱い

Windows環境では、改行コードは実は二文字(2バイト)で表現されています。
MacOS(UNIX、Linux)環境では、改行コードは一文字です。
(古いMacOSでも一文字ですが、コードの種類が異なります)

OS 改行コード 16進数
Windows CR+LF
(\r\n)
0D 0A
Mac OS X(UNIX、Linux) LF
(\n)
0A
Mac OS 9以下 CR
(\r)
0D

例えば、「ABC(改行)DEF」という文字列をWindowsとMacOSのテキストエディタで作成するとします。
それらをバイナリデータとして表示すると以下のようになります。
WindowsとMacOSの改行コードの違い

今までのサンプルコードでは、文字列の改行はすべて「\n」を用いてきましたが、Windows環境ではファイルの読み取りの際に「\r\n」は「\n」に、書き込みの際に「\n」は「\r\n」に自動的に変換されます。
ただし変換されるのはテキストモードの場合で、バイナリモードでは変換は行われません

何が問題か

MacOSで作成したテキストファイルを、Windowsでバイナリモードで開いた時、改行コードを二文字として扱ってしまうとデータがズレてしまいます。
上記の図の例では、「D」を取得しようと先頭から6文字目を指定しても、実際に取得されるのは「E」となります。
もちろんOSが逆の場合でも問題は発生します。

「現在のファイル位置から『\n』までを一行とみなし文字列配列に保存する」というようなコードだと、Windowsで作成したテキストを読み込むと配列の最後に「\r」という不要なデータが含まれてしまいます。
さらに、MacOS9以前に作られたテキストだと改行コードを検出できません。

バイナリモードとテキストモード、さらに各OSでの改行コードの違いに注意しないと思ったように動作しない可能性があります。