【问题标题】:Are programs in functional languages more likely to have stack overflows?函数式语言的程序更容易发生堆栈溢出吗?
【发布时间】:2025-12-09 22:45:02
【问题描述】:

我开始学习 ocaml,并且非常欣赏该语言中递归的强大功能。但是,我担心的一件事是堆栈溢出。

如果ocaml使用堆栈进行函数调用,最终不会溢出堆栈吗?例如,如果我有以下功能:

let rec sum x =
  if x > 1 then f(x - 1) + x
  else x;;

它最终一定会导致堆栈溢出。如果我要在 c++ 中做同样的事情(使用递归),我知道它会溢出。

所以我的问题是,是否有内置的保护措施来防止函数式语言溢出堆栈?如果不是,它们是不是像这样不太有用,因为上面的求和算法,以带有 for 循环的程序风格编写,可以处理任何数字(不考虑整数溢出)?

【问题讨论】:

  • 嘿!这是网站的名称。

标签: functional-programming stack-overflow tail-call-optimization imperative-programming


【解决方案1】:

所有(体面的实现;-)函数式语言都优化了尾递归,但这不是您在这里所做的,因为递归调用不是最后一个操作(它需要跟在加法之后)。

因此,人们很快就会学会使用一个尾递归的辅助函数(并将当前累积的总数作为参数),这样优化器就可以完成它的工作,即排除可能的 O'Caml 语法,其中 I'我生锈了:

let sum x =
  aux(x)(0);;

let rec aux x accum =
  if x > 1 then aux(x - 1)(accum + x)
  else (accum + x);;

在这里,总和作为递归调用的参数发生,即在递归本身之前,因此可以启动尾部优化(因为递归是需要发生的最后一件事!)。

【讨论】:

  • OCaml 不使用逗号来分隔其函数参数。它使用空格。任何作为表达式的参数都需要用括号括起来。
  • 是的,我认为你的代码应该是if x > 1 then aux (x -1) (accum + x)
  • 但是 +1 用于展示如何转换为尾递归函数(现在 that 真的让我大吃一惊 - 函数式编程很难让我按照我的方式理解已经写好了)
  • @a_m0d, tx 用于语法更正,将进行编辑以修复它(正如我所说,我对 O'Caml 语法很生疏——例如,如何使用两个参数调用函数——但是函数式编程的关键思想被深入研究;-)。
  • @a_m0d 再次,是的,本质上:当且仅当尾调用是“尾”,即调用者中的最后一个操作时,才能优化尾调用;这通常可以通过带有类似“累加器”的参数的辅助函数来获得,但并非总是如此。
【解决方案2】:

函数式语言的堆栈通常要大得多。例如,我已经编写了一个专门用于测试 OCaml 中的堆栈限制的函数,并且在它被吐出之前它得到了超过 10,000 次调用。但是,您的观点是有效的。堆栈溢出仍然是函数式语言中需要注意的问题。

函数式语言用来减轻对递归的依赖的策略之一是使用tail-call optimization。如果对当前函数的下一个递归的调用是函数中的最后一条语句,则可以从堆栈中丢弃当前调用,并在其位置实例化新调用。生成的汇编指令将与命令式的while循环基本相同。

您的函数不是尾调用可优化的,因为递归不是最后一步。它需要先返回,然后才能将 x 添加到结果中。通常这很容易解决,您只需创建一个辅助函数,将累加器与其他参数一起传递

let rec sum x =
  let sum_aux accum x =
    if x > 1 then sum_aux (accum + x) (x - 1)
    else x
  in sum_aux 0 x;;

【讨论】:

  • 函数式语言没有“更大的堆栈”。堆栈的大小(对于本机代码可执行文件)由操作系统定义。 OCaml 可以进行更多的递归调用,这可能是因为 ABI - 默认情况下参数在寄存器中传递,因此使用的堆栈空间更少。我相信你可以通过 fastcall 调用约定在 C 中实现同样的效果。
  • 感谢指正!我认为某些版本的 Windows(我几年前接触过)需要/允许在链接时指定估计的堆栈大小。有一个合理的默认值,但如果您的程序使用大量递归或具有较大堆栈帧的函数,您需要请求更大的堆栈。我想我只是假设这是事情的工作方式,并且 OCaml 必须设置为默认请求更大的堆栈,但显然更好的操作系统没有这个限制。直到现在我才考虑重新检查调用堆栈的工作方式。谢谢!
【解决方案3】:

Scheme等一些函数式语言指定tail recursion必须优化为等效于迭代;因此,Scheme 中的尾递归函数永远不会导致堆栈溢出,无论它递归多少次(当然,假设它在结尾之外的其他地方也不会递归或参与相互递归)。

大多数其他函数式语言不需要尾递归即可有效实现;有些选择这样做,有些则不这样做,但它相对容易实现,所以我希望大多数实现都这样做。

【讨论】:

  • 我相信这在 F# 中也是如此,因为检查发出的 IL 将显示创建了一个普通循环。见thevalerios.net/matt/2009/01/…
  • 其中一些可能需要你写“x + f(x - 1)”而不是“f(x - 1) + x”才能被认为是尾递归,对吧?
  • 这样写仍然不会是尾递归的。
  • @RobertHarvey:是的,F# 编译器将 tail recursion 变成一个循环,而且 tail calls 发出 IL .tail 操作码,它执行 TCO . (注意:在 'debug' 模式下,尾调用默认关闭,以便保留堆栈以进行调试;请参阅 --tailcalls 编译器标志。)
【解决方案4】:

对于新手来说,编写破坏堆栈的深度递归当然很容易。 Objective Caml 的不寻常之处在于List 函数对于长列表不是堆栈安全的。像Unison 这样的应用程序实际上已经用堆栈安全版本替换了Caml 标准List 库。大多数其他实现在堆栈方面做得更好。 (免责声明:我的信息描述的是 Objective Caml 3.08;当前版本 3.11 可能更好。)

Standard ML of New Jersey 的不寻常之处在于它不使用堆栈,因此您的深度递归会继续进行,直到您用完堆为止。在 Andrew Appel 的优秀书籍Compiling with Continuations 中有描述。

我认为这里没有严重的问题;这更像是一个“意识点”,如果你要编写大量递归代码,你更有可能用函数式语言来做,你必须注意非尾调用和堆栈大小与您将要处理的数据的大小相比。

【讨论】:

    【解决方案5】:

    这很棘手——原则上是的,但函数式语言的编译器和运行时解释了函数式语言中递归程度的增加。最基本的是,大多数函数式语言运行时请求的堆栈比普通迭代程序使用的堆栈要大得多。但除此之外,由于语言的更严格的限制,函数式语言编译器更能够将递归代码转换为非递归代码。

    【讨论】: