Effective ATS:
末尾再帰関数としてのループ

ATS では for ループと while ループを直接コンストラクトできますが、ループは末尾再帰関数 (別名: 反復関数 (iterative functions)) として実装されることが強く推奨されています。 そのように実装する主要な利点は、このスタイルのループ実装が ATS によてサポートされている定理証明とたやすく混ぜ合わせられることです。 そのためループを含むプログラムの検証が容易になります。

以降で示すコードの全体は tailrec.dats から入手できます。

末尾再帰とは何ですか?

[foo] と [bar] が2つの関数で、[foo] の本体から [bar] を呼び出していると仮定します。 この呼び出しのリターンが [foo] の返値にもなるなら、この呼び出しは末尾呼出です。 別の言い方をすると、呼び出しの返値が呼び出した関数の返値になるとき、そのような呼び出しをしている関数本体のその関数呼出は末尾呼出です。 例えば、次のコードにおける [bar] の呼び出しは末尾呼出ですが、[baz] の呼び出しはそうではありません:
fun foo (x: int): int =
  if x > 0 then bar(x) else baz(x)+1
ところで、たとえ baz(x)+1 を baz(x)+0 に変えたとしても、baz(x)+0 をなんとかして baz(x) に翻訳することなしに、[baz] の呼び出しはまだ末尾呼出とは見なせません。

もし末尾呼出で呼び出す関数が自分自身を呼び出しているなら、この末尾呼出はしばしば末尾再帰呼出と呼ばれます。 関数本体内のあらゆる再帰呼出が末尾呼出であれば、その関数は末尾再帰です。 末尾再帰関数は反復関数と呼ばれることもあることに注意してください。 次の例では、 外側の [f91] 呼び出しは末尾再帰ですが、内側の呼び出しはそうではありません:

fun f91 (x: int): int =
  if x >= 101 then x-10 else f91(f91(x+11))
定義から、[f91] は末尾再帰関数ではありません。

なぜ末尾再帰が望ましいのですか?

それぞれの末尾再帰関数はたやすくループによって実装できます。 関数呼出の実装に、実際非常に一般的な実装方式である、コールスタックが用いられていると仮定しましょう。 すると、末尾再帰関数の呼び出しには一定量のスタック空間のみが必要になります。 スタック空間がおびただしく制限された状況下 (例: 低レイヤーな組み込みプログラミング) では、末尾呼出はしばしば唯一許されうる再帰です。 手短に言えば、一般的な再帰と比較して、時間的にも空間的にもより効率的な作法で実装できるために、末尾再帰は望ましいのです。

再帰を末尾再帰に変換する

末尾再帰に利点があるのであれば、末尾再帰でない再帰関数から等価な末尾再帰への変換が当然必要になります。 再帰を末尾再帰に変換する体系立てられたアプローチ (CPS 変換) がありますが、効率に注目するとこのアプローチは一般的に使えるものではありません。 その代わりに、主にそれぞれ個々の場合を扱うアドホックは手法やトリックに頼ることになります。

例 1

ここで具体的な例を見てみましょう。 次のコードでは、1 から与えられた数字 n までの全ての整数の和を計算する関数 [tally] を実装しています:
fun tally (n: int): int =
  if n > 0 then n + tally (n-1) else 0
[tally] が末尾再帰でないことは明確です。 もしこの [tally] の実装をC言語に変換すると、本質的に次のようなコードが得られるはずです:
int tally (int n)
{
  return (n > 0) ? n + tally (n-1) : 0 ;
}
C言語における [tally] のこの実装は少し独特で、一般的な実装は for ループに基づくものになるはずです:
int tally2 (int n)
{
  int i ;
  int res = 0 ;
  for (i = n ; i > 0 ; i--) res += i ;
  return res ;
}
整数の加算の 結合法則 を使えば、[tally] と [tally2] が等価であることは指摘するべきでしょう。 もし加算を減算で置換すると、それは結合法則を満たしません。 そのため2つの実装はもはや等価ではなくなってしまいます。

