末尾再帰とcontinuation passing style

文字列を逆順にする関数reverseは以下のとおり

reverse [] = []
reverse (h:t) = (reverse t) ++ [h]

空のときは逆順も空、何かあるときは、全体を頭と残りにわけ、残り側を逆順にしたものの後ろに頭を付け足す。

これは末尾再帰ではない。なぜなら内側のreverse呼び出し後にさらに処理(append関数の呼び出し)を行っているから。一般的に末尾再帰にする理由は、末尾再帰呼び出しの場合、処理系がその処理を高速化するから

reverse [] = []
reverse (h:t) = let rt = reverse t in
                (++) rt [h]

reverseを末尾再帰にしたバージョンは以下のようになる。

reverse l = reverseIn l []
  where
    reverseIn [] acc = acc
    reverseIn (h:t) acc = let nacc = h:acc in
                          reverseIn t nacc

reverseに"abc"を渡したコールイメージは、以下のとおり

reverseIn "abc" []
  reverseIn "bc" 'a':[]
    reverseIn "c" 'b':"a"
      reverseIn [] 'c':"ba" --> "cba"

この末尾再帰呼び出しに変えるために追加した引数をアキュムレータと呼んだりする。
末尾再帰呼び出しにすると高速化できるのは以下の理由から

  • 後処理をせず大本の呼び出しまで一気に処理を戻せる
  • 再帰の関数呼び出しでは、新たに関数フレームを作らずに、呼び出し元関数のフレームを上書きして使いまわせる


ただし、再帰関数を末尾再帰関数呼び出しにするのは普通は知恵がいる。このreverseのようなアキュムレータ操作を思いつくのは普通は難しい。そこで再帰呼び出しの後の処理を関数にして、それを渡し最後に実行してみよう。

reverse (h:t) = reverse t (\rt -> rt ++ [h])

これは引数の数が違うので正しくない。そこで引数nextを追加する。このnextにあたる内側の(\rt -> rt ++ t)はreverse tした結果を受け取る必要がある。そのため、nextは外側のreverse計算の結果である、rt ++ hを引数にとるとになる。

reverse (h:t) next = reverse t (\rt -> next (rt ++ [h]))

同様に空の場合もnextを引数にもち、nextはreverseの結果である空リストを受け取る。

reverse [] next = next []
reverse (h:t) next = reverse t (\rt -> next (rt ++ [h]))

大本で実行する場合は、next result = resultを返せばいいため、同様の定義となるid関数を渡してやる。

reverse "abc" id
  reverse "bc" (\rt1 -> id (rt1 ++ "a"))
    reverse "c" (\rt2 -> (\rt1 -> id (rt1 ++ "a")) (rt2 ++ "b"))
      reverse [] (\rt3 -> (\rt2 -> (\rt1 -> id (rt1 ++ "a")) (rt2 ++ "b")) (rt3 ++ "c"))
        (\rt3 -> (\rt2 -> (\rt1 -> id (rt1 ++ "a")) (rt2 ++ "b")) (rt3 ++ "c")) []
          (\rt2 -> (\rt1 -> id (rt1 ++ "a")) (rt2 ++ "b")) ([] ++ "c")
             (\rt1 -> id (rt1 ++ "a")) (([] ++ "c") ++ "b")
               id ((([] ++ "c") ++ "b") ++ "a"))
                 ((([] ++ "c") ++ "b") ++ "a")) -- "cba"