シェルスクリプトで指定回数 (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による方法も検討に値する。

N=10
i=0
while [ $((i+=1)) -le $N ]; do
echo $i
done

背景

シェルスクリプトで,指定回数だけ処理を実行したいときがある。Linux系OSであればseqコマンド,Mac含むBSD系OSだとjot,その他シェル独自拡張 (bash/ksh93/zsh) のブレース展開や算術for文を使って実現できる。

N=10
## seq is not POSIX
for i in $(seq $N); 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は機能の単純さや利用頻度からPOSIXで定義されているかと勘違いしがちだが,実はPOSIXで未定義だ。

回数を指定した反復は頻出事項であり,POSIX互換な方法を確立する必要があると感じたので検討した。

アプローチとして以下の2種類がある。

  1. ループ方法の工夫
    1. while
    2. printf
  2. seqの代替
    1. bc
    2. awk

ループ方法の工夫

while

まず,最も簡単な方法はwhileを使うことだ。

N=10
i=0
while [ $((i+=1)) -le $N ]; do
echo $i
done

whileを使えば,通常のループに自然に組み込めるので,違和感は少ない。また,連番に必要なコードはループ変数を増加させるだけなので,最小となる。

欠点
  • ループ内でループ変数のインクリメントが必要
  • ループの度に評価が行われるので速度が遅い
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で00 に置換することでも実現できる。

処理の内容が単純で,ループ変数のインクリメントが不要なので,速い実行速度を期待できる。

欠点
  • ループ変数の値が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
done

bcとほぼ同様で,C系言語のfor文の書式で連番を出力している。awkだと出力形式などをカスタマイズしやすいという利点がある。

欠点
  • コードが複雑

portability – Portable POSIX shell alternative to GNU seq(1)? – Unix & Linux Stack Exchange

速度比較

ここまでで,POSIX互換な合計5通りのループの実装方法を説明した。最後にこれらの実装の速度を比較して,どれが最良であるかの判断材料とする。

以下のコードで示すように,10万回のループを実行して速度を測る。

POSIX互換なループ実装の速度比較コード
N=100000 \time -p sh -c '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ではループの度にインクリメントや評価が行われるので,速度が遅くなるだろうという予想通りの結果となった。

printfawkによるものはwhileの方法の約2倍の実行速度であり有力だと感じた。

結論

5通りのループの実装方法を紹介し速度を計測した。この結果から,awkによる実装がベストだろうと思った。

N=10
for i in $(awk "BEGIN{for(i=1; i<=$N; ++i) print i}"); do
echo $i
done

理由は以下2点だ。

  1. 速度が速い
  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個飛ばしなども,ループ変数の加減算により簡単にできる。

N=10
i=0
while [ $((i+=1)) -le $N ]; do
echo $i
done

用途に応じて,awkprintfwhileを使い分けるのがよいだろう。個人的には,以下の優先順位で利用を検討するのがよいと思った。

POSIX互換なループの実現方法
方法 利用場面
for-printf ループ変数が不要な場合。
while ループ回数が少なく,速度が重要でない場合。
for-awk 実行速度が重要な場合,汎用性を高める場合。

今後,シェルスクリプトで指定回数のループを行うときは,seqを使わずにfor-printfwhilefor-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+"$@"}

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です