最近、古いシェルスクリプトをリファクタリングしたので、そのときに使ったテクニックをここに残しておく。
コマンドをパイプでつないで、最終的に出力を行単位で読み込みたいときがある。例えば、以下のls
コマンドの出力から一番ファイルサイズが大きいやつを選びたいとする。
$ ls /tmp/
total 28
...
drwx------ 2 root wheel 64 10 8 01:25 KSDownloadAction.dFCyHsIUZR
-rw-r--r-- 1 root wheel 0 10 9 23:35 wetrsmuoutpDmR5aR
-rw-r--r-- 1 root wheel 0 10 9 23:36 wetrsmuoutpalIXOW
...
レガシー(というかマナーが悪い)なコードだとこんな感じになる。
#!/usr/bin/env bash
set -eu
OLD_IFS="${IFS}"; IFS=$'\n'
max_size=0
max_filename=
ls_outpu="$(\ls -l /tmp/ | sed -e '1d' | grep -v '^d')"
for output in ${ls_outpu}
do
filesize="$(echo "${output}" | awk '{print $5}')"
filename="$(echo "${output}" | awk '{print $9}')"
if [ "${filesize}" -gt "${max_size}" ]; then
max_size="${filesize}"
max_filename="${filename}"
fi
done
IFS="${OLD_IFS}"
やりたいことはタイトルにあるように「パイプで受け取った標準入力を列ごとに読みこんで配列っぽく処理したい」なんだけど、上のコードはいくつか良くない点がある。
IFS
を一時的に置き換えている。awk
を使っている。1つずつ説明する。
これは古いシェルスクリプトだとよく見かけるけど、このやり方はメンテナンスが非常にやりにくくなり、本当に危ないのでやめたほうが良い。
まず、IFS
はshellにおいて、文字列の区切りを示すもの。これを変えることで、文字列の区切りを変えることが出来る。
通常、IFS
は半角スペース、タブ文字、改行の3つである。ここではls -l
の出力を行ごとに読み込むために文字列の区切りを改行区切りにしている(そうしなければ、出力を1行ごとに列単位で扱うことになってしまう)。そして処理が完了したらIFS
を元に戻している。
これ、最初に書いた人は良いけど、別の人がコードを保守するときに、この部分を見落としがちなのでバグが発生しやすくなるんだよね。コードを読む人は途中でIFS
が変わったことを常に意識しながら読むことを強いられるので、コードの保守性が著しく悪くなる。
よって、IFS
の置き換えは良くないのでやめましょう。ではどうすれば良いのかと言うと、ヒアドキュメントを使ってread
で読み込めば良い。
while read line
do
...
done <<< "$(ls -l /tmp/ | sed -e '1d' | grep -v '^d')"
すこし見づらくなるけど、こうすることでIFS
の置き換えが不要になり、行ごとに変数line
の中に文字列が格納されるようになる。
もしかしたらこうしたほうが良いかも?と思う人がいるかもしれない。
ls -l /tmp/ | sed -e '1d' | grep -v '^d' | while read line
do
...
done
べつにこれでも良いのだけど、パイプで繋がれた処理はサブシェルで起動するということを思い出してほしい。今回のようにwhileのループ文の中で、事前に定義した変数を読み込みたい場合、つまり、以下のような場合、
max_size=0
max_filename=
ls -l /tmp/ | sed -e '1d' | grep -v '^d' | while read line
do
filesize="$(echo "${output}" | awk '{print $5}')"
filename="$(echo "${output}" | awk '{print $9}')"
if [ "${filesize}" -gt "${max_size}" ]; then
max_size="${filesize}"
max_filename="${filename}"
fi
done
ループ文中のmax_size
とmax_filename
は先に定義したものとは別の変数として扱われる。その上、サブシェルなのでループ文を抜けると、その中にあった変数たちは参照できなくなる。なぜなら、サブシェルはメインのシェルとは違う世界なので。よって、パイプでつなぐのでなく、ヒアドキュメントで入力を受け取るようにしている。
もちろん、サブシェルで起動しても問題なければパイプで繋げばいいと思うけど、ヒアドキュメントを使えばサブシェルの起動を1つ減らせるので、リソースやパフォーマンスの面からもオススメしたいやり方だったりする。
awk
を使っている。awk
って便利なのでよく使われているんだけど、大抵の場合、空白切りの文字列の特定の位置の文字を取り出すときに使われることが多い。手元のコマンドラインで使うとかだったら良いんだけど、データ処理とかバッチ処理のスクリプトで使っているとしたら、なるべく次に紹介する方法で置き換えたほうが良い。awk
は重いし、思わず色々awkの中に処理を詰め込みがちになってしまうから。
オススメのリファクタリングとしては、set
を使うこと。set
というのは、よくシェルスクリプトの冒頭でset -eu
とかやると思うんだけど、そのset
。これはbuilt-in関数なのでawk
と違って別途にプロセスがforkされることがなくて軽い。
で、どういうふうにset
をawk
の代わりに使うのかというと、以下のようになる。
...
set -- ${output}
filesize="${5}"
filename="${9}"
...
これだけ。スッキリしてわかりやすくなったと思う。解説はman set
で見れば良いと思うけど、ここに引用しておく。
Any arguments remaining after option processing are treated as values for the positional parameters and are assigned, in order, to $1, $2, ... $n.
--
については以下の引用を参考。grep
とかでもよく使うので知っている人は多いと思う。
-- If no arguments follow this option, then the positional
parameters are unset. Otherwise, the positional parame-
ters are set to the args, even if some of them begin with
a -.
つまり、set -- foo bar baz
とやると、それぞれが$1
, $2
, $3
に格納される。Pythonで言うところの*args
みたいな可変長引数みたいな感じで位置引数として利用することが出来る。これにより、もうawk
を使ってわざわざecho
してパイプで…みたいなことをしなくて済む(もちろんIFS
を変更していないことが前提である)。
副作用としては、シェルスクリプトに渡された位置引数を上書きすることになるので、そういった引数は事前に別の変数に格納しておくのを忘れないこと。
これはなんのこっちゃと思うかもしれないけど、-gt
や-ne
は本来は数値を比較するためのものであって数字を比較するものではない。つまり、文字列のままで数字を扱うのは危ないのでやめましょうということ。ではどうするかというと、以下のようにすれば良い。
if [ $((filesize)) -gt $((max_size)) ]; then
シェルスクリプトで足し算とかするときに$((foo + bar))
みたいなことをするとおもんだけど(もしexpr
を使っているなら即刻書き換えましょう)、上のようにすることで数字を数値にすることが出来る。これの何が良いかと言うと、もし変数の中身がアルファベットのような数字ではない文字だった場合はゼロとして扱われる。
$ a="aaa"
$ echo $((a))
0
状況によるが、変数が数字でなければゼロとして扱って差し支えない場合はこのようにすることで余計な数字判定の式を省くことが出来る。
ということで、ここまでの内容をまとめると以下のようになる。
max_size=0
max_filename=
while read -r line
do
set -- ${line}
filesize="${5}"
filename="${9}"
if [ "$((filesize))" -gt "$((max_size))" ]; then
max_size="${filesize}"
max_filename="${filename}"
fi
done <<< "$(\ls -l /tmp/ | sed -e '1d' | grep -v '^d')"
行数はほとんど変わらないけど、最初の方と比べるとわかりやすくなったかなと思う。特別な処理とかないし。ちなみに、もっとも大きいファイル名を取得するだけならls -S | head -1
で一発で取れるので、わざわざこんなことをしなくて良い。
Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。
また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!
こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?
コメント