C言語における [tally2] の上記の実装は、容易に次のような ATS コードに変換できます:

fun tally2
  (n: int): int = let
//
fun loop
  (n: int, res: int): int =
  if n > 0 then loop (n-1, res+n) else res
//
in
  loop (n, 0)
end // end of [tally2]
内部関数 [loop] が末尾再帰であることに注意してください。 ATS (ATS/Postiats) コンパイラは、[loop] を上記で示した for ループ相当のC言語コードに本質的にコンパイルします。

一般的に、再帰関数を末尾再帰関数に変換は、その関数を実行中に生成されるスタックをエンコードする効率の良い方法を見つけられるかで決まります。 例えば、[tally] を 100 に対して呼び出したと仮定しましょう; この呼び出しは 99 に対する再帰呼出を生成し、そして 98 に対する再帰呼出、などと続きます; [tally] を 50 に対して呼び出した時、そのコールスタックは本質的に次のような評価コンテキストを表わします:

100 + (99 + (98 + (... + (51 + []) ...)))
このとき記号 [] は tally(50) の返値で置き換えられます。 整数の加算は結合法則を満たすので、この評価コンテキストを表現するのに和 (100+99+98+...+51) を使うことができます。 これは [tally2] の本体内に内部関数 [loop] を実装する際のアイデアそのままです。

例 2

別の例を見てみましょう。 次のコードは、フィボナッチ数列を計算する [fib] という名前の関数を実装しています: 1
fun fib (n: int): int =
  if n >= 2 then fib(n-1) + fib(n-2) else n
[fib] 本体内の2つの再帰呼出のどちらも末尾再帰でないことは明白です。 フィボナッチ数列を計算する別の関数 [fib2] を次に示します:
fun fib2
  (n: int): int = let
//
fun loop
  (i: int, f0: int, f1: int): int =
  if i < n then loop (i+1, f1, f0+f1) else f0
//
in
  loop (0, 0, 1)
end // end of [fib2]
[fib2] の本体の内部関数 [loop] が末尾再帰であることは明らかです。 fib2(100) の評価を想像してみましょう。 この評価は loop(0, 0, 1) 呼び出しを生成し、その後 loop(1, 1, 1) 呼び出しを、さらにその後 loop(2, 1, 2) 呼び出しを、などと続きます。 もしこの呼び出し列を loop(i, f0, f1) とすれば、f0 は i 番目のフィボナッチ数、f1 は i+1 番目のフィボナッチ数になります。 これは、この列の最後の呼び出しの返値が 100 番目のフィボナッチ数 (i が 100 に到達した地点) になることを意味しています。 この引数は、[fib] と [fib2] は等価であることを納得させるに十分な情報でしょう。 つまり与えられた整数に適用した時、それらは同じ結果を返すのです。

[fib] を [fib2] に変換する背後にあるアイデアは、n >=2 の n 番目のフィボナッチ数を計算するために2つ前に計算されたフィボナッチ数のみを保持する必要がある、という単純な観察によっています。

例 3

次の例はリスト処理を含んでいます。 次のコードは、与えられた2つのリストの一般的な連結を実装しています:
fun
{a:t@ype}
list_append
  {m,n:nat} (
  xs: list (a, m)
, ys: list (a, n)
) : list (a, m+n) =
(
case+ xs of
| list_cons
    (x, xs) => list_cons (x, list_append (xs, ys))
| list_nil ((*void*)) => ys
) // end of [list_append]
[list_append] に割り当てられた型は、この関数が長さがそれぞれ m と n の2つのリスト xs と ys に適用されると、長さ m+n のリストを返すことを意味しています。 これが入力のリストを修正しない、いわゆる関数的なリストの連結であることに注意してください。 [list_append] 内の再帰呼出が末尾呼出でないので、この関数は末尾再帰ではないことは明確です。 もし [list_append] 呼び出しの最初の引数が長い (例: 100万要素を含むなど) 場合には、この呼び出し実行がコールスタックを溢れさせてクラッシュを引き起こす可能性が高くなります。

