【问题标题】:Why isn't this recursive function being optimized? (Haskell)为什么这个递归函数没有被优化? (哈斯克尔)
【发布时间】:2014-05-27 15:25:30
【问题描述】:

我在 Haskell 中编写了自己的“求和”函数:

mySum [a] = a
mySum (a:as) = a + mySum as

并用它测试过

main = putStrLn . show $ mySum [1 .. 400000000]

只接收堆栈溢出错误。

以同样的方式使用 Prelude 的总和:

main = putStrLn . show $ sum [1 .. 400000000]

我没有堆栈溢出。

这可能是我正在评估的巨大列表,特别是如果传递给我的函数的列表正在被严格评估,尽管我不怀疑这一点的唯一原因是使用 Prelude 的总和与相同的列表我没有得到错误。

【问题讨论】:

标签: haskell recursion


【解决方案1】:

您的函数因堆栈溢出而失败,因为它不是tail recursive。每次调用都会消耗一个堆栈帧,并在每个步骤中保留“a”的部分总和。

Prelude's sum 是通过尾调用实现的:

sum     l       = sum' l 0
   where
    sum' []     a = a
    sum' (x:xs) a = sum' xs (a+x)

{-# SPECIALISE sum     :: [Int] -> Int #-}
{-# SPECIALISE sum     :: [Integer] -> Integer #-}
{-# INLINABLE sum #-}

不占用堆栈空间。

请注意,specialize 和 inline pragma 是公开严格性信息所必需的,这些信息使“a”累加器可以安全使用,而不会累加 thunk。更现代的版本是:

    sum' []     !a = a
    sum' (x:xs) !a = sum' xs (a+x)

使严格性假设明确。这是almost 相当于foldl' 版本wrt。严格。

【讨论】:

  • 你知道为什么 sum 是这样实现的吗?更具体地说,我会认为foldl' (+) 0 是自然实现。它似乎在 ghci 中效果更好,实际总和对我来说溢出但这个没有?
  • 现在没有理由不使用fold' (+),因为我们有很好的内联和专业化(以及左折叠的融合)。并非总是如此。
  • hackage.haskell.org/package/base-4.7.0.0/docs/src/… 完整的定义包括一个 foldl
  • 库的实现当然依赖于严格性分析和专业化。最好用累加器来写。
【解决方案2】:

您可能希望编译器会对您的方法执行尾调用优化。不幸的是,mySum 的这个定义不是尾部调用可优化的。它需要的是最后一个被调用的函数是递归调用,所以在这种情况下,您希望 mySum 成为最后一个调用的函数。但是,定义中调用的最后一个函数是(+),而不是mySum。您可以改为按照@DonStewart 的建议编写它,他设法在我能够输入之前输入了该解决方案。

【讨论】:

  • 它是 TR 模 (+)。弗里德曼和怀斯在 1974 年的 TR19 中为更多参与的 (:) case 描述了这种转换,他们还提到“累积和关联构造函数,例如整数乘法...... [如]阶乘”(+ 显然也适用)。
【解决方案3】:

编辑:我刚刚意识到这个问题是重复的,我基本上已经从Does Haskell have tail-recursive optimization? 重新发明了它的答案

GHC 使用所谓的lazy evaluation 评估表达式。与本次讨论最相关的惰性求值特性是所谓的“最左、最外层求值”或normal-order evaluation。要查看正常顺序评估的实际效果,让我们关注 sum 的两个实现的评估,一个 foldr 实现和一个 foldl 实现:

foldr (+) 0 (1:2:3:[])
1 + foldr (+) 0 (2:3:[])
1 + (2 + foldr (+) 0 (3:[]))
1 + (2 + (3 + foldr (+) 0 [])))
1 + (2 + (3 + 0))
1 + (2 + 3)
1 + 5
6

请注意,由于对 foldr 的递归调用不是最左、最外层的,因此惰性求值不能减少它。然而,因为 (+) 在它的第二个参数中是严格的,所以右手边将被计算,留下一个加法链。因为对 (+) 的调用是最左边最外面的,所以这个实现类似于 sum 的实现。

经常听说 foldl 因为尾递归而效率更高,但真的是这样吗?

foldl (+) 0 (1:2:3:[])
foldl (+) (0+1) (2:3:[])
foldl (+) ((0+1)+2) (3:[])
foldl (+) (((0+1)+2)+3) []
((0+1)+2)+3
(1+2)+3
3+3
6

请注意一些差异。首先,对 foldl 的递归调用是最左边的最外层,并且因为它通过正常顺序评估而减少,所以不会占用堆栈上的任何额外空间。但是,对 (+) 的调用并没有减少,并且 确实 占用了堆栈上的空间。这应该足以让您相信“尾递归”不足以防止 GHC 中的空间泄漏。

因此,我们可以使用尾部位置调用来防止表示调用 foldl(或 Don 的版本中的 sum')的 thunk 的累积,但是我们如何防止 (+) 的 thunk 累积呢?我们可以使用严格注解,或者让foldl'为我们添加:

foldl' (+) 0 (1:2:3:[])
foldl' (+) 1 (2:3:[])
foldl' (+) 3 (3:[])
foldl' (+) 6 []
6

请注意,这需要恒定的堆栈空间恒定的堆空间。

总之,如果您的递归调用是最左端、最外端(对应于尾部位置),则可以通过惰性求值来减少它们。这是必要的,但 还不够 防止递归函数的评估使用 O(n) 堆栈和堆空间。 foldlfoldr 样式递归本身都占用 O(n) 堆栈和堆空间。 foldl-style 递归对累积参数进行严格注释 是使评估在恒定空间中运行所必需的。

【讨论】:

  • foldr 示例中没有“thunk 的累积”:为下一个尾部创建的每个新 thunk 都会立即被消耗,这是由严格的 + 构造函数强制执行的。也就是说,是堆栈的清盘。在foldl 示例中,确实存在(嵌套)thunk 的积累——{_+2} 所指的{0+1},以及下一个{_+3}。然后当达到[] 时,会强制执行最外层的thunk,这会导致创建总和堆栈{_+3} = {_+2} + 3 = ({0+1} + 2) + 3 = (1 + 2) + 3 = ...。 -- 当然,具有非严格组合功能的foldr 本身就是 O(1)。 :)
  • 谢谢,威尔。更新了该部分以修复。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-12-07
  • 2013-10-19
  • 1970-01-01
  • 1970-01-01
  • 2019-03-19
  • 1970-01-01
相关资源
最近更新 更多