2019-10-11に投稿

パイプで受け取った標準入力を列ごとに読みこんで配列っぽく処理したい

最近、古いシェルスクリプトをリファクタリングしたので、そのときに使ったテクニックをここに残しておく。

コマンドをパイプでつないで、最終的に出力を行単位で読み込みたいときがある。例えば、以下の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}"

やりたいことはタイトルにあるように「パイプで受け取った標準入力を列ごとに読みこんで配列っぽく処理したい」なんだけど、上のコードはいくつか良くない点がある。

  1. IFSを一時的に置き換えている。
  2. 空白区切りの行から特定位置の文字列を取り出すためにawkを使っている。
  3. 数字を比較している。

1つずつ説明する。

問題点と解決法

IFSを一時的に置き換えている。

これは古いシェルスクリプトだとよく見かけるけど、このやり方はメンテナンスが非常にやりにくくなり、本当に危ないのでやめたほうが良い。
まず、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_sizemax_filenameは先に定義したものとは別の変数として扱われる。その上、サブシェルなのでループ文を抜けると、その中にあった変数たちは参照できなくなる。なぜなら、サブシェルはメインのシェルとは違う世界なので。よって、パイプでつなぐのでなく、ヒアドキュメントで入力を受け取るようにしている。

もちろん、サブシェルで起動しても問題なければパイプで繋げばいいと思うけど、ヒアドキュメントを使えばサブシェルの起動を1つ減らせるので、リソースやパフォーマンスの面からもオススメしたいやり方だったりする。

空白区切りの行から特定位置の文字列を取り出すためにawkを使っている。

awkって便利なのでよく使われているんだけど、大抵の場合、空白切りの文字列の特定の位置の文字を取り出すときに使われることが多い。手元のコマンドラインで使うとかだったら良いんだけど、データ処理とかバッチ処理のスクリプトで使っているとしたら、なるべく次に紹介する方法で置き換えたほうが良い。awkは重いし、思わず色々awkの中に処理を詰め込みがちになってしまうから。

オススメのリファクタリングとしては、setを使うこと。setというのは、よくシェルスクリプトの冒頭でset -euとかやると思うんだけど、そのset。これはbuilt-in関数なのでawkと違って別途にプロセスがforkされることがなくて軽い。

で、どういうふうにsetawkの代わりに使うのかというと、以下のようになる。

...
    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で一発で取れるので、わざわざこんなことをしなくて良い。

ツイッターでシェア
みんなに共有、忘れないようにメモ

shige

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント