ファイル操作

今回の資料には,理解の確認や考察の幅を広げるための「QUIZ(参考)」をいくつか設けています.あくまで,参考として示しているものです.特別な指示がない限り,最初は,読み飛ばしても結構です.

C言語におけるファイル入出力

ファイルの入出力(読み書き)について,軽く復習をしておきましょう.ここでは,教科書の第7章,特に,教科書7.5節「ファイル・アクセス」の内容を中心に,おさらいをしておきます.

ファイルポインタを介したファイルの読み書き

ファイルへの書き込みの例

以下は,hello-world.txtというファイルに,Hello world!と書き出すプログラムです.

Listing 19 例1 p07-fwrite_hello_world.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main()
{
  FILE *fp;
  char *filename = "hello-world.txt";

  fp = fopen(filename, "w"); /* filename を書込モード "w" で開く */

  if (fp == NULL) { /* fp が NULL なら,オープン失敗 */
    fprintf(stderr, "Could not open file: %s\n", filename);
    return -1;
  }
  fprintf(fp, "Hello world!\n");
  fclose(fp);  /* ファイルを閉じる */

  return 0;
}

実際にコンパイルして,試してみましょう.

QUIZ(参考):

コンパイルと実行を行い,Hello world!と書かれたファイルhello-world.txtが生成されていることを確認してください.(ヒント:手順は分かりますね?分からない場合は,プログラミング演習1初回の資料に戻って,復習してください.あるいは,FAQを参照してください.)

プログラムのおおまかな流れは,以下の通りです.

  1. FILE*型のfpを宣言する

    FILE*型のことをファイルポインタといいます.FILE*型は,stdio.hファイルの中で宣言されています.

    #include <stdio.h>stdio.hを自分のプログラム中に読込むという意味でしたね.

  2. fopen()関数でファイルを開いて,fpに代入

    ファイルポインタは,ファイルを読み書きするためのチャンネル(窓口)と考えることができます.fopen()ではfpを特定のファイルに結び付けます.

  3. fprintf()関数でfpチャンネルに"Hello world!\n"を書く

    fprintf()関数やfgets()関数,fscanf()関数,fputc()関数など,頭にfが付く関数は,ファイルポインタ経由でファイルにアクセスする関数です.fopen()で開いたFILE*型の変数を,入力元や出力先として指定します.

  4. 最後にfclose()関数でファイルを閉じる

ファイルからの読み込みの例

以下は,sample.csvファイルを開いて,行番号を先頭に付与しながら,画面に出力するプログラムです.(cat -nに似た動きをするプログラムです.)

Listing 20 例2 p07-catnum.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

int main()
{
  FILE *fp;
  char *filename = "sample.csv";

  int n;
  char buffer[1024];

  fp = fopen(filename, "r"); /* filename を読込モード "r" で開く */

  if (fp == NULL) { /* fp が NULL なら,オープン失敗 */
    fprintf(stderr, "Could not open %s\n", filename);
    fclose(fp);
    return -1;
  }

  /* fgets が成功している間(ファイルが終わりでない間)ループ */
  n = 0;
  while (fgets(buffer, 1024, fp) != NULL) {
    printf("%6d\t%s", ++n, buffer);
  }
  fclose(fp);

  return 0;
}

試してみましょう.

$ gcc -Wall p07-catnum.c
$ ./a.out > sample.csv,catnum

確認は,先頭の10行程度を見れば十分でしょう.

headコマンドを用いて,cat -n smaple.csvの実行結果と比較しておきましょう.

$ head sample.csv,catnum
$ cat -n sample.csv | head
QUIZ(参考):

21行目は,以下のように書き換えても正しく動作します.なぜか,説明できますか?分からない場合は,練習1-2 文字列操作に関する復習(設問4の解答例)の復習が必要です.

