【问题标题】:Do stack overflow errors occur in Haskell?Haskell中是否发生堆栈溢出错误?
【发布时间】:2017-09-02 01:48:38
【问题描述】:

作为一种纯函数式编程语言,Haskell 大量使用递归。 Haskell 中是否会出现堆栈溢出错误,就像在 Java 中一样?为什么,或者为什么不?

【问题讨论】:

  • 像 GHC 这样的 Haskell 编译器执行尾调用消除 en.wikipedia.org/wiki/Tail_call。在实现良好的函数式语言中,尾递归函数不会导致堆栈溢出。
  • @JustinL。 Yes it does.
  • 惰性函数式语言不需要尾调用消除来避免堆栈溢出。如果您实现一个简单的图形缩减引擎,其中所有内容都在堆上,那么无论如何,尾调用(不仅仅是递归)消除的等价物会因为懒惰而免费; GHC 将尾递归转换为汇编程序循环以提高效率,我并不感到惊讶,但它不需要堆栈管理。实际上可以通过尝试将代码转换为尾递归形式来引入在 Haskell 中可能出现的那种堆栈溢出(嵌套的 thunk 太多,递归深度不够)!
  • 在 Haskell 中,使用尾递归可能是有益的,也可能是有害的,具体取决于上下文。例如。使用尾递归和(严格!)累加参数对列表求和是好的。使用尾递归实现foldr 是可怕的,即使忽略无限列表也是如此。
  • 在 Java 和其他一些语言中,我认为运行时在堆栈上设置了一个相对任意的界限,因为在这种语言中,递归太多是不习惯的,所以超过 -say- 10000 调用帧是非常规的,因此被禁止,即使操作系统内存允许更大的堆栈。 (然而,JVM 有一个改变限制的标志)因此,我猜许多程序员“知道”递归本质上是不好的,在命令式代码中应该不惜一切代价避免。这加强了递归的单一性。

标签: haskell recursion stack-overflow


【解决方案1】:

由于惰性,Haskell 使用堆栈的方式与 Java 不同。

在 Java 中,调用方法时会创建堆栈帧,并在方法返回时释放堆栈帧。所以如果f()是递归方法,每次递归调用f()都会产生一个栈帧,这些帧是严格嵌套的。当你有很深的递归调用链时,你可能会发生堆栈溢出,比如f() -> f() -> f() -> …

而在 Haskell 中,thunk 是在调用函数时创建的。当使用模式匹配(例如case)强制 thunk 时会创建一个堆栈帧,并在 thunk 的评估完成到足以返回一个值(可能包含更多未评估的 thunk)时释放。

所以如果f 是一个递归函数,每次调用f 都会生成一个thunk,而case 在这个结果上会生成一个堆栈帧,但是这些帧只有 嵌套当 thunk 之间存在依赖关系时。事实上,这就是seq 原语的作用:a `seq` b 的意思是“在b 之前评估a,返回b”,但您也可以将其视为在@ 上添加b 的依赖项987654334@,所以当b被求值时,a也被强制了。

当你有一个很深的 thunks 链要评估时,你可能会发生堆栈溢出,例如在过度懒惰的 foldl 函数中:

foldl (+) 0 [1..5]
==
foldl (+) 0 (1 : 2 : 3 : 4 : 5 : [])
==
((((0 + 1) + 2) + 3) + 4) + 5

这会生成一个这样的 thunk 链:

((+)
    ((+)
        ((+)
            ((+)
                ((+)
                    0
                    1)
                2)
            3)
        4)
    5)

当我们强制结果时(例如,通过打印它),我们需要沿着这条链一路下降,以便能够在(+) 0 1 thunk 处开始评估它。

所以foldl 经常会为大型输入产生堆栈溢出,这就是为什么大多数时候你应该在需要左关联折叠时使用foldl'(这是严格的)。 foldl' 不是构建嵌套的 thunk 链,而是立即评估中间结果(0+1 = 11+2 = 33+3 = 6,...)。

【讨论】:

  • 注意foldl (+)的问题是+foldl的问题;因为+ 是完全严格的,所以最外层的+ 认为如果不强制在它的一个参数中输入thunk 就不能做任何事情,等等沿着整个链条。但是如果你给foldl 的操作是构建一个结构比整数更多的数据结构,它可能会返回一个构造函数而不强制整个链。您仍然会得到与列表单元格一样多的 thunk,因为 foldl 在消耗整个列表之前不会生成要返回的最外层,但它们不必破坏堆栈。
  • @Ben 我会用 1) 来扩展您的评论 1) 我会根据它的 生产性 来描述 +,而不是严格程度(尽管也许它们在这里是相同的),2)+ 可以如果我们正在处理 peano 数字,例如 :-) 3) 在这种情况下也值得指出(其中你传递了一个生产函数,而不是Ints 上的+foldr 仍然更好(如果可以使用),因为你避免了你提到的每个单元格一个thunk
  • @jberryman 好点。如果您的函数不是关联的,那么foldrfoldl 之间的选择是语义之一,而不仅仅是效率。一般来说,我宁愿先根据它进行选择,并且只跳过箍试图适合foldr,如果事实证明我需要,foldl 更自然,而不是将“foldr 更好”作为一揽子规则。大多数情况下,这是一种不必要的微优化(有时非常有必要让某些东西运行得完全可以接受)。
【解决方案2】:

在 Haskell 中不会像在 Java 等中那样发生堆栈溢出,因为求值和函数调用的发生方式不同。

在 Java 和 C 等类似语言中,函数调用是使用调用堆栈实现的。当你调用一个函数时,函数的所有局部变量都被分配到一个调用栈中,当函数结束时,函数从栈中弹出。嵌套调用太多,调用栈会溢出。

在 Haskell 中,函数调用不一定是这样工作的。在大多数编译器(如 GHC)中,Haskell 函数调用使用调用堆栈实现。它们是使用完全不同的过程实现的,使用堆上的 thunk 分配。

因此,大多数 haskell 实现一开始并没有为函数调用实现调用堆栈,因此堆栈溢出的想法是没有意义的。这就像在只有淋浴的更衣室里谈论浴缸溢出。

(GHC 确实使用调用堆栈进行 thunk 评估,但不用于函数调用。因此,可能发生的堆栈溢出与 Java、C 等的堆栈溢出具有完全不同且不相关的性质。)

【讨论】:

  • ghc 确实使用调用堆栈,但用于 thunk 评估而不是函数调用。见this answer
  • @luqui 谢谢;我知道这可能会导致混乱。编辑了我的答案以澄清这一点。
猜你喜欢
  • 2011-08-22
  • 1970-01-01
  • 2019-07-08
  • 2015-08-05
  • 1970-01-01
  • 1970-01-01
  • 2020-01-07
  • 1970-01-01
  • 2014-01-26
相关资源
最近更新 更多