f

アーカイブ

2016-10-10

How to loop N times in POSIX shell script

POSIX原理主義で指定回数ループする方法を記す。

最終的に,awkを使った実装が最も汎用的でベストだろうと結論づけた。

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

ただし,ループ変数が不要な場合はfor-yesを使う方法が最速で手短な方法だ。

N=10
for i in $(yes | head -n $N); do
echo $i
done

また,速度が重要でない場合や記述の簡潔さを重視する場合はwhileによる方法も検討に値する。

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

なお,処理内容が単純な場合は,yes-shが最短の記述となる。

N=10
yes 'echo $((i+=1))' | head -n $N | i=0 sh

Introduction

シェルスクリプト内で指定回数処理を実行したいときがある。通常であれば,seqコマンドかbashの構文を使って実現される。

N=10
## seq is not POSIX
for i in $(seq $N); do
echo $i
done
## bash specific syntax
for i in {1..$N}; do
echo $i
done

しかし,どちらもPOSIX非準拠となってしまう。seqは機能の単純さや利用頻度からPOSIXで定義されているかと勘違いしがちだが,実はPOSIXで未定義だ。

2016-10-13追記:Google+のコメントで以下の構文はPOSIX準拠ではないのかと質問された。

for ((i=1; i<=10; ++i)); do
echo $i
done

C系言語のfor文とよく似ており,この方法でも指定回数のループが可能だ。

しかし,この構文はPOSIXでは未定義であり,ksh,bashやzshの独自拡張だ。bashでは2.04からksh-93形式の算術forコマンドとして導入されている。

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

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

  1. ループ方法の工夫
    1. while
    2. printf
    3. yes
  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
echo $i # 0
done

この方法は少しトリッキーなので仕組みを解説する。

printfコマンドで%0による0パティングにより任意の数の0を一度に出力できることを利用している。最初のprintf "%0${N}d\n"で任意の個数の0を出力して,この内00 に置換して,それぞれの0を空白区切りにすることで指定回数のループを実現している。書籍「 すべてのUNIXで20年動くプログラムはどう書くべきか」のp. 104「同じ文字が連続した文字列を作る」で説明されていた方法を応用している。

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

欠点
  • ループ変数の値が0固定
  • コードがトリッキーで覚えにくい
yes

また,printfと似た方法でyesコマンドで任意の文字を出力し続け,それをheadコマンド任意の行数取得する方法もある。

N=10
for i in $(yes | head -n $N); do
echo $i # y
done

yesコマンドの引数に任意の文字列を指定することで,ループ変数に任意の固定文字列を使うことができる。この方法は,printfよりも記述が簡単である。この方法も書籍「 すべてのUNIXで20年動くプログラムはどう書くべきか」のp. 104「同じ文字が連続した文字列を作る」に書かれていた方法を利用している。

欠点
  • ループ変数の値が任意の文字列で固定

yesで固定文字列を出力する都合,ループ変数の連番を使えないのが欠点となる。

2017-04-15追記:

yes | head -n $Nの形式だとループ変数の連番を保存できない欠点があったが,後述のshを間に経由することで,ループ変数の連番も利用できる。

N=10
for i in $(i=0; yes 'echo $((i+=1))' | head -n $N | sh); do echo $i done

文字列'echo $((i+=1))'を指定回数だけshに渡して実行させて連番を生成している。環境変数としてiに数値が設定されていると,その値が使われるので初期化している。

ただし,記述が冗長になるので,いまいちな方法だ。

2017-01-21追記:

ネットで調べものをしていたらyesコマンドを使った方法で,forwhileすら使わない実現方法が見つかったので追記する。

N=10
yes 'echo $((i+=1))' | head -n $N | i=0 sh

Linux・UNIXでコマンドを定期的(数秒ごとなど)に連続実行させる方法 | 俺的備忘録 〜なんかいろいろ〜

