時代遅れひとりFizzBuzz祭り bash編(/bin/sh よりも高速に+α)

時代遅れひとりFizzBuzz祭り、今回はbashのよるシェルスクリプトだ。id:eel3:20100531:1275307408 にて/bin/shによる素のシェルスクリプトFizzBuzzを書いたのだが、余りに遅かった。今度はbash拡張機能を使って高速化してみようと思う。

移植性というものは厄介だ代物だ。前回のFortranは最新規格こそFortran 2008だが、実際の所Fortran 2003にフル対応できていない処理系もそれなりにあるようだ。新たにコードを書く場合でも処理系の都合を考えるとFortran 90ないし95をベースとするのが妥当だったりする。しかしFortran 90や95に従って実装しても、実際に他の処理系への移植を試みると様々な問題に遭遇するようだ。

シェルスクリプトの場合も大変だ。中で叩いているコマンドが存在しない――というケースは除外したとしても、複数の環境で実行するので/bin/shで実行できるように書こうという時点で便利な機能が使えないことが決定されてしまう。更に例えばSolaris/bin/shはlocalをサポートしていないけどどうしよう等々で時として使えない機能もあったりする。

というかFortranの場合は標準規格の変化と共に処理系も進化するので段々と新しい機能を使えるようになる傾向にあるのだが、シェルスクリプトの場合は……。

まあそういったしがらみがなければ色々な機能を使えるのはFortranシェルスクリプトも同じだ。

/bin/shというかBourne Shellの系譜に連なるShellは何種類かあるが、メジャーなのはbash(Bourne Again Shell)だろう。多くのLinuxディストリビューションCygwin、最近のMac OS Xにおいて標準のShellである点が影響しているように思う。

最初にも書いたように、このエントリでは以前書いたFizzBuzzシェルスクリプトbash拡張機能を使うことで高速化する所をスタートとする。今回もMSYS付属のbashを使用するが、バージョンは2.04ではなく3.1.17だ。後の方でUbuntu 10.04 LTSのbash 4.1.5も使う。

改造の元となるスクリプトはコレ。

#!/bin/bash

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

このスクリプトWindows上では物凄く遅い。18秒ちょっと掛かる。exprを実行する度にプロセスを生成している事に加えて、アンチウイルスソフトがプロセスの生成を監視しているので更に遅くなっている。

頻繁にプロセスを生成している点はLinux上でも影響している。Windowsで実行する場合に比べれば遥かに速いものの、明らかにgawkによるFizzBuzzよりも遅い。

そこで最初はexprを取り除く所から始めてみた。bashでは算術式展開という組み込みの機能があり、exprの部分を全て置き換えることが可能だ。

#!/bin/bash

pfizbuz() {
    if [ $(($1 % 15)) -eq 0 ]; then
        echo FizzBuzz
    elif [ $(($1 % 3)) -eq 0 ]; then
        echo Fizz
    elif [ $(($1 % 5)) -eq 0 ]; then
        echo Buzz
    else
        echo $1
    fi
}

i=1
while [ $i -le 100 ]; do
    pfizbuz $i
    i=$((i + 1))
done

これで組み込みコマンドとシェル関数だけになった為か、実行速度は18秒ちょっとから1秒前後に短縮された。大幅な改善だ。

算術式は展開せずに評価することが可能なので、更に次のように書き換えることができる。

#!/bin/bash

pfizbuz() {
    if (($1 % 15 == 0)); then
        echo FizzBuzz
    elif (($1 % 3 == 0)); then
        echo Fizz
    elif (($1 % 5 == 0)); then
        echo Buzz
    else
        echo $1
    fi
}

i=1
while ((i <= 100)); do
    pfizbuz $i
    ((i++))
done

算術式の評価や展開には注意が必要だ。

算術式の結合規則や評価結果の値はC言語と同じで、算術式展開では評価結果の値がそのまま展開される。比較演算ではtrueなら1に、falseなら0になる。これが算術式を展開せずに評価する場合、評価結果の値が0なら1が、0以外なら0がステータス値として返される。

特に比較演算の評価に関しては元々のShellにおける流儀と食い違うため、混同してしまわないように注意する必要がある。

declareやtypesetを使うと、変数を宣言する時に属性を与えることができる。これを使って整数として扱うように明示することが可能だ。typesetは互換性のために用意されているコマンドなので、declareを使う方が望ましいらしい。

#!/bin/bash

pfizbuz() {
    local s=''

    (($1 % 3 == 0)) && s=Fizz
    (($1 % 5 == 0)) && s=${s}Buzz
    [ "$s" = '' ]   && s=$1

    echo $s
}

declare -i i=1
while ((i <= 100)); do
    pfizbuz $i
    ((i++))
done

文字列の連結は特にコマンドが用意されている訳ではない。というか基本的に「何でも文字列」な世界なので、こんな風に展開すればよいはず。

伝統的なfor - inとは別に、算術式用のC言語的なforもある。

#!/bin/bash

