在递归函数中处理多种可能的返回类型

问题描述

我正在使用 Racket ISL+ 编写一个计算一系列结构的递归。如果结构无法通过某些参数,我想返回 #false 的值。但是,在递归过程中的某个时刻,我知道计算机正在获取 1 + 1 + 1 + 1 + 1 + 1 + #false,这给了我一个错误

有没有办法让我声明,如果发现错误,只从原始调用中返回 #false 的值?

解决方法

当某个值从递归过程深处的某个过程返回时,接收该值的过程必须能够处理它。如果递归过程可以返回两种不同类型中的任何一种,例如布尔值或数字,则该过程将需要在使用需要单一类型的过程进行处理之前测试返回值。

使用延续

可能获得一个延续,然后用它来逃避递归过程。这可能不是 OP 正在寻找的解决方案;我不相信 HTDP 学生语言提供了一种获得延续的方法。在普通 Racket 中,您可以使用 call/cc 来获取延续:

(define (count-numbers-or-f xs)
  (call/cc
   (lambda (break)
     (define (loop xs)
       (cond ((null? xs) 0)
             ((number? (car xs))
              (+ 1 (loop (cdr xs))))
             (else (break #f))))
     (loop xs))))

这里不是开发延续细节的地方,但简而言之,call/cc 安排它使得上面代码中的 break 是一个转义过程,当被调用时,返回它的参数 ({ {1}} 此处)到与 #f 调用相关的计算的继续。这里,当输入的第一个元素是call/cc时,对其余输入的计数结果加1;但是,当输入的第一个元素不是 number?(并且输入不是空列表)时,会调用 number? 过程。在break描述的递归过程中调用break时,控制跳转到调用loop之后的那个延续,从而逃避递归过程;将值 call/cc 赋予延续,以便 #f 然后由 #f 返回。

做事没有延续

Continuations 是从循环中实现这种非本地退出的经典方法,但您可以获得类似的结果,通过一些仔细的设计,使用较少的外来手段:

count-numbers-or-f

这里,如果 (define (count-numbers-or-f-1 xs) (cond ((null? xs) 0) ((not (number? (car xs))) #f) (else (let ((r (count-numbers-or-f-1 (cdr xs)))) (if (number? r) (+ 1 r) #f))))) 的 car 不是数字(并且 xs 不是空列表),则将 xs 返回给前一个调用者。否则 #f 表示在 r 的 cdr 上调用 count-numbers-of-f-1 的结果。如果 xs 不是数字(因为后续调用者遇到非数字元素并返回 r),则返回 #f。否则,附加过程会增长。

这样做的结果是,如果遇到不是数字的元素,#f 会立即通过所有先前的堆栈帧传递回原始调用,否则进行求和。设计这样的程序很容易犯错误,使它们看起来可以工作,但最终会通过遍历所有输入(或更糟)来做很多不必要的工作。请参阅答案末尾以讨论此处可能出现的问题。

比较

以上定义都适用:

#f

这是第二个版本的一些痕迹:

scratch.rkt> (count-numbers-or-f '(1 2 3 4))
4
scratch.rkt> (count-numbers-or-f '(1 2 x 3 4))
#f
scratch.rkt> (count-numbers-or-f-1 '(1 2 3 4))
4
scratch.rkt> (count-numbers-or-f-1 '(1 2 x 3 4))
#f

可以看到,当遇到失败的元素时,递归立即结束,但控制权仍然要通过之前的堆栈帧传递回来。在使用 scratch.rkt> (count-numbers-or-f-1 '(1 2 3 4 5)) >(count-numbers-or-f-1 '(1 2 3 4 5)) > (count-numbers-or-f-1 '(2 3 4 5)) > >(count-numbers-or-f-1 '(3 4 5)) > > (count-numbers-or-f-1 '(4 5)) > > >(count-numbers-or-f-1 '(5)) > > > (count-numbers-or-f-1 '()) < < < 0 < < <1 < < 2 < <3 < 4 <5 5 scratch.rkt> (count-numbers-or-f-1 '(1 2 3 x 4 5)) >(count-numbers-or-f-1 '(1 2 3 x 4 5)) > (count-numbers-or-f-1 '(2 3 x 4 5)) > >(count-numbers-or-f-1 '(3 x 4 5)) > > (count-numbers-or-f-1 '(x 4 5)) < < #f ; returned from (count-numbers-or-f-1 '(x 4 5)) < <#f ; returned from (count-numbers-or-f-1 '(3 x 4 5)) < #f ; returned from (count-numbers-or-f-1 '(2 3 x 4 5)) <#f ; returned from (count-numbers-or-f-1 '(1 2 3 x 4 5)) #f ; the actual value printed to the REPL 的版本中,控件只是从递归过程中逃脱,而不通过任何先前的帧。

这是使用 call/cc 的第一个过程的一个版本,它被修改以便可以跟踪递归过程:

call/cc

这里有一个跟踪显示,当返回 (define (count-numbers-or-f xs) (call/cc (lambda (k) (loop xs k)))) (define (loop xs break) (cond ((null? xs) 0) ((number? (car xs)) (+ 1 (loop (cdr xs) break))) (else (break #f)))) 时,它不经过前面的堆栈帧而直接返回。当遇到 #f 时,loop 过程根本不返回;而是 #f 调用 loop 过程,将 break 传递给它。 #f 过程跳转到计算的继续,返回 break 到那个点。由于延续在 #f 过程结束时进行计算,因此 count-numbers-or-f 只是从 #f 返回。

count-numbers-or-f

什么地方会出错?

设计良好的递归过程在这里可以做得很好,但很容易实现设计不佳的解决方案,这可能会导致严重的性能问题。

本节是关于展示如何在经过深思熟虑的设计时偏离正轨。下面的所有过程都可以返回正确的结果,但它们具有不同的性能特征,其中最糟糕的是实际上无法使用。结论应该是,至少在一系列预期输入上测试新程序以检查意外情况很重要。第二个要点可能是应该仔细考虑过程定义中的多次递归调用。一个很好的辅助测试工具是 scratch.rkt> (count-numbers-or-f '(1 2 3 x 4 5)) >(count-numbers-or-f '(1 2 3 x 4 5)) >(loop '(1 2 3 x 4 5) #<procedure>) > (loop '(2 3 x 4 5) #<procedure>) > >(loop '(3 x 4 5) #<procedure>) > > (loop '(x 4 5) #<procedure>) ; loop does not return <#f ; the value returned by (count-numbers-or-f '(1 2 3 x 4 5)) #f ; the actual value printed to the REPL ;在 Racket 中,您可以 trace 然后 (require racket/trace) 检测 (trace my-procedure),以便对 my-procedure 的任何调用都使用过程跟踪进行注释。这就是这个答案中的痕迹是如何生成的。

我们已经在上面看到了 my-procedure 的踪迹,表明当遇到失败的元素时,count-numbers-or-f-1 会被传递回之前的调用者,直到到达原始调用为止。

考虑这个实施不佳的版本:

#f

这里,由于在检查计算的剩余部分的结果是否为 (define (count-numbers-or-f-2 xs) (if (null? xs) 0 (let ((x (car xs)) (r (count-numbers-or-f-2 (cdr xs)))) (if (and (number? x) (number? r)) (+ 1 r) #f)))) 之前调用了 count-numbers-or-f-2,因此递归过程将继续,直到达到基本情况 number?。当遇到失败的输入时,这根本没有帮助:

(null? xs)

现在考虑这个试图解决最后一个问题的真正可怕的实现。在将其添加到最终结果之前,这里会检查其余计算的结果。这在输入包含失败元素时有效;但在计算整个输入的情况下,scratch.rkt> (count-numbers-or-f-2 '(1 2 3 x 4 5)) >(count-numbers-or-f-2 '(1 2 3 x 4 5)) > (count-numbers-or-f-2 '(2 3 x 4 5)) > >(count-numbers-or-f-2 '(3 x 4 5)) > > (count-numbers-or-f-2 '(x 4 5)) > > >(count-numbers-or-f-2 '(4 5)) > > > (count-numbers-or-f-2 '(5)) > > > >(count-numbers-or-f-2 '()) < < < <0 < < < 1 < < <2 < < #f < <#f < #f <#f #f 被调用两次,导致指数时间复杂度。

count-numbers-or-f-3

如果输入失败,事情看起来不错:

(define (count-numbers-or-f-3 xs)
  (if (null? xs)
      0
      (if (and (number? (car xs))
               (number? (count-numbers-or-f-3 (cdr xs))))
          (+ 1 (count-numbers-or-f-3 (cdr xs)))
          #f)))

但是对于应该计算的输入,事情会变得非常错误。事实上,这是错误的,我不得不使用较小的输入来在合理的空间内说明过程:

scratch.rkt> (count-numbers-or-f-3 '(1 2 3 x 4 5))
>(count-numbers-or-f-3 '(1 2 3 x 4 5))
> (count-numbers-or-f-3 '(2 3 x 4 5))
> >(count-numbers-or-f-3 '(3 x 4 5))
> > (count-numbers-or-f-3 '(x 4 5))
< < #f
< <#f
< #f
<#f
#f

这里有一个程序可以用来对上述程序计时:

>(count-numbers-or-f-3 (1 2 3))
> (count-numbers-or-f-3 '(2 3))
> >(count-numbers-or-f-3 '(3))
> > (count-numbers-or-f-3 '())
< < 0
> > (count-numbers-or-f-3 '())
< < 0
< <1
> >(count-numbers-or-f-3 '(3))
> > (count-numbers-or-f-3 '())
< < 0
> > (count-numbers-or-f-3 '())
< < 0
< <1
< 2
> (count-numbers-or-f-3 '(2 3))
> >(count-numbers-or-f-3 '(3))
> > (count-numbers-or-f-3 '())
< < 0
> > (count-numbers-or-f-3 '())
< < 0
< <1
> >(count-numbers-or-f-3 '(3))
> > (count-numbers-or-f-3 '())
< < 0
> > (count-numbers-or-f-3 '())
< < 0
< <1
< 2
<3
3

我使用了一个包含 800,001 个号码的列表进行测试,并在列表中间放置了一个 (define (time-test f xs) (collect-garbage 'major) (time (f xs))) 。使用延续的 'x 表现最好:

count-numbers-or-f

与在遇到失败输入时结束递归的 scratch.rkt> (time-test count-numbers-or-f (append (build-list 400000 values) '(x) (build-list 400000 values))) cpu time: 1 real time: 1 gc time: 0 #f 相比,第一个版本由于使用延续立即转义而更快一些:

count-numbers-or-f-1

即使遇到失败的输入也会遍历所有输入的错误版本更糟:

scratch.rkt> (time-test count-numbers-or-f-1
                        (append (build-list 400000 values)
                                '(x)
                                (build-list 400000 values)))
cpu time: 5 real time: 5 gc time: 0
#f

看起来更好的非转义实现 scratch.rkt> (time-test count-numbers-or-f-2 (append (build-list 400000 values) '(x) (build-list 400000 values))) cpu time: 28 real time: 28 gc time: 9 #f count-numbers-or-f-1(使用延续)慢约 5 倍,而实现不佳的 count-numbers-or-fcount-numbers-of-f-2 慢约 28 倍count-numbers-or-f。但至少所有这些实现的时间复杂度都大致O(n)。最糟糕的版本 count-numbers-or-f-3O(2ⁿ)。 Universe 中没有足够的时间来等待此过程对 800,001 个元素的列表进行操作。我们将不得不花时间使用很多较小的输入:

scratch.rkt> (time-test count-numbers-or-f-6 (build-list 25 values))
cpu time: 576 real time: 576 gc time: 0
25
scratch.rkt> (time-test count-numbers-or-f-6 (build-list 26 values))
cpu time: 1159 real time: 1159 gc time: 0
26
scratch.rkt> (time-test count-numbers-or-f-6 (build-list 27 values))
cpu time: 2314 real time: 2314 gc time: 0
27

在这里你可以看到,在最坏的情况下,将输入列表的大小增加 1 个元素大约 双倍完成计算的时间,而那些最坏的情况正是你想要的那些情况要完成的计算,即当您想要计算列表中有多少个数字时。