09 月 13 日(水)3-4h

入出力(2)

前回は, テキストファイルおよびバイナリファイルから データを 1 byte ずつ(1文字ずつ)入出力できるようになった. 今回は,テキストファイルに限定し,データを1行ずつ入出力しよう.

さらに,1行の文字列から単語や数値を取り出したり, 単語や数値を組み合わせて文字列を作る方法についても勉強しよう.


行単位の入出力

テキストファイルを取り扱う場合, 複数の単語から成る1行分の文字列を処理対象とすることがよくある.

例:シェルのコマンドライン,プログラミング言語のソースコード,表データ,etc.

行文字列を入力するためには, ライブラリ関数 fgets() を利用すればよい:

#include <stdio.h>
char *fgets(char buf[], int n, FILE *fp)

ここで,buf[ ] は1行の文字列を記録するためのバッファ, n はバッファのサイズ(記録可能な最大の文字数)である. 戻り値は,通常,文字列バッファ buf へのポインタである. ただし,データを読み尽くした場合には,NULL を返す.

ファイル終端での入力関数の戻り値について, 文字の入力 fgetc( ) では EOF (型は整数の int,値は -1)だったが, 文字列の入力 fgets( ) では NULL (型は文字列ポインタの char *,値はゼロ) であることに注意. なお,NULL は fopen() でも利用した.

この関数の動作の詳細については, 次のコードを読めば,理解できるだろう:

/* fgets( ) のクローン */
char *fgets(char buf[], int n, FILE *fp)
{
	int  c;
	int  i = 0;

	for (i = 0; i < n-1; i++) {	// n-1 文字まで入力
		if ((c = fgetc(fp)) == EOF) {
			if (i == 0) return (NULL);	// 0 文字目で EOF ならば入力失敗
			break;				// 1 文字目以降ならば入力終了
		}
		buf[i] = (char)c; i++;	// 入力された文字をバッファへ格納

		if (c == '\n') break;	// 改行ならば入力終了
	}
	buf[i] = '\0';		// 終端記号を追加
	return (buf);		// 行の先頭アドレスを返す
}

なお,fgets( ) で取得したバッファ文字列には, 行末の改行 '\n' も記録される. さらに,バッファの末尾には,通常の文字列と同様に, 終端記号 '\0' が追加される. したがって,実際にファイルから入力される文字の個数は, バッファの要素数の n 以内ではなく, n - 1 以内となる. 注意が必要だ.

実際に入力される行文字列が想定外に長く 用意したバッファの配列に収まりきらない場合には, はみ出す部分は入力されず,次回の入力処理に持ち越される.

この関数の利用例として, テキストファイルの行数を数えるプログラムを List 1 に示す.

List 1. 行数を数えるプログラム lc.c
#include <stdio.h>
#include <stdlib.h>

#define BUFSIZE 256

int lc(FILE *fp)
{
	char buf[BUFSIZE];
	int  n = 0;

	while (fgets(buf, BUFSIZE, fp) != NULL) {	// 一行ずつ入力
		n++;					// 行数をカウント
	}
	return (n);
}

int main(int argc, char *argv[])
{
	FILE *fp;
	int  n;

	if (argc == 1) {
		fp = stdin;
	} else {
		if ((fp = fopen(argv[1], "r")) == NULL) return (EXIT_FAILURE);
	}

	n = lc(fp);
	printf("%d\n", n);

	fclose(fp);
	return (EXIT_SUCCESS);
}

注意:このプログラムでは,入力に 255 文字以上の長い行がある場合, 結果は不正確になってしまう. また,エラー処理も手抜きである.

正確に行数をカウントするには,例えば,1文字ずつ入力し, 改行文字 '\n' を数える必要があるだろう.

なお,fgets( ) とは逆に, 文字列をファイルへ出力する関数もある:

#include <stdio.h>
int fputs(char *s, FILE *fp)

しかし,この関数を無理に使う必要はない. fprintf(fp, s) でも代用できるので. (または,fprintf(fp, "%s", s) でもよいですが,少々冗長ですね.)

