今日、unixで "time"コマンドを発見し、Haskellの末尾再帰関数と通常の再帰関数のランタイムの違いを確認するために使用すると思いました。
次の関数を作成しました。
--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x y = fac' (x-1) (x*y)
--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)
これらはこのプロジェクトで使用するためだけのものであることに留意して有効であるため、ゼロや負の数をチェックすることはありませんでした。
ただし、それぞれのメインメソッドを作成し、それらをコンパイルし、「time」コマンドで実行すると、両方ともnormal recursive関数で同様のランタイムがあり、末尾の再帰的なものを削除します。これは、LISPの末尾再帰最適化に関して聞いたこととは反対です。この理由は何ですか?
Haskellは、遅延評価を使用して再帰を実装しているため、必要なときに値を提供する約束としてこれを扱います(これはサンクと呼ばれます)。サンクは、続行するのに必要な分だけ削減され、それ以上は削減されません。これは、数式を数学的に単純化する方法に似ているため、そのように考えると便利です。評価順序がコードで指定されているnotであるという事実により、コンパイラは、従来のテールコールの除去だけでなく、より巧妙な最適化を行うことができます。 最適化が必要な場合は_-O2
_でコンパイルしてください!
ケーススタディとして_facSlow 5
_を評価する方法を見てみましょう。
_facSlow 5
5 * facSlow 4 -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3) -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2)) -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120
_
あなたが心配したように、計算が行われる前に数字が蓄積されていますが、nlikeあなたは心配しています。facSlow
関数呼び出しのスタックはありません。リダクションが適用されて終了し、 スタックフレーム が残ります(つまり、_(*)
_は厳密であるため、2番目の引数の評価をトリガーします)。
Haskellの再帰関数は、非常に再帰的な方法では評価されません!ぶらぶらしている呼び出しの唯一のスタックは、乗算そのものです。 _(*)
_が厳密なデータコンストラクターと見なされる場合、これはguarded recursionとして知られています(通常はnon-strict data constructorsと呼ばれますが) 、その後に残っているのはデータコンストラクターです-さらなるアクセスによって強制された場合)。
次に、末尾再帰_fac 5
_を見てみましょう。
_fac 5
fac' 5 1
fac' 4 {5*1} -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}} -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}} -- the thunk "{...}"
(2*{3*{4*{5*1}}}) -- is retraced
(2*(3*{4*{5*1}})) -- to create
(2*(3*(4*{5*1}))) -- the computation
(2*(3*(4*(5*1)))) -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120
_
そのため、末尾再帰だけでは時間やスペースを節約できなかったことがわかります。全体的に_facSlow 5
_よりも多くの手順を実行するだけでなく、ネストされたサンク(ここでは_{...}
_として表示)を構築します。これには余分なスペースが必要です。計算、実行されるネストされた乗算。
このサンクは、下にitをたどることによって解かれ、スタック上の計算を再作成します。また、どちらのバージョンでも、非常に長い計算でスタックオーバーフローが発生する危険性があります。
これを手動で最適化する場合は、厳密にするだけです。厳密なアプリケーション演算子_$!
_を使用して定義できます
_facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
_
これにより、2番目の引数で_facS'
_が厳密になります。 (_facS'
_のどの定義を適用するかを決定するために評価する必要があるため、最初の引数はすでに厳密です。)
時には厳格さが非常に役立つ場合もありますが、怠inessがより効率的であるために大きな間違いになることもあります。ここでは良いアイデアです:
_facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
_
あなたが達成したかったのはそれだと思います。
-O2
_でコンパイルすることですfoldr
とfoldl
について参照し、それらを互いにテストします。次の2つを試してください。
_length $ foldl1 (++) $ replicate 1000
"The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000
"The number of reductions performed is more important than tail recursion!!!"
_
_foldl1
_は末尾再帰であるのに対し、_foldr1
_は保護された再帰を実行するため、最初のアイテムがすぐに処理/アクセスのために提示されます。 (最初の左括弧「(...((s+s)+s)+...)+s
_」を一度に「括弧で囲み」、入力リストを最後まで完全に強制し、完全な結果が必要になるよりもはるかに早く将来の計算の大きなサンクを構築します。 、s+(s+(...+(s+s)...))
、入力リストをビットごとに消費するため、最適化により、すべてが一定の空間で動作できます)。
使用しているハードウェアに応じて、ゼロの数を調整する必要があります。
fac
関数は、ガード付き再帰の適切な候補ではないことに注意してください。末尾再帰はここへ行く方法です。遅延のため、アキュムレータの引数が大きなサンクを構築し続けるため、fac'
関数でTCOの効果が得られません。評価時には巨大なスタックが必要になります。これを防ぎ、TCOの望ましい効果を得るには、これらのアキュムレーター引数を厳密にする必要があります。
{-# LANGUAGE BangPatterns #-}
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x !y = fac' (x-1) (x*y)
-O2
(または単に-O
)を使用してコンパイルする場合、GHCはおそらく strictness analysis フェーズで独自にこれを行います。
Haskellでの末尾再帰 に関するwiki記事をご覧ください。特に、式の評価のため、必要な再帰の種類はguarded再帰です。 (Haskellの抽象マシンで)ボンネットの下で何が起こっているかの詳細を計算すると、厳密な言語の末尾再帰と同じ種類のものが得られます。これに加えて、遅延関数の構文は統一されています(末尾再帰は厳密な評価に結び付けられますが、保護された再帰はより自然に機能します)。
(そしてHaskellの学習では、これらのwikiページの残りの部分も素晴らしいです!)
正しく思い出せば、GHCは自動的に単純な再帰関数を末尾再帰最適化関数に最適化します。