17

在阅读“经验丰富的计划者”时,我开始了解letrec. 我理解它的作用(可以用 Y-Combinator 复制),但本书使用它来代替在已经defined 函数上重复运行,该函数对保持静态的参数进行操作。

一个使用defined 函数的旧函数的例子(没有什么特别的):

(define (substitute new old l)
  (cond
    ((null? l) '())
    ((eq? (car l) old)
      (cons new (substitute new old (cdr l))))
    (else
      (cons (car l) (substitute new old (cdr l))))))

现在举一个相同功能的例子,但使用letrec

(define (substitute new old l)
  (letrec
    ((replace
      (lambda (l)
        (cond
          ((null? l) '())
          ((eq? (car l) old)
           (cons new (replace (cdr l))))
          (else
           (cons (car l) (replace (cdr l))))))))
(replace lat)))

除了略长且更难阅读之外,我不知道他们为什么要重写书中的函数以使用 letrec。以这种方式重复静态变量时是否会提高速度,因为您不会一直传递它?

对于具有保持静态但减少了一个参数(例如递归列表的元素)的函数,这是标准做法吗?

来自更有经验的计划者/LISPers 的一些输入会有所帮助!

4

3 回答 3

16

因此,您有一些涵盖可读性问题的答案,应该没问题。但一个不清楚的问题是是否存在任何性能问题。从表面上看,似乎letrec版本,如 named- letone(实际上是相同的)应该更快,因为在循环中传递的参数更少。然而,实际上编译器可以在你背后进行各种优化,比如找出普通版本中的循环不改变地传递前两个参数,然后将其变成带有第一个参数的单参数循环。这里不是向您显示特定系统上的数字,而是一个 PLT 模块,您可以运行它来计时四个不同的版本,并且您可以轻松添加更多以尝试其他变体。简短的总结是,在我的机器上,名为-let版本稍微快一些,并且使其尾递归具有更大的整体优势。

#lang scheme

;; original version
(define (substitute1 new old l)
  (cond [(null? l) '()]
        [(eq? (car l) old) (cons new (substitute1 new old (cdr l)))]
        [else (cons (car l) (substitute1 new old (cdr l)))]))

;; letrec version (implicitly through a named-let)
(define (substitute2 new old l)
  (let loop ([l l])
    (cond [(null? l) '()]
          [(eq? (car l) old) (cons new (loop (cdr l)))]
          [else (cons (car l) (loop (cdr l)))])))

;; making the code a little more compact
(define (substitute3 new old l)
  (let loop ([l l])
    (if (null? l)
      '()
      (cons (let ([fst (car l)]) (if (eq? fst old) new fst))
            (loop (cdr l))))))

;; a tail recursive version
(define (substitute4 new old l)
  (let loop ([l l] [r '()])
    (if (null? l)
      (reverse r)
      (loop (cdr l)
            (cons (let ([fst (car l)]) (if (eq? fst old) new fst)) r)))))

;; tests and timings

(define (rand-list n)
  (if (zero? n) '() (cons (random 10) (rand-list (sub1 n)))))

(for ([i (in-range 5)])
  (define l   (rand-list 10000000))
  (define new (random 10))
  (define old (random 10))
  (define-syntax-rule (run fun)
    (begin (printf "~a: " 'fun)
           (collect-garbage)
           (time (fun new old l))))
  ;; don't time the first one, since it allocates a new list to use later
  (define new-list (substitute1 new old l))
  (unless (and (equal? (run substitute1) new-list)
               (equal? (run substitute2) new-list)
               (equal? (run substitute3) new-list)
               (equal? (run substitute4) new-list))
    (error "poof"))
  (newline))
于 2010-01-14T02:10:24.493 回答
4

关于您的具体示例:我个人觉得该letrec版本更易于阅读:您定义了一个递归辅助函数,并在顶级函数的主体中调用它。这两种形式的主要区别在于,在这种letrec形式中,您不必在递归调用中一遍又一遍地指定静态参数,我觉得这样更简洁。

如果代码被编译,避免在每个递归函数调用上传递静态参数在这种情况下也可能会提供小的性能优势,因为调用者避免了将参数复制到新的堆栈帧中。如果递归函数调用位于尾部位置,编译器可能足够聪明,可以避免一遍又一遍地复制堆栈上的参数。

同样,如果代码被解释,在递归调用中使用更少的参数会更快。

letrec更一般地说,我认为您在上面没有提到的主要好处之一是它允许相互递归的函数定义。我对方案非常缺乏经验,但据我所知,letrec与例如define.

于 2010-01-13T22:46:27.707 回答
4

一方面,该letrec版本允许您使用该函数,即使它的全局名称被重置为其他名称,例如

(define substitute
  ; stuff involving letrec
  )

(define sub substitute)
(set! substitute #f)

然后sub仍然可以工作,而非letrec版本则不会。

至于性能和可读性,后者可能是一个品味问题,而前者不应该有明显的不同(尽管我没有资格坚持这样,而且无论如何它也是依赖于实现的)。

另外,我实际上会let亲自使用命名:

(define (substitute new old lat) ; edit: fixed this line
  (let loop (
             ; whatever iteration variables are needed + initial values
            )
    ; whatever it is that substitute should do at each iteration
    ))

我发现这种方式更具可读性。YMMV。

于 2010-01-13T22:49:19.373 回答