递归期间在方案中创建封闭过程的性能影响

问题描述

我正在阅读The Little Schemer这本书,开始学习用 Lisp 思考。当您进入它并真正涵盖 lambda 的使用时,“删除”过程以以下一般形式编写,它返回任意测试 test?删除过程:

(define rember-f
  (lambda (test?)
    (lambda (a l)
      (cond
        ((null? l) (quote ()))
        ((test? (car l) a) (cdr l))
        (else (cons (car l)
                ((rember-f test?) a (cdr l))))))))

我理解它的工作原理,但简单的阅读它表明在每个递归步骤中,再次调用过程rember-f生成封闭过程.这意味着当您在列表上调用返回的过程时,它会调用 rember-f 再次anew 生成相同的过程,然后 new 就是所谓的递归(如果不清楚,请参阅下面的修复程序)。我知道这可能会被优化掉,但代替不知道它是否是(以及无论如何也试图让我了解这个语法),我在一些实验后设法将递归移动到过程本身而不是封闭程序如下:

(define rember-f
  (lambda (test?)
    (define retfun
      (lambda (a l)
        (cond
          ((null? l) (quote ()))
          ((test? (car l) a) (cdr l))
          (else (cons (car l) (retfun a (cdr l)))))))
    retfun))

我已经验证这可以按预期工作。返回值是删除与值 (arg 1) 匹配的列表 (arg 2) 的第一个元素的过程。在我看来,这个程序只调用 rember-f 一次,这保证它只生成一个封闭的过程(这次使用名称 retfun)。

这对我来说实际上很有趣,因为与通常的尾调用优化不同,尾调用优化不消耗调用堆栈上的空间,因此递归与迭代一样有效,在这种情况下,编译器必须确定 {{1 }} 是未修改的封闭过程范围,因此将其替换为相同的返回值,即匿名 (rember-f test?)。得知解释器/编译器没有捕捉到这一点,我一点也不感到惊讶。

是的,我知道该方案是一种规范,并且有许多实现,可以在不同程度上获得各种函数式编程优化。我目前正在通过在 guile REPL 中进行实验来学习,但对不同实现在这个问题上的比较感兴趣。

有谁知道在这种情况下,Scheme 应该如何表现?

解决方法

两个过程具有相同的渐近时间复杂度。让我们考虑对 ((rember-f =) 1 '(5 4 3 2 1 0)) 的评估。

部分评估按如下方式进行:

((rember-f =) 1 '(5 4 3 2 1 0))
((lambda (a l)
      (cond
        ((null? l) (quote ()))
        ((= (car l) a) (cdr l))
        (else (cons (car l)
                ((rember-f =) a (cdr l)))))) 1 '(5 4 3 2 1 0))
(cons 5 ((rember-f = 1 '(4 3 2 1 0))))

请注意,临时 lambda 过程的创建需要 O(1) 时间和空间。所以它实际上并没有给调用函数的成本增加任何实质性的开销。最好的情况是,分解该函数将导致常数因子加速并使用常数更少的内存。

但是关闭一个真正需要多少内存?事实证明它只需要很少的内存。一个闭包由一个指向环境的指针和一个指向编译代码的指针组成。基本上,创建闭包所需的时间和空间与创建 cons 单元格一样多。因此,尽管在我展示评估时看起来我们使用了大量内存,但实际上用于创建和存储 lambda 的内存和时间非常少。

因此,本质上,通过分解递归函数,您分配了一个 cons 单元,而不是编写代码为每次递归调用分配该 cons 单元一次。

有关这方面的更多信息,请参阅 Lambda is cheap,and Closures are Fast

,

您担心额外重复的 lambda 抽象是正确的。例如,你不会写这个,对吗?

(cond ((> (some-expensive-computation x) 0) ...)
      ((< (some-expensive-computation x) 0) ...)
      (else ...))

相反,我们将 some-expensive-computation 的结果绑定到一个标识符,以便我们可以检查同一值的多个条件 -

(let ((result (some-expensive-computation x)))
 (cond ((> result 0) ...)
       ((< result 0) ...)
       (else ...)))

您发现了所谓的“命名 let”表达式的基本用途。这是你的程序 -

(define rember-f
  (lambda (test?)
    (define retfun
      (lambda (a l)
        (cond
          ((null? l) (quote ()))
          ((test? (car l) a) (cdr l))
          (else (cons (car l) (retfun a (cdr l)))))))
    retfun))

和它的等价物使用命名的let 表达式。下面我们将 let 主体绑定到 loop,这是一个允许主体递归的可调用过程。请注意 lambda 抽象如何仅使用一次,并且可以重复内部 lambda,而无需创建/评估额外的 lambda -

(define rember-f
  (lambda (test?)
    (lambda (a l)
      (let loop ; name,"loop",or anything of your choice
       ((l l))  ; bindings,here we shadow l,or could rename it
       (cond
         ((null? l) (quote ()))
         ((test? (car l) a) (cdr l))
         (else (cons (car l) (loop (cdr l))))))))) ; apply "loop" with args

让我们运行它-

((rember-f eq?) 'c '(a b c d e f))
'(a b d e f)

named-let 的语法是 -

(let proc-identifier ((arg-identifier initial-expr) ...)
  body ...)

Named-let 是 letrec 绑定的语法糖 -

(define rember-f
  (lambda (test?)
    (lambda (a l)
      (letrec ((loop (lambda (l)
                       (cond
                         ((null? l) (quote ()))
                         ((test? (car l) a) (cdr l))
                         (else (cons (car l) (loop (cdr l))))))))
        (loop l)))))
((rember-f eq?) 'c '(a b c d e f))
'(a b d e f)

同样,您可以想象使用嵌套的 define -

(define rember-f
  (lambda (test?)
    (lambda (a l)
      (define (loop l)
        (cond
          ((null? l) (quote ()))
          ((test? (car l) a) (cdr l))
          (else (cons (car l) (loop (cdr l))))))
      (loop l))))
((rember-f eq?) 'c '(a b c d e f))
'(a b d e f)

PS,你可以用'()代替(quote ())

,

开始学习用 Lisp 思考

那本书不是关于在 lisp 中思考,而是关于递归思考,这是 Goedel、Herbrand、Rozsa 在 20 世纪发现的计算方法之一彼得。

有谁知道Scheme在这种情况下应该如何表现?

在完成小 lisper 之后,您应该参加 SICP,这将使您了解语言的实现可以做出什么样的决定。你的意思是,不同的实现是如何起作用的。要了解他们的实施决策,最好的做法是向 SICP 学习。请注意,除非您已经是经过认证的计算机科学专业的毕业生,否则如果您每天都学习这本教材,您将需要几年时间才能掌握它。如果您已经是研究生,则只需大约 1 年的时间即可掌握。