【问题标题】:Can a tail recursive function still get stack overflow?尾递归函数仍然会出现堆栈溢出吗?
【发布时间】:2020-06-18 22:41:00
【问题描述】:

我一直在 codesignal.com 解决一些挑战,使用 C-Lisp 来学习它,我一直在避免使用循环来制作 lisp 风格的代码。

在这个名为alternatingSums 的挑战中(它为您提供一个可能非常大的int 数组a,并要求您返回一个数组/列表{sumOfEvenIndexedElements,sumOfOddIndexedElements}),我收到以下代码的堆栈溢出错误:


(defun alternatingSums(a &optional (index 0) (accumulated '(0 0)))

    (cond ((= index (length a)) 
                accumulated)
          ((evenp index)
                (alternatingSums 
                  a
                  (1+ index)
                 `(,(+ (svref a index ) (elt accumulated 0)) ,(elt accumulated 1))) 
          )
          ((oddp index)
                (alternatingSums 
                  a
                  (1+ index)
                 `(,(elt accumulated 0) ,(+ (svref a index ) (elt accumulated 1))))
          )
    )

)

它不是尾递归还是尾递归函数仍然会导致堆栈溢出?

【问题讨论】:

  • 请注意,CLISP 仅允许尾部自调用优化,这是 TCO 的一种特殊情况,仅优化自调用,而不是完全尾部调用优化。看到这个article
  • @Kaz 我确实看到那个帖子链接了,但是那个人的代码中有错字,他甚至说他很抱歉在那里发布。正如他在对其中一个答案的评论中所说,他的 addup3 调用了一个通过循环实现的 addup

标签: lisp common-lisp tail-recursion


【解决方案1】:

从尾部位置调用自身的递归函数会导致堆栈溢出;语言实现必须支持某种形式的tail call elimination 以避免该问题。

我一直避免使用循环来制作 lisp 风格的代码。

Common Lisp 不要求实现消除尾调用,但 Scheme 实现必须这样做。在 Scheme 中使用递归进行迭代是惯用的,但在 Common Lisp 中使用其他迭代设备是惯用的,除非递归为手头的问题提供了自然的解决方案。

尽管 Common Lisp 实现不需要进行尾调用消除,但许多实现。 Clisp 确实支持有限的尾调用消除,但仅在编译代码中,并且仅用于自递归尾调用。这没有很好的记录,但是there is some discussion to be found here@Renzo。 OP 发布的代码在 Clisp 中编译时将受到尾调用消除的影响,因为函数 alternatingSums 从尾部位置调用自身。这涵盖了您可能对尾调用消除感兴趣的大多数情况,但请注意,对于 Clisp 中的相互递归函数定义,尾调用消除完成。有关示例,请参阅此答案的末尾。

从 REPL 定义函数,或从源文件加载定义,将产生解释代码。如果你在像 SLIME 这样的开发环境中工作,它很容易编译:从源文件缓冲区执行 Ctrl-c Ctrl- k 编译整个文件并将其发送到 REPL,或者将点放在函数定义内部或之后,然后执行 Ctrl-c Ctrl-c 编译单个定义并将其发送到 REPL。

您还可以在加载源文件之前对其进行编译,例如(load (compile-file "my-file.lisp"))。或者你可以加载源文件,然后编译一个函数,例如(load "my-file.lisp"),然后是(compile 'my-function)

如前所述,惯用的 Common Lisp 代码很可能不会对此类函数使用递归。下面是使用loop 宏的定义,有些人会发现它更清晰简洁:

(defun alternating-sums (xs)
  (loop for x across xs
     and i below (length xs)
     if (evenp i) sum x into evens
     else sum x into odds
     finally (return (list evens odds))))

Clisp 中相互递归函数的案例

这是一对简单的相互递归函数定义:

(defun my-evenp (n)
  (cond ((zerop n) t)
        ((= 1 n) nil)
        (t (my-oddp (- n 1)))))

(defun my-oddp (n)
  (my-evenp (- n 1)))

这两个函数都没有直接调用自己,但是my-evenp 在尾部位置调用了my-oddp,而my-oddp 在尾部位置调用了my-evenp。人们希望消除这些尾调用以避免因大量输入而导致堆栈崩溃,但 Clisp 并没有这样做。这是反汇编:

CL-USER> (disassemble 'my-evenp)

Disassembly of function MY-EVENP
14 byte-code instructions:
0     (LOAD&PUSH 1)
1     (CALLS2&JMPIF 172 L16)              ; ZEROP
4     (CONST&PUSH 0)                      ; 1
5     (LOAD&PUSH 2)
6     (CALLSR&JMPIF 1 47 L19)             ; =
10    (LOAD&DEC&PUSH 1)
12    (CALL1 1)                           ; MY-ODDP
14    (SKIP&RET 2)
16    L16
16    (T)
17    (SKIP&RET 2)
19    L19
19    (NIL)
20    (SKIP&RET 2)

CL-USER> (disassemble 'my-oddp)

Disassembly of function MY-ODDP
3 byte-code instructions:
0     (LOAD&DEC&PUSH 1)
2     (CALL1 0)                           ; MY-EVENP
4     (SKIP&RET 2)

与调用自身的尾递归函数进行比较。这里在反汇编中没有调用factorial,而是插入了一条跳转指令:(JMPTAIL 2 5 L0)

(defun factorial (n acc)
  (if (zerop n) acc
      (factorial (- n 1) (* n acc))))
CL-USER> (disassemble 'factorial)

Disassembly of function FACTORIAL
11 byte-code instructions:
0     L0
0     (LOAD&PUSH 2)
1     (CALLS2&JMPIF 172 L15)              ; ZEROP
4     (LOAD&DEC&PUSH 2)
6     (LOAD&PUSH 3)
7     (LOAD&PUSH 3)
8     (CALLSR&PUSH 2 57)                  ; *
11    (JMPTAIL 2 5 L0)
15    L15
15    (LOAD 1)
16    (SKIP&RET 3)

一些 Common Lisp 实现确实支持相互递归函数的尾调用消除。这是从 SBCL 中对my-oddp 的反汇编:

;; SBCL
; disassembly for MY-ODDP
; Size: 40 bytes. Origin: #x52C8F9E4                          ; MY-ODDP
; 9E4:       498B4510         MOV RAX, [R13+16]               ; thread.binding-stack-pointer
; 9E8:       488945F8         MOV [RBP-8], RAX
; 9EC:       BF02000000       MOV EDI, 2
; 9F1:       488BD3           MOV RDX, RBX
; 9F4:       E8771B37FF       CALL #x52001570                 ; GENERIC--
; 9F9:       488B5DF0         MOV RBX, [RBP-16]
; 9FD:       B902000000       MOV ECX, 2
; A02:       FF7508           PUSH QWORD PTR [RBP+8]
; A05:       E9D89977FD       JMP #x504093E2                  ; #<FDEFN MY-EVENP>
; A0A:       CC10             INT3 16                         ; Invalid argument count trap

这比前面的示例更难阅读,因为 SBCL 编译为汇编语言而不是字节码,但您可以看到跳转指令已替换为对 my-evenp 的调用:

; A05:       E9D89977FD       JMP #x504093E2                  ; #<FDEFN MY-EVENP>

【讨论】:

  • 请注意,CLISP 不支持完整的尾调用优化(参见article)。
  • @exnihilo:如果有人告诉我他们的实现支持尾调用消除,我会假设所有尾调用都被消除了。我会更加警惕“尾递归消除”,我认为这可能只意味着对同一函数的递归调用(换句话说,自我调用)。所以我的解释与你的相反。我来自传统的 CL 背景。
  • @exnihilo 非常感谢您的回答,它在许多不同的层面都非常有启发性
【解决方案2】:

普通 Lisp 编译器不需要优化尾调用。许多人这样做,但并非所有实现都默认编译您的代码;您必须使用compile-file 编译文件,否则使用(compile 'alternatingsums) 单独编译该函数。

CLISP 包含一个解释器,它处理 Lisp 源代码的嵌套列表表示,以及一个字节码编译器。编译器支持尾递归,而解释器不支持:

$ clisp -q
[1]> (defun countdown (n) (unless (zerop n) (countdown (1- n))))
COUNTDOWN
[2]> (countdown 10000000)

*** - Program stack overflow. RESET
[3]> (compile 'countdown)
COUNTDOWN ;
NIL ;
NIL
[4]> (countdown 10000000)
NIL

稍微窥探一下:

[5]>(反汇编'倒计时)

Disassembly of function COUNTDOWN
1 required argument
0 optional arguments
No rest parameter
No keyword parameters
8 byte-code instructions:
0     L0
0     (LOAD&PUSH 1)
1     (CALLS2&JMPIF 172 L10)              ; ZEROP
4     (LOAD&DEC&PUSH 1)
6     (JMPTAIL 1 3 L0)
10    L10
10    (NIL)
11    (SKIP&RET 2)
NIL

我们可以看到虚拟机有一个JMPTAIL原语。

尾调用的另一种方法是通过宏。几年前,我破解了一个macro called tlet,它可以让你使用类似于 labels 的语法定义(看起来像)词法函数。 tlet 构造编译为 tagbody 形式,其中函数之间的尾部调用是 go 形式。它不分析处于尾部位置的调用:所有调用都是无条件传输,无论它们在语法中的位置如何,它们都不会返回。同一个源文件还提供了一个基于蹦床的全局函数之间的尾调用实现。

这是 CLISP 中的tlet;注意:表达式尚未编译,但它没有用完堆栈:

$ clisp -q -i tail-recursion.lisp 
;; Loading file tail-recursion.lisp ...
;; Loaded file tail-recursion.lisp
[1]> (tlet ((counter (n) (unless (zerop n) (counter (1- n)))))
       (counter 100000))
NIL

tlet 不是优化器。对counter 的调用在语义上总是一个goto;在适当的情况下,它不是有时会变成 goto 的过程调用。观察当我们添加 print 时会发生什么:

[2]> (tlet ((counter (n) (unless (zerop n) (print (counter (1- n))))))
       (counter 100000))
NIL

没错;没有! (counter (1- n)) 永远不会返回,因此永远不会调用 print

【讨论】:

  • 请注意,CLISP 不支持完整的尾调用优化(请参阅article)。
  • @Renzo 你在那里写的非常有趣的文章。
  • @MauricioRodriguez ,我不是作者。我通过这个link找到它。
猜你喜欢
  • 1970-01-01
  • 2020-03-06
  • 1970-01-01
  • 2021-04-19
  • 2017-10-20
  • 2013-06-16
  • 2017-06-18
  • 1970-01-01
相关资源
最近更新 更多