在Racket的递归中堆栈溢出

时间:2016-09-02 12:37:18

标签: recursion lisp racket

作为函数式编程的一部分,在Racket中高度推广了递归。但是,堆栈溢出是递归时通常提到的一个重要问题。在Racket中是否存在可能发生堆栈溢出的情况以及应采取哪些预防措施来防止此类事件发生?

3 个答案:

答案 0 :(得分:5)

没有。你永远不会在Racket中获得堆栈溢出。这是因为Racket VM并不真正在OS级别调用堆栈中存储内存。但是,你可以做的就是耗尽你所有的机器内存。您可以通过使用要求Racket VM持续存储越来越多空间的功能来实现此目的。例如:

(define (f)
  (define x (random))
  (f)
  x)

在此函数中,Racket需要在开始返回之前存储无限数量的随机x值,这将导致VM耗尽内存。

另一方面,如果你在函数中交换两行:

(define (f)
  (f)
  (define x (random))
  x)

您的功能仍然永远不会终止,并且内存耗尽也会花费更长的时间。这是因为VM只需记住在完成之前返回之前对f的调用,但不需要为x存储空间。

最后,如果我们有这个功能:

(define (f)
  (define x (random))
  x
  (f))

该函数永远不会终止,但它也永远不会耗尽内存。这是因为它为x分配了一个空间,但在递归调用f时能够删除该空间。此外,因为递归调用是函数执行的最后一件事,它也不再需要将原始调用存储到f,这意味着每个递归函数调用都不需要新的空间。这称为尾调用消除。 1 实际上,这最后一个函数相当于C或Java中的无限循环。

1 请注意,有些人错误地调用了此尾调用优化。这不是优化,因为它是语言核心语义的一部分。将其称为“优化”与将Java的GC称为“优化”一样错误。

答案 1 :(得分:1)

堆栈溢出通常是错误递归的问题,或者通常称为无限递归的问题。所以首先你要解决这个问题。

其次,如果你的递归是以这样一种方式编写的,即除了return之外的任何递归调用之后没有任何事情要做,你就会得到所谓的尾递归,它可以通过解释器/编译器重新使用当前的堆栈帧,从而消除堆栈溢出的可能性(至少从那个原因)。这并非总是可行,但如果就业可能会是一个巨大的胜利。

例如:

  • 这会产生堆栈溢出:

    (define (f n) 
        (+ 1 (f (- n 1)))
    
  • 这不会,但永远不会终止:

    (define (f n) 
        (f n))
    
  • 这不会有任何问题:

    (define (f n) 
        (if (<= n 0) 
            n 
            (f (- n 1))))
    

答案 2 :(得分:1)

我认为谈论堆栈溢出有点令人困惑,因为它可能是实现实际上并没有使用堆栈,或者将堆栈存储在堆上或其他东西上(我认为Racket至少有一个不是这两个)。

更有用的是询问计算所需的存储如何针对该计算的参数的不同值而改变。天真地看起来,如果你递归地指定一个计算,存储确实会增长,至少和递归的深度一样快。

但实际上并非如此。考虑这样的函数:

(define (complicated n)
  (let ((y (f n)))
    ...
    (g y)))

特别考虑对fg的调用:

  • 当调用f时,系统需要记住自己需要返回complicated才能完成更多工作。
  • 但是当调用g时,它不会:g的调用是complicated执行的最后一件事,它的返回值只是从{{ 1}}(注意complicated此时绑定并不重要:当调用y时,永远不会再次引用绑定。)

好吧,很久以前人们开始编写利用这个的Lisp实现,并优化了这种“尾部”调用,从而实现了一种更好的编程方法,你不需要把所有东西都变成循环时间,因为系统会为你做。

一个好的(非常传统的)例子是有点像阶乘函数的函数:自然递归定义的东西。而不是阶乘(通常产生巨大的数字,这是一个难以打印)考虑函数s,定义为:

  • s(0)= 0;
  • s(n)= n + s(n - 1)

(当然,这非常像阶乘)。

在传统的非尾部调用优化Lisp中,如果要为大的n值计算它,则必须将其转换为循环(此示例为Common Lisp):

g

但是在优化尾调用的系统中,您可能会尝试递归地编写它:只需遵循定义。

(defun s (n)
  (let ((sum n))
    (dotimes (i n sum)
      (incf sum i))))

现在你得到一个令人讨厌的惊喜:它耗尽了存储空间(在我的Racket上,它在(define (s n) (if (= n 0) n (+ n (s (- n 1))))) 1000之间的某个地方死亡,我还没有确切地检查过哪里。好吧,很容易理解为什么它的存储空间不足:对1000000的调用不是尾调用,因为在它返回后结果仍然需要添加s。所以这是一个真正的递归调用,它需要存储。

但是你可以通过考虑s的替代定义将事物变成尾调用:

  • s(n)= l(n,0)
  • l(0,a)= a
  • l(m,a)= 1(m-1,a + m)

您可以将其转换为代码:

n

很容易看到对(define (s n) (define (l m a) (if (= m 0) a (l (- m 1) (+ a m)))) (l n 0)) 的所有调用现在都是尾调用,实际上这个版本的l对它的参数的更大值非常高兴(它最终开始大量使用bignums并因此变得相当慢,但它会在没有耗尽存储的情况下运行。)

这一切都很好,除了我没有定义何时调用是一个尾调用:我已经说'显而易见'和类似的东西,但我没有坐下来编写规范。如果我希望尾部调用被作为语言的一部分而不是作为可能发生或可能不发生的编译器优化而被消除,那么我最好这样做。

嗯,这就是Scheme(以及Scheme,Racket)所做的:语言本身指定哪些调用是尾调用,并说这些调用不使用存储。它是怎么回事涉及语言语义的毛茸茸的问题,我(作为一个老式的Lisp黑客)并不真正理解:一个起点也许就在这里。