pfizbuz() {
    local -ar stbl=($1 Fizz Buzz FizzBuzz)
    local -i i=0

    (($1 % 3 == 0)) && ((i++))
    (($1 % 5 == 0)) && ((i += 2))

    echo ${stbl[$i]}
}

for ((i = 1; i <= 100; i++)); do
    pfizbuz $i
done

データ構造としては1次元ながら配列も使える。あとlocalのオプションにてdeclareと同様に属性を与えることができる。

ブレース展開を使うと整数値や文字のシーケンスを生成できる。for - inと組み合わせてこんな風に書くこともできる。

#!/bin/bash

pfizbuz() {
    local -ar stbl=($1 Fizz Buzz FizzBuzz)

    local -i i=$(($1 % 3 == 0))
    (($1 % 5 == 0)) && ((i += 2))

    echo ${stbl[$i]}
}

for i in {1..100}; do pfizbuz $i; done

ただしブレース展開は使用できる場所が少ない為、扱いにくい面がある。for - inと組み合わせて使うか、echoの引数として実行した結果を変数で保持するぐらいだろうか? bashのバージョンが新しければmapfileやreadarrayがあるので配列化に手間が掛からないかもしれない。

さて、Bourne Shellにはcaseという構文があり、ある値に対するパターンマッチングをそこそこ手軽に記述することができる。但し評価する値は1度に1つだけだ。複数の値に対して同時にパターンマッチングできないだろうか?

少し考えて、こんな方法を思いついた。

#!/bin/bash

pfizbuz() {
    case "$(($1 % 3 == 0)) $(($1 % 5 == 0))" in
        '1 0' ) echo Fizz ;;
        '0 1' ) echo Buzz ;;
        '1 1' ) echo FizzBuzz ;;
        * )     echo $1 ;;
    esac
}

for i in {1..100}; do pfizbuz $i; done

発想は単純で、複数の値を同時に評価した結果を1つの文字列にしてしまうだけだ。

ただこのままではパターンを書く時に `?' や `*' をワイルドカードとして扱うことができない。少し試してみたが、この書き方だと意図した通りに動作した。

#!/bin/bash

pfizbuz() {
    case "$(($1 % 3)) $(($1 % 5))" in
        0\ 0 )  echo FizzBuzz ;;
        0\ * )  echo Fizz ;;
        *\ 0 )  echo Buzz ;;
        * )     echo $1 ;;
    esac
}

for i in {1..100}; do pfizbuz $i; done

パターンを記述する際にバックスラッシュでスペースをエスケープする必要があるようだ。この辺りを含めて、裏技ではなく正式な書き方の範疇なのか気になる。

caseといえば、パターンを自前で書くのではなく動的に生成できないか試みたのだが、うまい具合に動作させることができなかった。現時点では「パターンを動的に生成するなら、スクリプトを実行する前に展開しておかないと駄目っぽい」という結論に至っている。

#!/bin/bash

readonly MAXVAL=100

eval "
pfizbuz() {
    case \$1 in
        `seq -s " | " 15 15 $MAXVAL` )  echo FizzBuzz ;;
        `seq -s " | "  3  3 $MAXVAL` )  echo Fizz ;;
        `seq -s " | "  5  5 $MAXVAL` )  echo Buzz ;;
        * )                             echo \$1
    esac
}

for i in {1..$MAXVAL}; do pfizbuz \$i; done
"

このスクリプトのように一旦パターンの部分を全て展開させてからevalで評価するのなら動作するが、evalを挟まずに動作させることはできなかった。

bashのバージョンが新しければ、seqを使わずにbash組み込み機能のみで記述できる。

#!/bin/bash

readonly FIZZ=`echo {3..100..3}`
readonly BUZZ=`echo {5..100..5}`
readonly FIZZBUZZ=`echo {15..100..15}`

eval "
pfizbuz() {
    case \$1 in
        ${FIZZBUZZ// / | } )    echo FizzBuzz ;;
        ${FIZZ// / | } )        echo Fizz ;;
        ${BUZZ// / | } )        echo Buzz ;;
        * )                     echo \$1
    esac
}

for i in {1..100}; do pfizbuz \$i; done
"

ブレース展開でインクリメント/デクリメントの幅を指定している。この書き方はMSYSのbash 3.1.17ではNGで、Ubuntu 10.04 LTSのbash 4.1.5ではOKだった。

/bin/shで動作させるシェルスクリプトと比べると、bashスクリプトは遥かに便利だ。様々な機能が組み込まれているだけでなく、同種の外部ツールから組み込みの機能に切り替えることによってスクリプト実行時の高速化にも繋がっている。

ただ、自分用のスクリプトを書いて同じ環境(OS/ディストリビューション)で使い続けるのならともかく、他の環境で使うとなると色々と問題がでるだろう。

bashが入っている/入っていないという話だけでなく、bashのバージョンの差異やビルド時のオプションの違いによって使用可能な機能が異なるとか、そういう話もある。もちろん同じ問題はRubyなどのスクリプト言語でもあるのだが、bashの場合はシェルでもあるので話がややこしくなる気がする。