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时间。如果有错请指正。
杰罗姆(Jerome)

3
尚未使用lisp对其进行测试,但是我阅读的教程暗示设置堆栈本身会导致更多的处理器成本,而从编译为迭代的尾递归解决方案并没有花费任何精力(时间),因此效率更高。
haskell rascal

1
@Jerome以及它取决于很多事情,但通常高速缓存也开始发挥作用,所以TCO通常会产生更快的程序以及..
Kristopher Micinski

这是什么原因?一句话:懒惰。
丹·伯顿

有趣的是,您fac或多或少都product [n,n-1..1]使用了辅助函数来计算ghc prod,但是当然product [1..n]会更简单。我只能假设他们没有对第二个参数做严格的规定,理由是ghc非常有信心可以将其编译为一个简单的累加器。
AndrewC 2012年

Answers:


166

Haskell使用惰性求值来实现递归,因此将任何东西都视为在需要时提供值的承诺(这被称为thunk)。unk子只会尽可能减少,而不会减少。这类似于您数学上简化表达式的方式,因此以这种方式思考将很有帮助。您的代码指定评估顺序的事实使编译器可以进行很多甚至更聪明的优化,而不仅仅是过去的尾声消除。如果您要优化,请编译-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函数挂起等待终止-每次归约都会应用并消失,从而在堆栈中保留一个堆栈框架唤醒(这是因为它(*)很严格,因此触发了对其第二个参数的求值)。

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,而且还会构建一个嵌套的thunk(在此处显示为{...})-需要额外的空间 -它描述了将来的计算以及要执行的嵌套乘法。

这thunk是然后通过遍历揭开的底部,重新创建堆栈上的计算。对于这两个版本,还有很长的计算时间导致堆栈溢出的危险。

如果我们想手动优化,我们要做的就是使其严格。您可以使用严格的应用运算符$!来定义

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

facS'在第二个论点中要求严格。(它的第一个参数已经很严格了,因为必须对其进行评估,以确定facS'要应用哪个定义。)

有时严格性可以提供极大帮助,有时则是一个很大的错误,因为懒惰更为有效。这是个好主意:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

我想您想实现的目标是什么。

摘要

  • 如果要优化代码,第一步是使用 -O2
  • 尾部递归只有在没有堆积的情况下才是好的,并且增加严格性通常可以在适当的情况下防止这种情况的发生。当您生成稍后需要的结果时,会发生这种情况。
  • 有时尾部递归是一个不好的计划,而保护性递归则更合适,例如,当您要逐步生成结果时,将需要一点一点地分批进行。见这个问题有关foldr,并foldl举例来说,并测试他们反目成仇。

尝试以下两个:

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)...))消耗输入内容一点一点地列出,因此整个过程可以在恒定的空间内进行优化操作)。

您可能需要根据所使用的硬件来调整零的数量。


1
@WillNess太好了,谢谢。无需缩回。我认为这对于后代来说是一个更好的答案。
AndrewC 2012年

4
很好,但是我可以建议严格性分析吗?我认为这几乎肯定可以在任何最新版本的GHC中完成尾递归析因。
dfeuer 2014年

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会将自动递归函数自动优化为尾递归优化函数。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.