私はF#を初めて使用し、末尾再帰関数について読んでいて、誰かが関数fooの2つの異なる実装を提供してくれることを望んでいました。1つは末尾再帰で、もう1つはそうではないので原理をよりよく理解できます。
リスト内のアイテムを 'aから' bにマッピングするなど、簡単なタスクから始めます。署名のある関数を書きたい
_val map: ('a -> 'b) -> 'a list -> 'b list
_
どこ
_map (fun x -> x * 2) [1;2;3;4;5] == [2;4;6;8;10]
_
非末尾再帰バージョンで開始:
_let rec map f = function
| [] -> []
| x::xs -> f x::map f xs
_
関数は再帰呼び出しを行った後もまだやるべきことがあるので、これは末尾再帰ではありません。 _::
_はList.Cons(f x, map f xs)
のシンタックスシュガーです。
関数の非再帰的な性質は、最後の行を_| x::xs -> let temp = map f xs; f x::temp
_として書き直した場合、もう少し明白になる可能性があります。明らかに、再帰呼び出しの後に機能します。
アキュムレータ変数を使用して末尾再帰にします。
_let map f l =
let rec loop acc = function
| [] -> List.rev acc
| x::xs -> loop (f x::acc) xs
loop [] l
_
これが、変数acc
に新しいリストを作成しているところです。リストは逆に作成されるため、ユーザーに返す前に出力リストを逆にする必要があります。
少しマインドワープが必要な場合は、継続渡しを使用してコードをより簡潔に記述できます。
_let map f l =
let rec loop cont = function
| [] -> cont []
| x::xs -> loop ( fun acc -> cont (f x::acc) ) xs
loop id l
_
loop
とcont
の呼び出しは、追加の作業なしで呼び出される最後の関数であるため、末尾再帰です。
これが機能するのは、継続cont
が新しい継続によってキャプチャされ、次に別の継続によってキャプチャされて、次のようなツリーのようなデータ構造になるためです。
_(fun acc -> (f 1)::acc)
((fun acc -> (f 2)::acc)
((fun acc -> (f 3)::acc)
((fun acc -> (f 4)::acc)
((fun acc -> (f 5)::acc)
(id [])))))
_
これは、リストを逆にすることなく、順番にリストを作成します。
その価値については、末尾再帰ではない方法で関数を書き始めてください。関数は読みやすく、操作しやすくなっています。
通過する大きなリストがある場合は、アキュムレータ変数を使用してください。
アキュムレータを便利な方法で使用する方法が見つからず、他に自由に使用できるオプションがない場合は、継続を使用してください。私は個人的に、継続の重要で大量の使用は読みにくいと考えています。
他の例よりも短い説明の試み:
let rec foo n =
match n with
| 0 -> 0
| _ -> 2 + foo (n-1)
let rec bar acc n =
match n with
| 0 -> acc
| _ -> bar (acc+2) (n-1)
fooは、「2 + foo(n-1)」を評価して返すために、fooを再帰的に呼び出す必要があるため、末尾再帰ではありません。
barは値を返すために再帰呼び出しの戻り値を使用する必要がないため、barは末尾再帰です。再帰的に呼び出されたbarにその値をすぐに返させることができます(呼び出しスタックを最後まで返すことはありません)。コンパイラはこれを認識し、再帰をループに書き換えることで「チート」します。
バーの最後の行を「| _-> 2+(bar(acc + 2)(n-1))」に変更すると、テールエンドの再帰性が破壊されます。
これはより明白な例です。階乗に対して通常行うことと比較してください。
let factorial n =
let rec fact n acc =
match n with
| 0 -> acc
| _ -> fact (n-1) (acc*n)
fact n 1
これは少し複雑ですが、戻り値を変更するのではなく、実行中の集計を維持するアキュムレータがあるという考え方です。
さらに、このスタイルのラッピングは通常は良い考えです。そうすれば、呼び出し元はアキュムレータのシードについて心配する必要がありません(事実は関数に対してローカルであることに注意してください)。
私もF#を学んでいます。以下は、フィボナッチ数を計算するための非末尾再帰関数と末尾再帰関数です。
末尾再帰バージョン
let rec fib = function
| n when n < 2 -> 1
| n -> fib(n-1) + fib(n-2);;
末尾再帰バージョン
let fib n =
let rec tfib n1 n2 = function
| 0 -> n1
| n -> tfib n2 (n2 + n1) (n - 1)
tfib 0 1 n;;
注:fibanacciの数は非常に速く増加する可能性があるため、最後の行を置き換えることができますtfib 0 1 n
からtfib 0I 1I n
F#でNumerics.BigInteger構造を利用する
また、テストするときは、デバッグモードでコンパイルするときに、間接末尾再帰(末尾呼び出し)がデフォルトでオフになっていることを忘れないでください。これにより、デバッグモードでは末尾呼び出しの再帰がスタックをオーバーフローする可能性がありますが、リリースモードではオーバーフローしません。