【问题标题】:Does Haskell have tail-recursive optimization?Haskell 有尾递归优化吗?
【发布时间】:2012-10-14 02:06:48
【问题描述】:

我今天在 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)

这些是有效的,记住它们仅用于这个项目,所以我没有费心检查零或负数。

然而,在为每个方法编写一个 main 方法、编译它们并使用“time”命令运行它们时,两者都有相似的运行时,normal 递归函数将尾递归函数边缘化。这与我听到的关于 lisp 中的尾递归优化的说法相反。这是什么原因?

【问题讨论】:

  • 我相信 TCO 是一种节省一些调用堆栈的优化,它并不意味着你会节省一些 CPU 时间。如有错误请指正。
  • 尚未使用 lisp 对其进行测试,但我阅读的教程暗示设置堆栈本身会产生更多的处理器成本,而编译到迭代的尾递归解决方案没有花费任何能量(时间)这样做,因此效率更高。
  • @Jerome 好吧,这取决于很多事情,但通常缓存也会发挥作用,因此 TCO 通常也会产生更快的程序..
  • 这是什么原因?一句话:懒惰。
  • 有趣的是,您的 fac 或多或少是 ghc 使用辅助函数 prod 计算 product [n,n-1..1] 的方式,但当然 product [1..n] 会更简单。我只能假设他们在第二个论点中没有严格要求,因为 ghc 非常有信心它可以编译成一个简单的累加器。

标签: haskell optimization lazy-evaluation tail-recursion tail-call-optimization


【解决方案1】:

Haskell 使用惰性求值来实现递归,因此将任何内容视为在需要时提供值的承诺(这称为 thunk)。 Thunks 只减少到继续进行所需的量,不再减少。这类似于您在数学上简化表达式的方式,因此以这种方式思考它是有帮助的。您的代码指定评估顺序这一事实允许编译器进行许多更聪明的优化,而不仅仅是您习惯的尾调用消除。 如果要优化,请使用 -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 函数调用等待终止 - 每个减少被应用并消失,留下一个stack frame(这是因为(*) 是严格的,因此会触发对其第二个参数的评估)。

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(这里显示为{...})——需要一个额外的空间——它描述了未来的计算,嵌套的要执行的乘法。

然后通过遍历 it 到底部来解开这个 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编译
  • 尾递归只有在没有 thunk 累积时才有用,如果合适的话,增加严格性通常有助于防止它。当您构建一个稍后需要的结果时,就会发生这种情况。
  • 有时尾递归是一个糟糕的计划,而受保护的递归更适合,即当您构建的结果需要一点一点、部分地进行时。例如,请参阅 this question 关于 foldrfoldl,并将它们相互测试。

试试这两个:

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)...)),一点一点地消耗输入列表,所以整个东西能够在恒定空间中运行,并进行优化)。

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

【讨论】:

  • @WillNess 太好了,谢谢。无需缩回。我认为现在对后代来说这是一个更好的答案。
  • 这很好,但我可以建议向strictness analysis 点头吗?我认为这几乎肯定会在任何合理的最新版本 GHC 中完成尾递归阶乘的工作。
【解决方案2】:

应该提到fac 函数不是保护递归的好选择。尾递归是这里的方法。由于懒惰,您没有在 fac' 函数中获得 TCO 的效果,因为累加器参数不断构建大的 thunk,在评估时将需要巨大的堆栈。为了防止这种情况并获得 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 阶段自行完成。

【讨论】:

  • 我认为$!BangPatterns 更清楚,但这是一个很好的答案。尤其是提到严格性分析。
【解决方案3】:

您应该查看tail recursion in Haskell 上的维基文章。特别是,由于表达式评估,您想要的递归类型是受保护的递归。如果你弄清楚幕后发生的事情的细节(在 Haskell 的抽象机器中),你会得到与严格语言中的尾递归相同的东西。除此之外,您还拥有用于惰性函数的统一语法(尾递归会将您与严格的评估联系起来,而保护递归更自然地工作)。

(在学习 Haskell 时,其他 wiki 页面也很棒!)

【讨论】:

    【解决方案4】:

    如果我没记错的话,GHC 会自动将普通递归函数优化为尾递归优化函数。

    【讨论】:

      猜你喜欢
      • 2017-05-23
      • 1970-01-01
      • 2021-05-30
      • 1970-01-01
      • 1970-01-01
      • 2014-06-23
      • 1970-01-01
      • 2018-06-05
      相关资源
      最近更新 更多