【问题标题】:Scheme: are `letrec` and `letcc` crucial for efficiency?方案:`letrec` 和 `letcc` 对效率至关重要吗?
【发布时间】:2021-01-28 02:27:15
【问题描述】:

我正在阅读 Friedman 和 Felleisen 的 The Seasoned Schemer,但我对他们的一些最佳实践感到有些不安。 作者特别推荐:

  • 使用letrec 删除对于递归应用程序不会更改的参数;
  • 使用letrec 隐藏和保护函数;
  • 使用letcc 突然而迅速地返回值。

让我们来看看这些规则的一些后果。 例如,考虑以下用于计算列表列表交集的代码:

#lang scheme

(define intersectall
  (lambda (lset)
    (let/cc hop
      (letrec
          [[A (lambda (lset)
                (cond [(null? (car lset)) (hop '())]
                      [(null? (cdr lset)) (car lset)]
                      [else (I (car lset) (A (cdr lset)))]))]
           [I (lambda (s1 s2)
                (letrec
                    [[J (lambda (s1)
                          (cond [(null? s1) '()]
                                [(M? (car s1) s2) (cons (car s1) (J (cdr s1)))]
                                [else (J (cdr s1))]))]
                     [M? (lambda (el s)
                           (letrec
                               [[N? (lambda (s)
                                      (cond [(null? s) #f]
                                            [else (or (eq? (car s) el) (N? (cdr s)))]))]]
                             (N? s)))]]
                  (cond [(null? s2) (hop '())]
                        [else (J s1)])))]]
        (cond [(null? lset) '()]
              [else (A lset)])))))

这个例子出现在第 13 章(不完全是这样:我粘贴了在前一段中单独定义的成员测试代码)。

我认为下面的替代实现,它对letrecletcc 的使用非常有限,更具可读性和更易于理解:

(define intersectall-naive
  (lambda (lset)
    (letrec
        [[IA (lambda (lset)
              (cond [(null? (car lset)) '()]
                    [(null? (cdr lset)) (car lset)]
                    [else (intersect (car lset) (IA (cdr lset)))]))]
         [intersect (lambda (s1 s2)
                      (cond [(null? s1) '()]
                            [(M? (car s1) s2) (cons (car s1) (intersect (cdr s1) s2))]
                            [else (intersect (cdr s1) s2)]))]
         [M? (lambda (el s)
               (cond [(null? s) #f]
                     [else (or (eq? (car s) el) (M? el (cdr s)))]))]]
    (cond [(null? lset) '()]
          [else (IA lset)]))))

我是 scheme 新手,我的背景不是计算机科学,但让我感到震惊的是,对于一个简单的列表交集问题,我们必须以如此复杂的代码结束。这让我想知道人们如何管理现实世界应用程序的复杂性。 经验丰富的策划者是否每天都在深度嵌套 letccletrec 表达式?

这就是询问 stackexchange 的动机。

我的问题是:Friedman 和 Felleisen 是否为了教育而过度复杂化了这个示例,还是出于性能原因我应该习惯于使用充满 letccs 和 letrecs 的方案代码? 对于大型列表,我的幼稚代码是否会变得非常慢?

【问题讨论】:

  • 他们至少可以使用一些let 绑定来避免所有那些使代码完全不可读的重复的carcdr 调用。好的代码应该在视觉上很明显。表面语法很糟糕,迫使你思考平凡的东西,而不是简单地看到它。这是混淆,就是这样。语法好(即使是just some made-up pseudocode)很容易理解,甚至很容易变成不需要任何继续捕获,这实际上是性能杀手。
  • 嗨@WillNess,你的伪代码看起来很不错,哪种语言给了你灵感?查看您的解决方案让我认为,在这种情况下,现实世界的代码可能会广泛使用模式匹配。不幸的是,The Seasoned Schemer 没有引入模式匹配(至少现在还没有,我已经完成了一半)。恐怕使用let 绑定来避免carcdr 调用不会节省很多空间,因为let 在绑定之前进行评估,并且我必须确保列表实际上有cdr 在询问之前为其cdr 并将其绑定到一个变量。
  • 部分是 Haskell,部分是我的 evolving imagination。我什至一开始就想到了[a, ...d...] 语法,后来发现JS 已经有了[a, ...d](很长时间了?)。 (但我不知道 JS。有一个我不知道的语言的日志列表,python、clojure、ruby 等)。是的,过早的cdr 是一个问题,但是对于伪代码,我们希望它消失,说“这是一个定义,而不是立即行动的指令”。
  • (contd.) 我认为 {x => x} 的 lambda 语法也在 JS 中。 (在 Haskell 中,let/where 绑定是惰性的,直到变量真正被访问后才会执行)。代码分析器可以将伪代码转换为“真正的方案”......等等。---顺便说一句,我认为,如果等式一一尝试,我的伪代码中的早熟car/cdr 没有问题自上而下的顺序)。我们有一个单独的第一个方程intersectall [] = [],因此第二个方程中的lset 保证为非空。假设我们只执行 one,第一个匹配的方程。与 Prolog 不同,Prolog 全部完成。

标签: scheme let letrec


【解决方案1】:

我不是 Scheme 实现方面的专家,但我对这里发生的事情有一些想法。作者通过他们的let/cc 拥有的一个您没有的优势是在清楚整个结果将是什么时提前终止。假设有人评估

(intersectall-naive (list big-list
                          huge-list
                          enormous-list
                          gigantic-list
                          '()))

您的 IA 会将其转换为

(intersect big-list
           (intersect huge-list
                      (intersect enormous-list
                                 (intersect gigantic-list
                                            '()))))

这很合理。最里面的交集将首先被计算,并且由于gigantic-list 不为零,它将遍历整个gigantic-list,为每个项目检查该项目是否是'() 的成员。当然,没有一个,所以这会导致'(),但您确实必须遍历整个输入才能找到它。这个过程将在每个嵌套的intersect 调用中重复:您的内部过程无法发出“没希望了,放弃吧”的信号,因为它们仅通过返回值进行通信。

当然,您可以在不使用let/cc 的情况下解决此问题,方法是在继续之前检查每个intersect 调用的返回值是否为空。但是(a)让这个检查只发生在一个方向而不是两个方向上是相当漂亮的,并且(b)并不是所有的问题都会如此顺从:也许你想返回一些你不能轻易发出信号的东西,即提前退出是需要的。 let/cc 方法是通用的,允许在任何情况下提前退出。

至于使用letrec 来避免重复调用递归调用的常量参数:同样,我不是Scheme 实现方面的专家,但在Haskell 中我听说过这样的指导:如果你只关闭一个参数,那就是洗了,并且对于 2+ 个参数,它可以提高性能。考虑到闭包的存储方式,这对我来说很有意义。但我怀疑它在任何意义上都是“关键的”,除非你有大量的参数或者你的递归函数做的工作很少:参数处理将是完成工作的一小部分。如果作者认为这提高了清晰度,而不是出于性能原因,我不会感到惊讶。如果我看到

(define (f a x y z)
  (define (g n p q r) ...)
  (g (g (g (g a x y z) x y z) x y z) x y z))

如果我看到了,我会更不开心

(define (f a x y z)
  (define (g n) ...)
  (g (g (g (g a)))))

因为我必须发现实际上p 只是x 等的另一个名称,请检查是否在每种情况下都使用相同的xyz,并确认这是故意的。在后一种情况下,很明显x 始终具有该含义,因为没有其他变量持有该值。当然,这是一个简化的例子,无论如何我不会很高兴看到 g 的四个字面应用,但同样的想法也适用于递归函数。

【讨论】:

  • 您好 amalloy,感谢您的回答。我同意通常传递更少的参数会使函数调用更清晰,但在这种特定情况下,只使用成员资格测试函数M? 而不是M?N? 会不会更清楚,并且只拥有I 而不是IJ?我想我只是希望编译器/解释器可以优化这些细节,而不需要我们非常小心地嵌套多个 letccletrec 块。我要求太多了吗?
猜你喜欢
  • 1970-01-01
  • 2013-05-07
  • 2013-01-31
  • 1970-01-01
  • 1970-01-01
  • 2015-02-22
  • 1970-01-01
  • 1970-01-01
  • 2015-08-09
相关资源
最近更新 更多