Skip to content

Instantly share code, notes, and snippets.

@znd-milktea
Last active August 29, 2015 13:57
Show Gist options
  • Save znd-milktea/9466162 to your computer and use it in GitHub Desktop.
Save znd-milktea/9466162 to your computer and use it in GitHub Desktop.

すごいHaskell読書会 in 大阪 2週目 #3 第4章「Hello 再帰!」延長戦

##再帰で本当に大丈夫?

Haskellは、ループ処理を「言語構文としては」サポートしていません。
「ループは再帰で書くのが関数型言語」とか良く言われます。
でも、手続き型言語の世界から来た人にとっては「再帰って、スタック膨れるんじゃね?」って、不安に思いますよね?

あなたの心配は正しい!

Haskellだって、うっかりな再帰をすればStack space overflowを起こします。
そうならないために、どのような再帰を書くべきか/書かないべきかを知っておきましょう。


##整数リストの和 標準で存在するsum関数を、自前で実装してみます。

sum' []     = 0
sum' (x:xs) = x + sum' xs

関数sum'に、リスト[1, 2, 3]を与えると、下記のように評価されます。

sum' [1, 2, 3] = 1 + sum' [2, 3]
               = 1 + (2 + sum' [3])
               = 1 + (2 + (3 + sum' []))
               = 1 + (2 + (3 + 0))
               = 6

再帰的な呼び出しになっていますが、再帰呼び出し完了後に(+)演算子の評価を行うため、再帰呼び出し完了後の戻り先をスタックに積む必要があります。 長いリストが渡されて再帰が深くなると、それだけスタックが膨れ上がることになります。


##末尾再帰

関数型言語が、ループを再帰で実現するするための重要な仕組みが「末尾呼び出し最適化」です。
これは、「関数の末尾が関数呼び出しで終わる場合、その呼び出しをジャンプに置き換える」というものです。戻り先をスタックに積む必要がなくなるため、スタックが膨れ上がることを防ぐことができます。

末尾の関数呼び出しが再帰である場合、特に「末尾再帰」と呼びます。末尾再帰はループと等価です。
つまり、関数型言語でループを実現するには末尾再帰にする必要があります。


##末尾再帰に書き換える

ということで、スタックが溢れないように、関数sum'を末尾再帰の形に書き換えます。

sum'' as = iter as 0
  where
    iter []     acc = acc
    iter (x:xs) acc = iter xs (acc + x)

末尾再帰に書き換える際のテクニックの1つとして、上記accのように計算結果を積み上げる引数を追加する方法があります。 このような変数のことを「アキュムレータ」と呼びます。

関数sum''に、リスト[1, 2, 3]を与えると、下記のように評価されます。

sum'' [1, 2, 3] = iter [1, 2, 3] 0
                = iter [2, 3] (0 + 1)
                = iter [3] ((0 + 1) + 2)
                = iter [] (((0 + 1) + 2) + 3)
                = 6

関数iterの末尾再帰になっているので、実際はループと同じコードに変換されます。
※なお、今回はサンクについての議論を端折っています。実際は、上記のままでは、サンクが膨れ上がってやっぱりStack space overflowとなってしまいます。そのあたりの事情は第6章で…


##末尾再帰が良いとは限らない

では、何でもかんでも末尾再帰にすべきかと言えば、そうでもありません。

例えば、Intリストを受け取り、各要素に1を加算したリストを返すincListを末尾再帰で定義すると、下記のようになります。

incList as = iter as []
  where
    iter []     acc = acc
    iter (x:xs) acc = iter xs (acc ++ [x + 1])

関数incListに、リスト[1, 2, 3]を与えると、下記のように評価されます。

incList [1, 2, 3] = iter [1, 2, 3] []
                  = iter [2, 3] [2]
                  = iter [3] [2, 3]
                  = iter [] [2, 3, 4]
                  = [2, 3, 4]

一見問題無さそうですが、答えを返すために、渡されたリストの要素全てを評価しています。
これは、無限リストが渡された場合に停止しないことを意味します。

一方、末尾再帰でない形で定義した場合は、下記のようになります。

incList' []     = []
incList' (x:xs) = (x + 1) : incList' xs

関数incList'に、リスト[1, 2, 3]を与えると、下記のように評価されます。

incList' [1, 2, 3] = 2 : incList' [2, 3]
                   = 2 : (3 : incList' [3])
                   = 2 : (3 : (4 : incList' []))
                   = 2 : (3 : (4 : []))
                   = [2, 3, 4]

例えば、関数takeと組み合わせた場合、無限リストを与えても、遅延評価のおかげで下記のように評価され停止します。

(take 2) (incList' [1..]) = (take 2) (2 : incList' [2..])
                          = (take 2) (2 : (3 : incList' [3..]))
                          = [2, 3]

なお、sum'の場合と違うのは、sum'の結果はリストの総和なので、リストの全要素を評価しないと決定できません。 一方incList'の場合は、先頭から必要な要素だけを評価し、不要な部分の評価を省略しています。

通常は、与えられたリストと同順の結果のリストを返す場合は、末尾再帰で無い方が良いです。


##畳み込み

sum''の例であった

sum'' [1, 2, 3] = (((0 + 1) + 2) + 3)

のアキュムレータの初期値を引数で与えられるようにすると

sum'' [1, 2, 3] acc = (((acc + 1) + 2) + 3)

という風に、アキュムレータに対して、リストの左要素から足しこんでいく操作になります。

一般的に、リスト[E1, E2, E3, ..]とアキュムレータaccと2引数関数fがある場合、

(f (f (f acc E1) E2) E3) ..

とする操作を「左畳み込み」と呼びます。

一方、incList'はアキュムレータを用いて

incList'' as = iter as []
  where
    iter []     acc = acc
    iter (x:xs) acc = (x + 1) : iter xs acc

と書き換えられます。sum''の場合と同様に、アキュムレータの初期値を引数で与えられるようにすると

incList'' [1, 2, 3] acc = 2 : (3 : (4 : acc))

という風に、アキュムレータに対して、リストの右要素から足しこんでいく操作になります。

一般的に、リスト[.., En-2, En-1, En]とアキュムレータaccと2引数関数fがある場合、

.. (f En-2 (f En-1 (f En acc)))

とする操作を「右畳み込み」と呼びます。

左畳み込みは、メモリを節約した再帰を実現できます。
一方、右畳み込みは、リストの要素の評価を省略可能な再帰を実現できます(省略可能なので、無限リストも扱える)。
畳み込みについては、関数foldl/foldrとして、第5章で登場します。


##参考 再帰ドリル
末尾再帰と正格評価 - Memorandum

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment