シンプルで無駄なテキストファイルの読み込み方

シェルスクリプトで1行ずつテキストファイルを読み込む方法を、わざわざ頑張って考えてみた。で、思いついてしまった。

ある意味画期的かもしれないけど、頑張る方向を間違えた気がしないでもない。

個々のコマンドはシンプルに、でもアルゴリズムは無駄だらけに

プレーンテキストのデータ構造はランダムアクセスに弱い――正確には可変長な文字コードのテキストを1文字単位で扱うとか1行が可変長なテキストを行単位で扱うとか、扱うデータ単位が可変長な場合にランダムアクセスが難しい。

適当にアクセスした領域を基点としてデータを取り出すなら何とかなる(ことが多い)だろう。しかし「15文字目」や「76行目」といった具合にアクセスしたい場所を指定された場合は駄目だ。先頭からシーケンシャルにアクセスしながら中身を解析していくしかない。

だからではないけど、テキストファイルを処理する場合は単純に先頭から「データを読み込んで処理する」を繰り返していくパターンが多い。

ならば、あえて読み込む行を指定しようじゃないか。

#!/bin/sh

readonly PROGNAME=`basename "$0"`

die() {
    echo "$1" 1>&2
    exit 1
}

[ $# -eq 1 ] || die "usage: $PROGNAME <file>"
[ -f "$1" -a -r "$1" ] || die "$1: invalid argument"

nr=`wc -l "$1" | cut -d ' ' -f 1`
for i in `seq 1 $nr`; do
    line=`head -$i "$1" | tail -1`
    # 本来はここで行ごとに何かしら処理を行う
    echo "$line"
done

wcで行数を調べたりheadとtailで中間行を取り出したり、個々のパーツはシンプルでオーソドックスだと思う。でもそれらを組み合わせたらあら不思議、何だこの無駄さ加減は。

なお作者的には標準入力からの読み込みに対応していない点がイマイチだと思う。

プロセス、プロセス

Unix環境では伝統的に「プロセス生成コストは比較的小さい」という常識があるように思う。まあ小さいからこそ保守的なネットワークサーバの実装が「fork(2)で子プロセスを生成して処理させる」というスタイルのままなのだろうし*1、ソケットプログラミングの入門書で未だにfork(2)を使う手法が真っ先に取り上げられているのだろう。

だから、まあ、その、プロセスの生成回数を増やす方向に改良してみた。

#!/bin/sh

readonly PROGNAME=`basename "$0"`

die() {
    echo "$1" 1>&2
    exit 1
}

[ $# -eq 1 ] || die "usage: $PROGNAME <file>"
[ -f "$1" -a -r "$1" ] || die "$1: invalid argument"

i=1
nr=`cat "$1" | wc -l`
while [ $i -le $nr ]; do
    line=`cat "$1" | head -$i | tail -1`
    # 本来はここで行ごとに何かしら処理を行う
    echo "$line"
    i=`expr $i + 1`
done

更に低速になった気がするぞ! Windows上でCygwinやMSYSのBourne Shellで動かすと効果が如実に表れる。expr(1)効果だ。アンチウイルスソフトがプロセスの生成を監視している影響も大きいだろうなあ*2と経験を元に語ってみる。

個人的に、行数を求める方法を微妙に負荷が上がりそうな方法に変更したり、1行読み出すところで地味に子プロセスを1つ増やしたりしているあたりも評価して欲しいと思う。

よくあるミスを注入して完成

C言語でのプログラミングに「for文の継続条件にstrlen(3)を書く」という鉄板のネタがありまして……文字列の長さは変わらないのにループする度にstrlen(3)で文字列の長さを計算するのは無駄だろう*3、という話ですな。

実に参考になる話で、これを元にもう少し突き詰めてみた。

#!/bin/sh

readonly PROGNAME=`basename "$0"`

die() {
    echo "$1" 1>&2
    exit 1
}

[ $# -eq 1 ] || die "usage: $PROGNAME <file>"
[ -f "$1" -a -r "$1" ] || die "$1: invalid argument"

i=1
while [ $i -le `cat "$1" | wc -l` ]; do
    line=`cat "$1" | head -$i | tail -1`
    # 本来はここで行ごとに何かしら処理を行う
    echo "$line"
    i=`expr $i + 1`
done

更に低速になったぞ! もうね、ファイルを何回読めば気が済むんだと小一時間(ry

結びのことば

これは無駄ではなく人生の寄り道です。でもよい子は真似しないように。

普通はこの手の行指向の処理ではwhile readで読み出すかawk辺りを使う*4。私なら、内容にもよるけどawkを使うかなあ。while readはどうにも苦手だ。

*1:プロセスの生成コストが無視できないほど高いのなら、別の方法が考えられたはず。

*2:もちろん元々のプロセス生成コスト自体もLinuxよりも高いはず。

*3:コンパイラの最適化を考慮しない場合。

*4:そうすれば標準入力からの読み込みもOK。