ちなみに, fgets( )fscanf(..., "%s", ... ) で代用することはできない. なぜなら,fscanf( ) では,空白が文字列の区切りとみなされるので, 空白を含むような文字列では, 一部分だけ(最初の一区切りまで)しか入力されないことになる.

参考:fgets()scanf() の動作の違いを確かめるためのプログラム のソースコードと実行結果を比較せよ.


文字列の分解

関数 fgets( ) で入力された文字列は,通常, 複数のトークン(単語や数字列など)と 区切り文字(空白やカンマなど)から構成されている. 行から各トークンを取り出すには, 関数 strtok( ) を利用する:

#include <string.h>
char *strtok(行文字列, 区切り文字列)

なお,この関数は,標準ライブラリ関数としては異端の存在であり, 正しく利用するには十分な注意が必要である. 教科書 p.314 も参考にしよう. 複数のトークンを取り出すには, strtok( ) の呼び出しを繰り返すことになるが, 1回目と2回目以降とで引数を変更する必要がある:

List 2 に,strtok( ) の利用例として, 再び wc のクローンを示す.

wc以前fscanf() 版を作成した.比較してみよう.
List 2. wc クローン(strtok( ) 版) p-wc.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUFLEN 256

int wc(FILE *fp)
{
	char buf[BUFLEN];
	char *word;
	char *p;
	int  n = 0;

	while (fgets(buf, BUFLEN, fp) != NULL) {
		p = buf;
		while (1) {
			word = strtok(p, " \t\n");	// 最初は strtok(buf, ...)
			if (word == NULL) break;
			n++;
			p = NULL;			// 2回目以降 strtok(NULL, ....)
		}
	}
	return (n);
}

int main(int argc, char *argv[])
{
	FILE *fp;
	int  n;

	if (argc == 1) {	// コマンドライン引数無しの場合,標準入力
		fp = stdin;
	} else if (argc == 2) {	// 有りの場合,ファイル入力
		if ((fp = fopen(argv[1], "r")) == NULL) return (EXIT_FAILURE);
	} else return (EXIT_FAILURE);

	n = wc(fp);
	printf("%d\n", n);

	fclose(fp);
	return (EXIT_SUCCESS);
}

2回目以降の呼び出しで, 入力文字列の何文字目までが処理済みであるのかを strtok( ) に教えなくてもよいのか?と思った人は, グローバル変数や静的変数のことを思い出そう.

strtok( ) は,その関数の内部で, ローカルな静的変数を利用している. (これが strtok() が異端である所以.) そのため,複数の文字列に対して交互に strtok( ) を使ったりすると, おかしなことになってしまう. (なぜだかわかるかな?)

なお,複数の文字列に対して同時並行的に分割処理については, 標準C言語の機能ではないが,strtok_r() を使えばできる.

なお,行文字列の形式(単語の個数やデータ型)が事前にわかっている場合には, strtok( ) よりも sscanf( ) が便利である:

#include <stdio.h>
sscanf(文字列, 書式, ポインタ1, ポインタ2, ...);

これも fscanf( ) の仲間であるが, ファイルからではなく,文字列からデータを読み取るものである.

文字列分解方法の違い:

文字列の生成

複数の単語や数字を組み合わせて文字列を作るには, sprintf( ) を使う:

#include <stdio.h>
sprintf(文字配列, 書式, データ1, データ2, ...);

これも fprintf( ) の仲間であるが, ファイルではなく,文字配列へデータを書き込むものである.

利用例を List 3 に示す. これは,バックアップファイル(ファイルのコピー)を作成するプログラムであり, 元のファイル名に拡張子 .bak を追加して, バックアップのファイル名としている. データのコピーには, 前回の cp() 関数fgetc版)を流用している.

List 3. バックアップファイルを作るプログラム backup.c
#include <stdio.h>
#include <stdlib.h>

void cp(FILE *fin, FILE *fout)
{
	...
}

