f

2016-07-01

How to extract column field in shell script

シェルスクリプトで列フィールドの抽出方法をまとめた。

シェルスクリプトでCSVなど一覧表やログファイルを処理していると,先頭や末尾のフィールドを取得したいときがある。いくつか方法があるのでまとめた。

結論は以下となった。

  • 記述の簡潔さではawkが一番だが,実行速度は最下位。
  • 最速はシェル変数展開。
  • 記述の簡潔さと実効速度を考えると,ループ処理ではcutコマンドを使うのが妥当。

シェルスクリプトでファイルを1行ずつ処理するときは,以下の形式よく使う。

cat data.dat | while read -r line
do
# field_first=$(echo "$line" | [command])
# field_last=$(echo "$line" | [command])
done

この中で,変数lineに格納された1行文のデータから,それぞれのフィールドを取得して処理していく。

先頭からN番目のフィールドを取得するには,cutコマンドが簡単だ。しかし,cutコマンドでは末尾からN番目のフィールドにアクセスするためのオプションなどはない。

何かよい方法がないか調査し,最後に実行速度も検討した。以下の順番で記載していく。

  1. cut
  2. awk
  3. sed
  4. grep
  5. シェル変数展開
  6. 速度比較
  7. まとめ

まずは,サンプルデータを用意する。

echo "a,b,c" > data.dat

cut

cutコマンドは,先頭からN番目のフィールドを取得するのは簡単だが,末尾からN番目は事前に列数を取得しておかないといけない。

cut -d ',' -f 1                data.dat  # a

LASTCOL=$(($(head -n 1 data.dat | grep -o ',' | wc -l) + 1))
cut -d ',' -f $LASTCOL data.dat # c
cut -d ',' -f $((LASTCOL - 1)) data.dat # b

最初にLASTCOL変数に列数を格納している。grepで区切り文字を検索して,そのヒット数をwcでカウントしてそれを+1している。最初に変数を用意しないといけないが,悪くない方法だ。

POSIXに含まれていないrevコマンドを使っていいなら,もっとエレガントにできる。

rev data.dat | cut -d ',' -f 1 | rev  # c
rev data.dat | cut -d ',' -f 2 | rev # b

処理内容を言葉にすると以下となる。

  1. revコマンドで一度行を反転(フィールドも反転)。
  2. cutコマンドで先頭(もともと末尾)のフィールドを抽出。
  3. 最後に反転した文字列を元に戻す。

参照:bash - How to find the last field using 'cut'. Linux - Stack Overflow

awk

awkコマンドは記述が一番簡単だ。

awk -F "," '{print 1}'         data.dat  # a
awk -F "," '{print $NF}' data.dat # c
awk -F "," '{print $(NF-1)}' data.dat # b

NF変数に最後のフィールドが格納されているとのこと。一時変数を用意しなくて済むので一番簡単だろう。

sed

sedだとちょっと複雑になる。

sed 's/^\([^,]*\),.*$/\1/' data.dat  # a
sed 's/^.*,\([^,]*\)$/\1/' data.dat # c
sed 's/^.*,\(.*\),.*$/\1/' data.dat # b

少し複雑でわかりにくいので説明する。sedは最短マッチのためのオプションなどはないので,正規表現を工夫して自分で最短マッチさせる必要がある。

  • ,[^,]*$で区切り文字から行末までの最短マッチ,つまり最後のフィールドをマッチさせている。
  • \(\)で,マッチした部分をグループ化し,\1で参照している。これにより,最後のフィールドだけを出力させている。
  • 最後から1番目のフィールドを取得するには,マッチの部分を,\(.*\),.*$のように,行末の直前にフィールドを表す,.*をその回数分記述する。

複雑で,3番目などになると,記述が長くなるのでよい方法ではない。

grep

sedと同様に,grepでもパターンマッチを使うことでフィールドを抽出できる。

grep -o '^[^,]*' data.dat # a
grep -o '[^,]*$' data.dat # c

-oオプションでマッチ部分だけ出力できるので,sedよりは簡単にかける。

ただし,grepで先頭と末尾以外のN番目のフィールドを抽出するのは難しい。正規表現のパターンが思いつかなかった。

http://stackoverflow.com/a/22727242/4352571

シェル変数展開

最後に考えられるのは,シェル変数展開だ。

