シェルスクリプトで末尾呼び出しの最適化

末尾呼び出しの最適化という最適化手法があって、これが有効な場合にスタックフレームの消費が抑えられる。具体的には、関数Aの末尾で関数Bを呼び出していて、関数Bの呼び出しについて末尾呼び出しの最適化が有効となった場合、関数B実行時に新たにスタックフレームを確保せず関数Aのスタックフレームを使い回す。

再帰関数にて末尾呼び出しの最適化が有効となる場合、すなわち関数Aの中で末尾呼び出しの最適化が有効となる対象が関数Bではなく関数A自身の場合が、いわゆる末尾再帰の最適化である(少なくとも、そういう解釈が可能である気がする)。再帰関数は、再帰回数が多い際にスタックオーバーフローが発生する危険があるが、末尾呼び出しの最適化が有効となるケースではスタックフレームの消費が抑えられるため、オーバーフローは発生しない。事実上、関数の先頭にジャンプしているようなものだ。

「末尾呼び出しの最適化」が実施される言語処理系としてはLuaRhinoJavaScript)がある。CやC++でも、コンパイラの種類や最適化オプションによっては有効となる。

「末尾呼び出しの最適化」を実施するか否かは不明だが、「末尾再帰の最適化」に関しては、Schemeでは言語仕様として明記されているようだ。Common Lispの場合、言語仕様には含まれていないものの、末尾再帰の最適化を行う言語処理系は結構多いらしい。Scalaは末尾再帰の最適化を行うという。Clojureではloop/recurを、Groovyではtrampolineを使用することで、末尾再帰の最適化が有効となる。ClojureやGroovyでの特殊な記法はJVMに起因するらしいが、Scalaでは特別な書き方をしなくても末尾再帰の最適化が行われる(どういう仕組みなんだろう?)。

ではシェルスクリプトではどうか?

シェル関数には末尾呼び出しの最適化の機能は無いようだ。一方で、外部のコマンドやスクリプトの呼び出しについては、execを使うことで末尾呼び出しの最適化っぽいことを実現できる(実際にはスタックフレームではなくプロセスそのものを使いまわしている)。

つまりシェルスクリプトそのものの末尾再帰に関しても、execで最適化を行うことが可能だ。という訳で、1からnまでの自然数の総和を求めるシェルスクリプトの例:

#!/bin/sh

case $# in
1)  ans=0 ;;
2)  ans=$2 ;;
*)  exit 1 ;;
esac

if [ "$1" -le 0 ]; then
    exec echo $ans
else
    exec "$0" `expr "$1" - 1` `expr "$ans" + "$1"`
fi

bashだとこんな感じ。

#!/bin/bash

case $# in
1)  ans=0 ;;
2)  ans=$2 ;;
*)  exit 1 ;;
esac

if (("$1" <= 0)); then
    exec echo $ans
else
    exec "$0" $(($1 - 1)) $(($ans + $1))
fi

どちらもエラーチェックとか考えていないコードなので注意。