シェルスクリプトで指定回数 (N回) ループ
シェルスクリプトで指定回数ループする方法を記す。
最終的に,awkを使った実装が高速で汎用的なので,ベストだと結論づけた。
N=10
for i in $(awk "BEGIN{for(i=1; i<=$N; ++i) print i}"); do
echo $i
doneただし,ループ変数が不要な場合は,for-printfを使う方法が最速で手短だ。
N=10
for i in $(printf "%0${N}d\n" | fold -w 1); do
echo $i
doneまた,速度が重要でない場合や,記述の簡潔さを重視する場合は,whileによる方法も検討に値する。
i=0 N=10
while [ $((i+=1)) -le $N ]; do
echo $i
done背景
シェルスクリプトで,指定回数だけ処理を実行したいときがある。Linux系OSであればseqコマンドやyesコマンド,Mac含むBSD系OSだとjot,その他シェル独自拡張 (bash/ksh93/zsh) のブレース展開や算術for文を使って実現できる。
N=10
## seq is not POSIX
for i in $(seq $N); do
echo $i
doneN=10
## yes is not POSIX
yes | head -n $N | while read -r i; do
echo $i
done
N=10
## jot is not POSIX
for i in $(jot $N); do
echo $i
done
## shell specific syntax
for i in {1..$N}; do
echo $i
done
for ((i=1; i<=10; ++i)); do
echo $i
done
しかし,どの方法もPOSIX非準拠となってしまう。seqやyesは機能の単純さや利用頻度からPOSIXで定義されているかと勘違いしがちだが,実はPOSIXで未定義だ。
回数を指定した反復は頻出事項であり,POSIX互換な方法を確立する必要があると感じたので検討した。
アプローチとして以下の2種類がある。
ループ方法の工夫
while
まず,最も簡単な方法はwhileを使うことだ。
i=0 N=10
while [ $((i+=1)) -le $N ]; do
echo $i
donewhileを使えば,通常のループに自然に組み込めるので,違和感は少ない。また,連番に必要なコードはループ変数を増加させるだけなので,最小となる。
- ループ内でループ変数のインクリメントが必要
- ループの度に評価が行われるので速度が遅い
printf
次の方法は,printfで指定回数文だけ空白区切りの文字列を用意することだ。
N=10
# for i in $(printf "%0${N}d\n" | sed 's/0/0 /g'); do
for i in $(printf "%0${N}d\n" | fold -w 1); do
echo $i # 0
doneこの方法は少しトリッキーなので仕組みを解説する。
printfコマンドで%0による0パティングにより任意の数の0を一度に出力できることを利用している。最初のprintf "%0${N}d\n"で任意の個数の0を出力して,これを1文字ずつ改行することで実現する。foldの他にsedで0を0 に置換することでも実現できる。
処理の内容が単純で,ループ変数のインクリメントが不要なので,速い実行速度を期待できる。
- ループ変数の値が0固定
- コードがトリッキーで覚えにくい
seqコマンドの代替
もう片方のアプローチとしては,seqコマンドそのものを代替することだ。seqコマンドのように連番の数字を別の方法で出力して,それをfor文に使う。bcコマンドとawkコマンドを使う2通りの方法がある。
bc
まず,bcコマンドを使う方法は以下の通りだ。
N=10
for i in $(echo "for (i=1; i<=$N; ++i) i" | bc); do
echo $i
doneこの方法では,bcコマンドが計算式と認識できる式をパイプで渡してbcコマンドで式を実行している。
- コードが複雑
- 標準でインストールされていない環境がある
bcコマンドはPOSIXで定義されているが,標準で付属されていない環境がいくつかある。試しに,Ubuntu16.04で以下のコマンドを実行すると,外部パッケージとしてインストールされていることがわかる。
apt search bc 2>&- | grep -B 1 "GNU bc"bc/xenial,now 1.06.95-9build1 amd64 [installed,automatic] GNU bc arbitrary precision calculator language
その他,seqコマンドをPOSIX互換で本気で実装する場合,bcコマンドは小数の出力形式がまちまちであるなどいくつかの欠点がある。
awk
続いて,awkで実装する場合以下の通りとなる。
N=10
for i in $(awk "BEGIN{for(i=1; i<=$N; ++i) print i}"); do
echo $i
donebcとほぼ同様で,C系言語のfor文の書式で連番を出力している。awkだと出力形式などをカスタマイズしやすいという利点がある。
- コードが複雑
portability – Portable POSIX shell alternative to GNU seq(1)? – Unix & Linux Stack Exchange
速度比較
ここまでで,POSIX互換な合計5通りのループの実装方法を説明した。最後にこれらの実装の速度を比較して,どれが最良であるかの判断材料とする。
以下のコードで示すように,10万回のループを実行して速度を測る。
N=100000 \time -p sh -c 'i=0; while [ $((i+=1)) -le $N ]; do echo $i >/dev/null; done'
N=100000 \time -p sh -c 'for i in $(printf "%0${N}d\n" | sed "s/0/0 /g"); do echo $i >/dev/null; done'
N=100000 \time -p sh -c 'for i in $(printf "%0${N}d\n" | fold -w 1); do echo $i >/dev/null; done'
N=100000 \time -p sh -c 'for i in $(echo "for (i=1; i<=$N; ++i) i" | bc); do echo $i >/dev/null; done'
N=100000 \time -p sh -c 'for i in $(awk -v N=$N "BEGIN{ for(i=1; i<=N; ++i) print i}"); do echo $i >/dev/null; done'
さらに,上記のコードを5回実行して平均をとったものを以下の表にまとめた。
| 方法 | 5回平均user時間[s] | 欠点 |
|---|---|---|
| while | 0.312 | ループ毎に評価が必要なので遅い。 |
| for-printf-sed | 0.168 | ループ変数が0固定。 |
| for-printf-fold | 0.160 | ループ変数が0固定。 |
| for-bc | 0.266 | whileに比べると複雑。インストールされていないことがある。 |
| for-awk | 0.180 | whileに比べると複雑。 |
この結果から,最速だったのはprintfを使ったものだった。この方法では,ループ変数のインクリメントなどが不要であり,処理が単純なので速かったのだと思われる。また,sedとfoldでは,foldのほうがわずかに速かった。foldのほうが記述が簡単なので,for-printfでのループではfoldを使うのが良いだろう。
次点はawkによるものだった。最も遅かったのはやはりwhileによるものだった。whileではループの度にインクリメントや評価が行われるので,速度が遅くなるだろうという予想通りの結果となった。
printfとawkによるものはwhileの方法の約2倍の実行速度であり有力だと感じた。
結論
5通りのループの実装方法を紹介し速度を計測した。この結果から,awkによる実装がベストだろうと思った。
N=10
for i in $(awk "BEGIN{for(i=1; i<=$N; ++i) print i}"); do
echo $i
done理由は以下2点だ。
- 速度が速い
- 応用が効く
短いループを何回も行う場合でも,実行速度は重要になる。awkを使っておけば,ループ変数の出力の形式を調整したり,デクリメントなど複雑な連番処理にも対応でき,seq自体をawkを使って自分で実装して代替することもできそうだ。
しかし,awkで行う場合は記述が長くなってしまう。ループ変数がそもそも不要であったり,デクリメントなどループ変数の処理が不要な場合は,記述の手軽さからprintfコマンドを使うのも悪くない。
N=10
for i in $(printf "%0${N}d\n" | fold -w 1); do
echo $i # 0
doneまた,速度は劣るものの,記述が簡単であり,ループ変数から番号も取得できるので,素直にwhile文を使うのもありだろう。whileだとデクリメントや2個飛ばしなども,ループ変数の加減算により簡単にできる。
i=0 N=10
while [ $((i+=1)) -le $N ]; do
echo $i
done用途に応じて,awk,printf,whileを使い分けるのがよいだろう。個人的には,以下の優先順位で利用を検討するのがよいと思った。
| 方法 | 利用場面 |
|---|---|
| for-printf | ループ変数が不要な場合。 |
| while | ループ回数が少なく,速度が重要でない場合。 |
| for-awk | 実行速度が重要な場合,汎用性を高める場合。 |
今後,シェルスクリプトで指定回数のループを行うときは,seqを使わずにfor-printf,while,for-awkを使いPOSIX互換なシェルスクリプトを心がけていこう。
オプションのないseqのawkでの実装
最後に,参考までにawkによるseqの実装コードを記す。オプションのパースが複雑になるので,ひとまずオプションは使わないという前提をおいている。
引数の処理を行い,デクリメントなども対応している。その内勉強も兼ねて,POSIX互換なseqの実装にも挑戦してみたい。
awkによるオプションのないseqの実装例:
################################################################################
## \file seq-minimum.sh
## \author SENOO, Ken
## \copyright CC0
################################################################################
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
seq()(
HELP_WARN="Try 'seq --help' for more information.\n"
## Check arguments
case $# in
1) LAST="$1";;
2) FIRST="$1" LAST="$2";;
3) FIRST="$1" INCREMENT="$2" LAST="$3";;
0) printf "seq: missing operand\n$HELP_WARN" 1>&2; exit 1;;
*) printf "seq: extra operand '$4'\n$HELP_WARN" 1>&2; exit 1;;
esac
## Set default value
: ${FIRST:=1} ${INCREMENT:=1} ${COMPARISON:=<}
case "$INCREMENT" in -*) COMPARISON='>';; esac
## Execute seq
awk "BEGIN{for(i=$FIRST; i$COMPARISON=$LAST; i+=$INCREMENT) print i}"
)
seq ${1+"$@"}
