Haskellには末尾再帰最適化がありますか?


88

今日、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」コマンドを使用して実行すると、どちらも通常の再帰関数が末尾の再帰関数をエッジングする同様のランタイムを持っていました。これは、lispでの末尾再帰最適化に関して私が聞いたこととは対照的です。これの理由は何ですか?


8
TCOはコールスタックを節約するための最適化だと思います。CPU時間を節約することを意味するものではありません。間違っていれば訂正してください。
ジェローム

3
lispでテストしていませんが、私が読んだチュートリアルでは、スタックをセットアップすること自体により多くのプロセッサコストが発生することを暗に示していますが、コンパイルから反復までの末尾再帰ソリューションでは、これを行うためにエネルギー(時間)を消費しなかったため、より効率的でした。
haskell rascal

1
@Jerome ..だけでなく、それは多くのものに依存するが、典型的にTCOは通常、同様に高速プログラムが生成されますので、遊びに来てもキャッシュする
のKristopher Micinski

これの理由は何ですか?つまり、怠惰です。
Dan Burton、

興味深いことに、補助関数を使用してfacghcを計算product [n,n-1..1]する方法は多かれ少なかれ異なりますprodが、もちろんproduct [1..n]単純です。これは、これがghcが単純なアキュムレータにコンパイルできると非常に確信している種類のものであることを理由に、2番目の引数で厳密にしていないことを前提とすることができます。
AndrewC 2012年

回答:


166

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
  • テール再帰は、サンクビルドアップがない場合にのみ有効であり、適切な場合は、厳密性を追加することで通常は防止できます。これは、後で一度に必要な結果を作成するときに発生します。
  • テール再帰は不適切な計画であり、ガード付き再帰の方が適している場合があります。つまり、作成している結果が少しずつ必要になる場合です。参照してくださいこの質問についてfoldrfoldlたとえば、互いに対してそれらをテストします。

次の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)...))で囲み、入力を消費します。ビットごとにリストアップするので、全体を最適化して一定の空間で操作できます)。

使用しているハードウェアに応じて、ゼロの数を調整する必要がある場合があります。


1
@WillNessありがとうございます。格納する必要はありません。今は後世のためのより良い答えだと思います。
AndrewC 2012年

4
これは素晴らしいですが、厳密性分析に同意することを提案できますか?これは、GHCの最近のバージョンでは、ほぼ間違いなく末尾再帰的階乗の役割を果たします。
dfeuer

15

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はおそらく厳密性分析フェーズで独自にこれを実行します。


4
を使用した$!場合よりも明確だと思いますBangPatternsが、これは良い答えです。特に厳格性分析についての言及。
singpolyma 2012年

7

Haskellの末尾再帰に関するWikiの記事をチェックしてください。特に、式の評価のため、必要な再帰は保護された再帰です。(Haskellの抽象マシンで)内部で行われていることの詳細を理解すると、厳密な言語での末尾再帰と同じような結果になります。これに加えて、遅延関数の構文は統一されています(末尾再帰は厳密な評価に結び付けられますが、保護された再帰はより自然に機能します)。

(そしてHaskellを学ぶ上で、これらのwikiページの残りも素晴らしいです!)


0

私が正しく思い出せば、GHCは自動的に単純な再帰関数を末尾再帰最適化関数に最適化します。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.