久々の時代遅れひとりFizzBuzz祭り、今回は/bin/sh。要はシェルスクリプト。前回のTclとの繋がりは……何となく雰囲気が似ているから。まあ元々TclはUnixアプリケーションの標準の拡張スクリプト言語としてデザインされたらしいだが、シェルスクリプトはUnix環境でポピュラーな伝統的プログラマブル・ツールの1つなので、参考にした可能性は低くはないと思う*1。
実はシェルスクリプトを書く機会は少ない。長時間作業する環境がWindowsな為、バッチファイルを書く方が多い。MinGW、MSYS、GnuWin32の各種ツールの混成で独自のUnixライク環境を作っているので*2、色々と制約が多いことも理由の一つだ。
あまりにも書く機会が少ないのでなかなか上達せず、シェルスクリプトを書いたり修正したりする時はいつも苦労する。でも、本心ではバッチファイルより/bin/shなシェルスクリプトを選びたい。でも選べない。ああ、アンビバレンス。
そんな背景もあって、今回のFizzBuzzはリハビリを兼ねている。動作環境はMSYS付属のbash 2.04上で、bashの実行ファイル名をsh.exeに変更したものを使用している。
#!/bin/sh pfizbuz() { RETVAL=$1 if [ `expr $1 % 3` -eq 0 ]; then RETVAL=Fizz fi if [ `expr $1 % 5` -eq 0 ]; then RETVAL=Buzz fi if [ `expr $1 % 15` -eq 0 ]; then RETVAL=FizzBuzz fi echo $RETVAL } i=1 while [ $i -le 100 ] do pfizbuz $i i=`expr $i + 1` done
遅い。手元のWindows環境*3では、全て表示されるまで30秒強掛かる。正直発狂したくなる状況だ。
Intel Atom 330マシン*4上のUbuntu 8.04LTSでも試してみた所、圧倒的に速くなったものの、それでもgawkなどで書いたFizzBuzzと比べると目に見えて分かる程度には遅い。gawk版は一瞬で終わるが、このシェルスクリプトだと文字が流れていくことが分かる速度だ。
これは何とかしなくてはならない。
一体、何が原因だろうか? 恐らく最も高くついているのはコマンドの実行だろう。組み込み関数やシェル関数以外のコマンドを実行する時に毎回プロセスが生成されているはずだし、多分プロセス生成の度にウイルスバスター2009の監視網に引っかかっているので*5、その分だけ遅くなっているはずだ。
ということで、まずpfizbuz内でのexprの呼び出し回数をほんの少し減らしてみた。
#!/bin/sh pfizbuz() { RETVAL=$1 if [ `expr $1 % 15` -eq 0 ]; then RETVAL=FizzBuzz elif [ `expr $1 % 3` -eq 0 ]; then RETVAL=Fizz elif [ `expr $1 % 5` -eq 0 ]; then RETVAL=Buzz fi echo $RETVAL } i=1 while [ $i -le 100 ] do pfizbuz $i i=`expr $i + 1` done
しかし目に見える効果は確認できなかった。
念の為、シェル変数を毎回定義するのも止めてみた。
#!/bin/sh pfizbuz() { if [ `expr $1 % 15` -eq 0 ]; then echo FizzBuzz elif [ `expr $1 % 3` -eq 0 ]; then echo Fizz elif [ `expr $1 % 5` -eq 0 ]; then echo Buzz else echo $1 fi } i=1 while [ $i -le 100 ] do pfizbuz $i i=`expr $i + 1` done
これも効果なし。やはり小手先の変更では効果はない。
ということで発想を変えてみた。pfizbuz本体ではなく、呼び出し側に問題があるのではないか? よく見てみたら、ループの時にexprでカウントアップしている。これは無駄だ。
#!/bin/sh pfizbuz() { if [ `expr $1 % 15` -eq 0 ]; then echo FizzBuzz elif [ `expr $1 % 3` -eq 0 ]; then echo Fizz elif [ `expr $1 % 5` -eq 0 ]; then echo Buzz else echo $1 fi } for i in `seq 1 100` do pfizbuz $i done
なのでseqで1〜100まで一気に生成するようにしてみた。このスクリプトだと実行時間は20秒程度で、10秒ぐらい短縮されたことになる。
ちなみにこんな書き方もできる。
#!/bin/sh pfizbuz() { if [ `expr $1 % 15` -eq 0 ]; then echo FizzBuzz elif [ `expr $1 % 3` -eq 0 ]; then echo Fizz elif [ `expr $1 % 5` -eq 0 ]; then echo Buzz else echo $1 fi } seq 1 100 | while read i do pfizbuz $i done
但し、気がつかない程度だが、パイプを一段挟む分だけ微妙に遅くなるようだ。
10秒の短縮に成功したので、更なる短縮に挑んでみた。またまた小手先の変更だが、exprで求めた剰余を文字列として比較してみた。比較対象の2つの文字列を数値に変換してから比較するのと、そのまま文字列として比較するのでは、どちらが負荷が高いのだろうか?
#!/bin/sh pfizbuz() { if [ `expr $1 % 15` = '0' ]; then echo FizzBuzz elif [ `expr $1 % 3` = '0' ]; then echo Fizz elif [ `expr $1 % 5` = '0' ]; then echo Buzz else echo $1 fi } for i in `seq 1 100` do pfizbuz $i done
試してみたが、このケースでは特にこれといった差は無いようだった。
やはり、pfizbuz内部でのコマンドの実行回数を減らすしか、高速化は実現できないようだ。極力シェル組み込みの関数やシェル関数を使用するように変更することで、何とか高速化できないものか、試してみた。
#!/bin/sh FIZVAL=`seq -s ' ' 3 3 100` isfizz() { for i in $FIZVAL do if [ $i = $1 ]; then FIZVAL=`echo $FIZVAL | sed -e 's/^[0-9]*[ ]*//'` return 0 fi break done return 1 } BUZVAL=`seq -s ' ' 5 5 100` isbuzz() { for i in $BUZVAL do if [ $i = $1 ]; then BUZVAL=`echo $BUZVAL | sed -e 's/^[0-9]*[ ]*//'` return 0 fi break done return 1 } isfizbuz() { for i in $BUZVAL do if [ $i = $1 ]; then for j in $FIZVAL do if [ $j = $1 ]; then FIZVAL=`echo $FIZVAL | sed -e 's/^[0-9]*[ ]*//'` BUZVAL=`echo $BUZVAL | sed -e 's/^[0-9]*[ ]*//'` return 0 fi break done fi break done return 1 } pfizbuz() { if isfizbuz $1; then echo FizzBuzz elif isfizz $1; then echo Fizz elif isbuzz $1; then echo Buzz else echo $1 fi } for i in `seq 1 100` do pfizbuz $i done
コードが随分長くなった。実行時間は7〜8秒程度。FizzBuzzと表示されるときに動作が引っかかるのはご愛嬌、ということで勘弁してほしい。
改良の余地はありそうだけど、これで終了。というかシェルスクリプトの中にPerlやgawkのスクリプトを埋め込む方が手っ取り早くて且つ高速に動作するはずなので、これ以上あれこれ試行錯誤する気が起きない。
#!/bin/sh seq 1 100 | gawk ' { str = "" if ($0 % 3 == 0) str = "Fizz" if ($0 % 5 == 0) str = str "Buzz" print str == "" ? $0 : str }'
このコードの方が短いし、(awkを知っている人には)分かりやすい。しかも高速に動作する。まあこれもシェルスクリプト本来の使い方の1つ、ということか。