int main(int argc, char *argv[])
{
	FILE *fin, *fout;
	char file[256];

	if (argc != 2) goto ERR_ARG;
	if ((fin = fopen(argv[1], "r")) == NULL) goto ERR_FIN;
	sprintf(file, "%s.bak", argv[1]);
	if ((fout = fopen(file, "w")) == NULL) goto ERR_FOUT;

	cp(fin, fout);

	fclose(fin);
	fclose(fout);
	return (EXIT_SUCCESS);

ERR_FOUT :
	fclose(fin);
ERR_FIN :
ERR_ARG :
	return (EXIT_FAILURE);
}

実行例:

$ ls
backup	backup.c

$ ./backup backup.c

$ ls
backup	backup.c  backup.c.bak

$ less backup.c.bak
...			# backup.c と同じ内容のハズ

Tips: 以前にも紹介したように, sprintf() で書式文字列を生成すると, 見栄えの良い出力結果を得られるようになる.

例: sprintf(fmt, "%%dd\n", w); printf(fmt, data); これは,書式文字列 fmt を生成し, 数値 data を桁数 w で表示している. ここで,w = 5 の場合,fmt = "%5d" となり, data の値は,桁数を5桁にそろえて表示されることになる.


練習問題

次の2題のうち,どちらか一方または両方に取り組もう.

  1. 表データから任意の列を取り出すプログラム cut の機能限定版クローン p-cut を作成せよ. (以前作成した pastetable の逆バージョン.)
  2. 実行例:

    $ cat  table.txt
    氏名	国語	数学	英語
    秋元才加	65	70	70
    板野友美	60	75	70
    ...
    
    $ ./p-cut  0  table.txt
    氏名
    秋元才加
    板野友美
    ...
    
    $ ./p-cut  2  table.txt
    数学
    70
    75
    ...
    
    $ ./p-cut  10  table.txt
    		# 簡単のため(たとえば入力に空行があっても OK とするため),
    		# 存在しない列を指定してもエラーとはしないでよい.
    
    $ ./p-cut
    使い方:p-cut  列番号  ファイル名
    
    
    ヒント:fgets( )strtok( ) の出番. 列が存在しない場合(strtok( )NULL を返す場合)には, 改行のみを表示すること.
  3. ひとつのテキストファイルから任意の行数ずつ取り出し 複数のファイルに分割するプログラム split の機能限定版クローン p-split を作成せよ.

    実行例:

    $ wc  -l  p-split.c			# 元のファイルの行数を調べている.
    67
    
    $ ./p-split  10  p-split.c		# 10 行ずつに分割している.
    
    $ ls
    p-split
    p-split.c				# 元のファイルの他に...
    p-split.c.000				# 分割ファイルができた!
    p-split.c.001
    p-split.c.002
    :
    :
    p-split.c.006
    
    $ wc  -l  psplit.c.*			# 分割ファイルの行数を調べている.
      10 p-split.c.000			# 10 行ずつになっている!
      10 p-split.c.001
      10 p-split.c.002
        :
        :
       7 p-split.c.006
    
    $ less p-split.c.002			# 内容も確認してみる(11行目から20行目のハズ)
    
    $ cat  p-split.c.*  > copy.c		# 分割ファイルを連結している.
    
    $ diff  -s  p-split.c  copy.c		# ファイルの違いを調べている.
    Files p-split.c and copy.c are identical. 	# 元通りになった!
    
    

    ヒント:分割ファイルの名前に番号を付けるには, sprintf(..., "%s.%03d", ...) などとすればよい. 復元の際の連結順序を維持するために, 番号の桁数を揃える必要がある. そのため,変換指定子 "%03d"の「0」が非常に重要. もし,桁数を揃えないと,たとえば,1,2,3,...,100 の順序が, 1,10,100,11,12,...,2,20,21,...,99 とかに乱れてしまうだろう.

なお,オリジナルの cut および split に興味のある人は, オンラインマニュアル等を参照しよう.

$ man  cut
$ man  split

(c) 2017, yanagawa@kushiro-ct.ac.jp