Haskellは遅延評価を使用して再帰を実装するため、必要なときに値を提供するという約束として何かを扱います(これはサンクと呼ばれます)。サンクは、続行するのに必要な分だけ削減されます。これは、数式を数学的に単純化する方法に似ているので、そのように考えると役立ちます。評価の順序がコードで指定されていないという事実により、コンパイラーは、かつて使用していた末尾呼び出しの排除だけでなく、より賢い最適化を数多く行うことができます。最適化したい場合はコンパイルし-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
したがって、心配するように、計算が行われる前に数値が蓄積されますが、心配するのとは異なり、facSlow
終了を待機する関数のスタックはありません。各削減が適用されて消え、スタックフレームが残ります。スリープ解除(これは(*)
が厳格であるため、2番目の引数の評価をトリガーするためです)。
Haskellの再帰関数は非常に再帰的な方法で評価されません!ぶらさがっている呼び出しの唯一のスタックは、乗算そのものです。(*)
が厳密なデータコンストラクターとして表示される場合 、これは保護された再帰と呼ばれます(通常、非厳密なデータコンストラクターと呼ばれますが、その後のアクセスで強制された場合、データコンストラクターがウェイクに残されます)。
では、末尾再帰を見てみましょう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
、ネストされたサンク(ここではとして示されます{...}
)も構築します-そのために追加のスペースが必要です-将来の計算、ネストされた乗算の実行を記述します。
このサンクは、それを最後までたどることによって解明され、スタック上で計算を再作成します。また、どちらのバージョンでも、非常に長い計算でスタックオーバーフローが発生する危険があります。
これを手動で最適化する場合は、厳密にする必要があります。厳密なアプリケーション演算子$!
を使用して、
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
これfacS'
は、その第2の議論において厳格であることを強制します。(facS'
適用する定義を決定するために評価する必要があるため、最初の引数はすでに厳格です。)
厳格さは非常に役立つ場合がありますが、怠惰の方が効率的であるため、大きな間違いになる場合もあります。ここでそれは良いアイデアです:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
あなたが達成したかったのはそれだと思います。
概要
- コードを最適化する場合は、ステップ1でコンパイルします
-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
で囲み、入力リストを最後まで完全に強制し、完全な結果が必要になるよりもはるかに早く将来の計算の大きなサンクを構築します。2番目は徐々に右に括弧s+(s+(...+(s+s)...))
で囲み、入力を消費します。ビットごとにリストアップするので、全体を最適化して一定の空間で操作できます)。
使用しているハードウェアに応じて、ゼロの数を調整する必要がある場合があります。