21:   while ( fgets(buffer, 1024, fp) ) {

もう少し複雑な例

最後に,もう少し複雑な例を見てみます.

以下は,sample.csvファイルを開いて,ファイルが終わるまでfgets()関数で1行読み込んで,sample-copy.csvに書き出すプログラムです.つまり,sample.csvsample-copy.csvにコピーします.

Listing 21 例3 p07-dup_sample_csv.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <string.h>

int main()
{
  FILE *src_fp;
  FILE *dst_fp;
  char *src_file = "sample.csv";
  char *dst_file = "sample-copy.csv";
  char *p, buffer[1024];

  src_fp = fopen(src_file, "r"); /* 読込モード "r" で開く */
  dst_fp = fopen(dst_file, "w"); /* 書込モード "w" で開く */

  /* 一方でも NULL なら失敗 */
  if (src_fp == NULL || dst_fp == NULL) {
    fprintf(stderr, "Could not open %s and/or %s\n", src_file, dst_file);
    if (src_fp) fclose(src_fp);
    if (dst_fp) fclose(dst_fp);
    return -1;
  }

  /* fgets が成功している間(ファイルが終わりでない間)ループ */
  while (fgets(buffer, 1024, src_fp) != NULL) {
    fprintf(dst_fp, "%s", buffer);
  }
  fclose(src_fp);
  fclose(dst_fp);

  return 0;
}

実行してみて,sample.csvがコピーされるかどうか調べてみましょう.

$ gcc -Wall p07-dup_sample_csv.c
$ ./a.out
$ ls -al sample*.csv
$ diff sample.csv sample-copy.csv | head

念のため,lsコマンドでファイルの作成日時やサイズを確認しましょう.

さらに,diffコマンドの結果,何も出力されなかったら成功です.

各関数の詳細は,教科書を読むか,manコマンドで確認するなどしてください.

QUIZ(参考):

fopen()関数や,fgets()関数のプロトタイプについて,答えられますか?(ヒント:教科書K&Rの p.302 と p.309)

入出力の抽象化:stdin, stdout, stderr

stdio.hの中にあらかじめ宣言されているファイルポインタがあります.

名前

意味

デフォルトの入出力先

stdin

標準入力

キーボード

stdout

標準出力

画面

stderr

標準エラー出力

画面

これら3つのファイルポインタは,あらかじめ宣言され,ファイルとして開かれた状態になっています.つまり,自分のプログラム中で宣言したり,fopen()関数でいちいちオープンする必要はありません.

これまでに示したプログラム例として,get_line()関数において,fgets()関数の引数にstdinを使っていました.あるいは,fprintf()関数を使った表示の際に,stderrを使った例も示してきました.

stderrの存在意義については,以下の説明を参照してください.

stdinstdoutを,キーボードや画面以外に結び付ける方法は,以下を参照してください.

さて.ここで理解して欲しいことは,C言語では,ファイルポインタを利用することで,あらゆる入出力をファイルの読み書きという概念に抽象化している,ということです.そのため,画面への出力やキーボードからの入力もファイルへの入出力と同等に扱うことができます.

例えば,プログラム上において,キーボードからの入力ファイルからの入力の差とは,fgets()関数に与えるファイルポインタの差でしかありません.具体的な例として,以下を比較すると分かるでしょう.

fgets(buffer, 1024, src_fp) /* src_fp により開いたファイルからの読み込み */
fgets(buffer, 1024, stdin)  /* stdin  によりキーボードからの読み込み     */

出力に関しても同様のことがいえます.

QUIZ(参考):

入力に関する具体例を参考にして,出力に関する具体例を書くことはできますか?

実装方針の検討

%Rコマンド(cmd_read() 関数)の検討

ファイルポインタと入出力の抽象化という考え方を有効利用して,%Rコマンド (cmd_read()) を作ることを考えましょう.

おおまかな手順は,以下のようになると思います.

1
2
3
4
5
6
7
8
9
/* cmd_read */

fp = 引数で指定されたファイルを開く;

while (fpから1行読み込む → 成功) {
  読み込んだ行を処理をする;
} // 繰り返し

fpを閉じる;

これをよく見ると,何かに似ていませんか?段階的詳細化からコードへの変換におけるmain()関数を見てみると.

1
2
3
4
5
/***** main *****/

while (標準入力から1行読み込む → 成功) {
    読み込んだ行を処理をする;
} // 繰り返し

そっくりですね.cmd_read()関数は,ファイルを開いてファイルポインタfpを得る部分と,ファイルを閉じる部分を足しているだけに見えます.下表(抜粋して再掲) によると,「読み込んだ行を処理をする」 は,parse_line()関数として,実装済みです.1行読み込む部分は,どうでしょう.get_line()関数として作成済み・・・でしたっけ?

項番

処理名称

関数名

1

main

main()

2

標準入力から1行読み込む

get_line()

3

読み込んだ行を処理をする

parse_line()

main()関数の実装を見ると,

1
2
3
4
5
6
7
8
9
int main()
{
  char line[MAX_LINE_LEN + 1];

  while (get_line(line)) {
    parse_line(line);
  }
  return 0;
}

となっています.これにcmd_read()関数で追加されるべき部分を重ねてみると,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
... cmd_read()
{
  char line[MAX_LINE_LEN + 1];

  fp = 引数で指定されたファイルを開く; /* 追加 */

  while (get_line(line)) { /* ここが問題: fp から読めないと困る */
    parse_line(line);
  }

  fpを閉じる;  /* 追加 */
}

ここでは,ファイルを開けなかった場合については記述していません.おおむねそのままいけそうなのですが,get_line()関数が問題になります.なぜなら,get_line()関数は,「標準入力stdinから1行読む関数」として設計されているからです.

ここでget_line()関数を拡張して,もう1つ引数を増やし,ファイルポインタを受け取ることにします.つまり,こうなります.

項番

処理名称

関数名

1

main

main()

2'

ファイルポインタ経由で1行読み込む

get_line()

3

読み込んだ行を処理をする

parse_line()

するとどうでしょう.cmd_read()関数がうまくget_line()関数を使えるようになります.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
... cmd_read()
{
  char line[MAX_LINE_LEN + 1];

  fp = 引数で指定されたファイルを開く;

  while (get_line(fp, line)) { /* fp を引数に追加 */
    parse_line(line);
  }

  fpを閉じる;
}

これでめでたしめでたし!?

・・・では早計です.こうすると,今度はmain()関数が困りますね.main()関数から呼び出すget_line()関数も,ファイルポインタを追加してやらないといけません.

ここで,入出力の抽象化が役立ちます.つまりstdinを引数として与えることによって丸く納まります.

1
2
3
4
5
6
7
8
9
int main()
{
  char line[MAX_LINE_LEN + 1];

  while (get_line(stdin, line)) {
    parse_line(line);
  }
  return 0;
}

get_line()の書換えは,すぐできますよね.

QUIZ(参考):

練習2-3 split() と get_line() の結合で作成したテストプログラムを用いて,ファイルポインタを引数に与えることができ,そのファイルポインタが適切に利用されたget_line()へと書き換えてください.(ヒント:get_line()の引数は,先のQUIZで調べたfgets()を参考にすればよい.)

リファクタリングとテスト

前項では,get_line()関数の構造を少し変えて,その変更に伴うmain()関数の修正を施しました.このような修正作業,すなわち,同一の機能を保ったまま内部の構造を変更する作業のことをリファクタリングと呼びます.

では,このリファクタリングの次にやるべきは何でしょう?

早速,cmd_read()関数の実装を始める・・・ではありません.それはお勧めしません.

新しい機能の実装をいきなり作り始めると,うまく動かない場合にハマる可能性が高くなります.そして,変更箇所が多ければ多いほど,どこでハマったのかわからず時間ばかりが過ぎていく,となりがちです.

次に必要なのは,テスト,です.特に,回帰テスト(練習5-5 現在までのプログラムの動作確認とテスト)が重要な意味を持ちます.

書換える前と後で,これまでと同一の機能を保っているか,テストでチェックします.具体的には,%P, %Q, %C コマンドやCSVの読み込みがうまくいくかチェックすることが重要です.

回帰テストのような手軽かつ十分なテストができない状態で実装を進めていると,リファクタリングの勇気が出ないことがあります.その際にやってしまいがちなのは,

  1. get_line()関数をそのままにしておいて,get_line2()という似た関数をもう1個作って,cmd_read()関数では,そちらを使う

  2. その際,コピペでget_line2()関数を作ってしまう

という解決方法です.この方法は,後に大問題になることがよくあります.get_line()関数は,わずか数行の関数なので分からないかもしれませんが,似たようなコードがあちこちに散在すると書換えが必要になったときに困ります.

安易なコピペでソースコードが肥大したプログラムを作るのは,非常に悪い作法です.

%Wコマンド(cmd_write() 関数)の検討

ここまで理解していれば,%Wコマンドは,%Pコマンドや%Fコマンドと類似していることに気付くと思います.

以下,%Fでも考えた,範囲指定機能を省いたシンプル版%Pコマンドを起点に考えてみましょう.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* Global variables */
int profile_data_nitems;
struct profile profile_data_store[10000];

/**
 * Simple %P
 */
void cmd_print()
{
  int i;
  for (i = 0; i < profile_data_nitems; i++) {
    print_profile(&profile_data_store[i]);   // 画面に出力する
    printf("\n");
  }
}

%Wの場合は,以下を考慮に入れる必要があります.

  1. print_profile()の代わりに CSV 形式で出力する必要がある.つまり,

    Id    : 5100046
    Name  : The Bridge
    Birth : 1845-11-02
    Addr. : 14 Seafield Road Longman Inverness
    Comm. : SEN Unit 2.0 Open
    

    ではなく,

    5100046,The Bridge,1845-11-02,14 Seafield Road Longman Inverness,SEN Unit 2.0 Open
    

    のように出力する関数(例えばfprint_profile_csv()関数)が必要となる.

  2. %W filenameで指定されるfilenameに出力する必要がある.つまり,標準出力 (stdout) へ出力するのではなく,ファイルを開いて得たファイルポインタ(fp) へ出力することが必要とある.

これらを考慮に入れると,以下のような構造が思いつきます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * Command %W filename
 */
... cmd_write()
{
  int i;
  fp = 引数で指定されたファイルを開く;  // 書き込みモードで開く

  for (i = 0; i < profile_data_nitems; i++) {
    fprint_profile_csv(...);           // ファイルに出力する
  }
  fpを閉じる;
}

項目を区切っていた空行出力printf("\n");も不要なので取りました.

fprint_profile_csv()関数は,解答例(練習4-5 print_profile() 関数の作成とテスト)にあるprint_profile()関数を参考にして作成すれば簡単でしょう.

ただし,get_line()関数がfpを受け取れるように変更したのと同様な手当が必要です.この手当は,先に QUIZ として考えた内容が理解できていれば,簡単なはずです.分からない人は,本ページの冒頭から,もう一度復習してください.

また,ここでは,ファイルを開けなかった場合について,考えられていません.適切なエラー処理をしておかないと,後に,思いもよらないエラーに悩まされるかもしれません.

QUIZ(参考):

ファイル操作における,典型的なエラー処理として,どのようなものがあるでしょうか?

実装と動作確認

以上の解説を踏まえつつ,皆さん自身の手で実装を進めてください.

繰り返しになりますが,動作確認も忘れずに.

まずは,名簿管理プログラムとは別に,簡単な検証用のプログラムを書いてみて,C言語による入出力の基本を押さえることから始めれば,効率的に実装を終えられるでしょう.(簡単なプログラムの例は,冒頭で示しています.)

また,今回の資料でも,QUIZ(参考)をいくつか設けています.一部のQUIZ(参考)については,その回答の検討が,実装方針の検討のヒントになるかもしれません.