line=$(cat data.dat)
line_first="${line%%,*}" # a
line_last="${line##*,}" # c

line_first_1="${line#*,}" && line_first_1="${line_first_1%%,*}" # b
line_last_1="${line%,*}" && line_last_1="${line_last_1##*,}" # b

この方法だと,先頭か末尾のフィールドの取得は簡単だ。N番目は以下の手順を繰り返すことで習得できる。

  1. 一度先頭か末尾のフィールドだけを削除した変数(${line#*,}${line%,*})を用意。
  2. その変数に対して,先頭や末尾までのフィールドまでを削除(${line2%%,*}${line2##*,})。

シェル変数展開を使う場合,コマンドを間に挟まないため早い実行速度が期待できる。

http://stackoverflow.com/a/22727262/4352571

速度比較

列からフィールドを取得する方法をみてきたが,awkが最も簡単にできそうだ。しかし,フィールドからの値の取得は,ファイルなどのループでの利用が頻出事項と考えられる。実行速度も重要な項目だろう。

そこで,実際にここで紹介した方法で5万行のファイルを処理させて処理時間を計測・比較した。

計測に使用したスクリプトは以下となる(Gist)。

cat <<- EOF > loop-time.sh
#!/bin/sh
# \file      loop-time.sh
# \author    SENOO, Ken
# \copyright CC0

set -u DATA="loop-data.dat" : > $DATA for i in $(seq 1 50000) do echo "1,2,3,4,5,6,7,8,9,0" >> $DATA done timeit_loop(){ start=$(date +%s) cat $DATA | ( while read -r line do last_field="$(eval $1)" done end=$(date +%s) dt=$((end - start)) echo "time: $dt [s], last field: ${last_field}, $1" ) } LASTCOL=$(($(head -n 1 $DATA | grep -o ',' | wc -l) + 1)) timeit_loop 'echo "$line" | rev | cut -d ',' -f 1 | rev' timeit_loop 'echo "$line" | cut -d ',' -f $LASTCOL' timeit_loop 'echo "$line" | awk -F "," "{print \$NF}"' timeit_loop 'echo "$line" | grep -o [^,]*$' timeit_loop 'echo "$line" | sed "s/^.*,\([^,]*\)$/\1/"' timeit_loop 'echo ${line##*,}'
EOF

これを実行させると以下となる。

./loop-time.sh | sort
time: 37 [s], last field: 0,  echo "$line" | cut -d , -f $LASTCOL
time: 50 [s], last field: 0,  echo "$line" | grep -o  [^,]*$
time: 51 [s], last field: 0,  echo "$line" | sed "s/^.*,\([^,]*\)$//"
time: 54 [s], last field: 0,  echo "$line" | rev | cut -d , -f 1 | rev
time: 71 [s], last field: 0,  echo "$line" | awk -F "," "{print \$NF}"
time: 8 [s], last field: 0,  echo ${line##*,}

コマンド実行が少ないシェル変数展開が8 sと最速となった。次点は37 sのcutコマンド単体となった。それ以降は,grep,sed,cut+revが並んでおり,awkが71 sと最下位となった。

cut+revコマンドは使用するコマンド数が3個と最も多いことから,処理速度が遅くなることを心配したが,そこまで遅くなかった。用途が特化している分,実行速度が最適化されているのだろう。

まとめ

シェルスクリプトで列フィールドを取得する方法をまとめた。結論は以下となった。

  • 記述の簡潔さではawkが一番だが,実行速度は最下位。
  • 最速はシェル変数展開。
  • 記述の簡潔さと実効速度を考えると,ループ処理ではcutコマンドを使うのが妥当。

記述の簡潔さからawkが一番かなと思ったが,実行速度をみるとそうともいえなかった。ループ中でawkでフィールドを取得するのは控え,シェル変数展開の活用を念頭に置き,難しそうならcutコマンド単体やcut+revコマンドの組み合わせが簡単だろう。

逆に,ループ外であれば実行速度はあまり問題にならないので,awkを使うのもよいだろう。

もともと,実行速度は調査するつもりはなく,awkが一番というありきたりな結論で終わりそうだった。今回速度を計測することで,予想外の結果となり勉強になった。時間の計測は大事だと思った。

0 件のコメント:

コメントを投稿