【问题标题】:Stack overflow from recursive function call in LispLisp中递归函数调用的堆栈溢出
【发布时间】:2013-02-22 12:48:12
【问题描述】:

我正在从康拉德·巴尔斯基 (Conrad Barski) 的《Lisp 之国》一书中学习 Lisp。现在我遇到了我的第一个绊脚石,作者说:

这样称呼自己不仅在 Lisp 中是允许的,而且经常是 强烈鼓励

在展示了以下示例函数来计算列表中的项目之后:

(defun my-length (list)
  (if list
    (1+ (my-length (cdr list)))
    0))

当我使用包含一百万个项目的列表调用此函数 my-length 时,我收到堆栈溢出错误。因此,要么您永远不会期望在 Lisp 中有这么长的列表(所以也许我的用例没用),或者还有另一种方法可以计算如此长的列表中的项目。你能对此有所启发吗? (顺便说一下,我在 Windows 上使用 GNU CLISP)。

【问题讨论】:

标签: lisp common-lisp tail-recursion clisp land-of-lisp


【解决方案1】:

CLISP 中的 TCO(尾调用优化)使用 Chris Taylor 的示例:

[1]> (defun helper (acc list)
       (if list
           (helper (1+ acc) (cdr list))
           acc))

(defun my-length (list)
  (helper 0 list))

HELPER

现在编译它:

[2]> (compile 'helper)
MY-LENGTH
[3]> (my-length (loop repeat 100000 collect t))

*** - Program stack overflow. RESET

现在,上面不起作用。让我们将调试级别设置为低。这允许编译器进行 TCO。

[4]> (proclaim '(optimize (debug 1)))
NIL

再次编译。

[5]> (compile 'helper)
HELPER ;
NIL ;
NIL
[6]> (my-length (loop repeat 100000 collect t))
100000
[7]> 

工作。

允许 Common Lisp 编译器执行 TCO 通常由调试级别控制。在调试级别较高的情况下,编译器会为每个函数调用生成使用堆栈帧的代码。这样每个调用都可以被跟踪,并且可以在回溯中看到。使用较低的调试级别,编译器可以用编译代码中的跳转代替尾调用。然后这些 调用 将不会在回溯中看到 - 这通常会使调试变得更加困难。

【讨论】:

  • 我只是想知道为什么这不被接受为正确答案。如果信息很好,谢谢。
  • 借助这个,我计算了 100,000 的阶乘。
【解决方案2】:

您正在寻找尾递归。目前您的功能定义为

(defun my-length (list)
  (if list
    (1+ (my-length (cdr list)))
    0))

请注意,在my-length 调用自身之后,它需要在结果中加一,然后再将该值传递给它的调用函数。这需要在返回之前修改值意味着您需要为每次调用分配一个新的堆栈帧,所使用的空间与列表的长度成正比。这就是导致长列表堆栈溢出的原因。

你可以重写它以使用辅助函数

(defun helper (acc list)
  (if list
    (helper (1+ acc) (cdr list))
    acc))

(defun my-length (list)
    (helper 0 list))

辅助函数有两个参数,一个累加参数acc,它存储了到目前为止的列表长度,还有一个列表list,它是我们正在计算的列表长度的。

重要的一点是helper是递归写成tail,这意味着调用自己是它做的最后一件事。这意味着您不需要在每次函数调用自身时分配新的堆栈帧 - 因为最终结果无论如何都会一直传递回堆栈帧链,您可以避免覆盖旧的堆栈帧使用新的,因此您的递归函数可以在恒定空间中运行。

这种形式的程序转换 - 从递归(但非尾递归)定义到使用尾递归辅助函数的等效定义是函数式编程中的一个重要习惯用法 - 值得花一点时间理解.

【讨论】:

  • 谢谢,您已经展示了 Rolf 在他的回答中暗示的内容,但即使使用此代码(在 GNU Clisp 上),我仍然会遇到堆栈溢出。
  • 有趣。你有另一个可以尝试的通用 lisp 实现吗?这个page on tail call optimization in common lisp implementations 不清楚是否在 GNU Clisp 中执行了尾调用优化。
  • 可能有一个优化级别可以优化 clisp 中的尾递归,但谷歌没有返回任何关于它在 clisp 中如何工作的权威文档。
  • 感谢您提供的尾调用优化的漂亮而简单的示例。我经常听到这个词,但直到现在才开始理解它。这个例子非常简单明了,值得在 Wikipedia 页面上进行 tall 调用优化,imo。
【解决方案3】:

在 lisp 中创建递归函数来对递归数据结构进行操作确实有好处。并且列表(在 lisp 中)被定义为递归数据结构,所以你应该没问题。

但是,正如您所经历的,如果使用递归遍历数据结构一百万个深度,也会在堆栈上分配一百万帧,除非您特别要求运行时环境分配大量堆栈空间(我不知道您是否或如何在 gnu clisp 中执行此操作...)。

首先,我认为这表明列表数据结构并不是对所有事情都是最好的,在这种情况下,另一种结构可能会更好(但是,您可能没有在 lisp-book 中找到向量然而;-)

另一件事是,为了使像这样的大型递归有效,编译器应该优化尾递归并将它们转换为迭代。我不知道 clisp 是否具有此功能,但您需要更改您的功能才能真正优化。 (如果“尾递归优化”没有意义,请告诉我,我可以挖掘一些参考资料)

其他的迭代方式,请看:

或其他数据结构:

【讨论】:

  • 酷,非常感谢。对我来说:1)列表并不是最适合所有事物的,2)还有其他数据结构可供查看。我很想了解更多关于尾递归优化的知识,但也许在以后的阶段,当我已经掌握了基础知识时;-) 谢谢!
【解决方案4】:
(DEFUN nrelem(l) 
    (if (null l)
        0
       (+ (nrelem (rest l)) 1)
))

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-08-07
    • 2017-10-20
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-05-28
    • 2011-02-26
    相关资源
    最近更新 更多