上記コードは以下の手順に解釈される。

  1. yesコマンドで実行するコマンドをテキスト('echo $((i+=1)')として生成
  2. headコマンドで実行する回数分(-n $N)だけ抽出
  3. shでテキストをコマンドとして実行

環境変数としてiに数値が設定されていると,初期値がその値になるのでshの実行前に初期化している。

この方法はforwhileを使わないため,ループ全体を含めた指定回数の実行コードとしては最短となる。

欠点
  • パイプを使うのでループ変数を実行後に維持できない
  • ループ中での条件分岐など複雑な処理に向かない

変数を維持させる場合は,以下のようにevalで展開すれば一応可能だ。

N=10
i=0 eval "$(yes 'eval i=$((i+1)); echo $i' | head -n $N)"
echo $i

変数や環境変数としてループ変数iが使われていると,初期値がその値になってしまうので,先頭のi=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原理主義に従った合計4通りのループの実装方法を説明した。最後にこれらの実装の速度を比較して,どれが最良であるかの判断材料とする。

以下のコードで示すように,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 $(yes | head -n $N); 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)
N=100000 \time -p sh -c "yes 'echo \$((i+=1))' | head -n \$N | i=0 sh >/dev/null"

さらに,上記のコードを5回実行して平均をとったものを以下の表にまとめた。

速度比較と欠点のまとめ
方法5回平均user時間[s]欠点
while0.240seqの代替にならない。ループ毎に評価が必要なので速度遅い。
for-printf0.094seqの代替にならない。ループ変数が0固定。
for-yes0.076seqの代替にならない。ループ変数が任意文字列固定。
for-bc0.180whileに比べると複雑。インストールされていないことがある。
for-awk0.108whileに比べると複雑。
yes-sh0.124複雑な処理に向かない。ループ変数が保存されない。

この結果から,最も実行速度は速いのはyesを使ったものだった。この方法では,ループ変数のインクリメントなどが不要であり処理が最も単純なので速かったのだと思われる。次点は,awkによるものだった。最も遅かったのはやはりwhileによるものだった。whileではループの度にインクリメントや評価が行われるので,速度が遅くなるだろうという予想通りの結果となった。

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

Conclusion

6通りのループの実装方法を紹介し速度を計測した。この結果から,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を使って自分で実装して代替することもできそうと感じた。やや記述が長いが,インデックスの出力方法はC形言語のループとほぼ同じでなじみやすい。

しかし,awkで行う場合は記述が長くなってしまう。ループ変数がそもそも不要であったり,デクリメントなどループ変数の処理が不要な場合は,記述の手軽さからyesコマンドを使うのも悪くない。

N=10
for i in $(yes | head -n $N)
do
echo $i # y
done

2017-01-09追記:

また,実際にコードを書く場合に,awkでの連番の出力方法はやや冗長で複雑なのでぱっと思い出しにくい。そういう場合はwhile文を使うのもありだろう。

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

速度は劣るが,記述が簡単であり,ループ変数から番号も取得できる。デクリメントや2個飛ばしなども簡単にできる。

2017-01-21追記:

ループ全体の記述量に関していえば,yes-shを使ったものが最短となる。

N=10
yes 'echo $((i+=1))' | head -n $N | sh

awkやfor-yesの方法より速度が遅くなるが,単純にコマンドだけを指定回数実行する場合に有力な選択肢となりえる。ただし,この方法だとループ中でのif文による条件分岐などは記述が難しくなるので,処理内容が単純な場合にのみ使うべきだろう。

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

seqに頼らないループの実現方法
yes-sh
処理が単純で短い場合
for-yes
ループ変数が不要な場合
while
ループ回数が少ない場合,実行速度が重要でない場合
awk
実行速度が重要な場合,汎用性を高める場合

今後指定回数のループを行うときは,seqを使わずにfor-yesyes-shwhileawkを使い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 "$@"

0 件のコメント:

コメントを投稿