[list_append] を末尾再帰実装にするためには、リストの結合に関するいくつかの知識が必要です。 list_cons(x, xs) の評価は本質的に2つのステップです; はじめにリストノードを格納するために必要なメモリを確保し; それからこのノードを x と xs で初期化します。 ATS では、これら2つのステップは形式的に分離することができます。 この分離は、次のコードで示すような、リスト連結の適切な末尾再帰実装を得るための鍵になります:

fun
{a:t@ype}
list_append2
  {m,n:nat} (
  xs: list (a, m)
, ys: list (a, n)
) : list (a, m+n) = let
//
fun loop{m:nat}
(
  xs: list (a, m)
, ys: list (a, n)
, res: &ptr? >> list (a, m+n)
) : void =
(
case+ xs of
| list_cons
    (x, xs1) => let
    val () = // allocate a list node with
    res := list_cons{a}{0}(x, _) // uninitialized tail
    val+list_cons (_, res1) = res // [res1] points to the tail
    // put into [res1] the concatenation
    val () = loop (xs1, ys, res1) // of [xs1] and [ys]
  in
    fold@(res) // folding translates into a no-op at run-time
  end // end of [list_cons]
| list_nil ((*void*)) => res := ys
)
//
var res: ptr
val () = loop (xs, ys, res)
//
in
  res
end // end of [list_append2]
xs, ys, res が与えられたとき、内部関数 [loop] は res の中に xs と ys の連結を配置します。 [loop] の本体では、式 fold@(res) が末尾呼出 [loop] よりも後ろにあるように見えます。 けれども、型検査の目的で fold@(res) は単に使われて型検査後では削除されてしまうため、この呼び出しは末尾呼出であると考えられます。 したがって [loop] は末尾再帰関数です。

ところで、この [loop] の実装に見られる再帰のスタイルはしばしば 末尾再帰を法とするアロケーション (tail-recursion modulo allocation) と呼ばれます。 これは一般に弱い型の言語 (例: LISP や C言語) 見られますが、(適切に型を付けることが非常に困難なため) 型付き言語ではあまり見られません。

相互末尾再帰関数

ときには、相互再帰呼出をローカルジャンプに変化させるために、関数を組み合わせる必要があります。 例えば、次のコードでは [isevn] と [isodd] は相互再帰的に定義されています:
fun isevn (n: int): bool =
  if n > 0 then isodd (n-1) else true
and isodd (n: int): bool =
  if n > 0 then isevn (n-1) else false
[isevn] 本体の [isodd] 呼び出しと [isadd] 本体の [isevn] 呼び出しは両方とも末尾呼出です。 これらはまた相互末尾呼出なので、これらは相互末尾再帰呼出と呼ばれます。

[isevn] と [isadd] のこの実装をコンパイルすると、 ATS コンパイラ (ATS/Postiats) は [isevn] と [isodd] を別々に扱います。 したがって、[isevn] ([isodd]) 本体内の [isodd] ([isevn]) 呼び出しをローカルジャンプに変換できません。 2つの関数を混合してコンパイルする必要があることをコンパイラに指示するために、キーワード [fun] を別のキーワード [fnx] で置き換えなければなりません:

fnx isevn (n: int): bool =
  if n > 0 then isodd (n-1) else true
and isodd (n: int): bool =
  if n > 0 then isevn (n-1) else false
上記のコードをコンパイルすると、ATS コンパイラは [isodd] 本体のコピーを [isevn] 本体内に埋め込みます。 これで、これら2つの関数の本体内の相互末尾再帰呼出はローカルジャンプに変換できるようになります。 また、キーワード [fnx] に続いて相互的に定義された関数列の最初の関数だけ、後のコードで使用できることに注意してください。 上記の場合、関数 [isevn] は後に使用することができますが、関数 [isodd] はそうではありません。
この記事は Hongwei Xi によって書かれ、 Japan ATS User Group によって翻訳されています。