循环形式的命名 let 如何工作?

How does the named let in the form of a loop work?

在解释如何将数字转换为列表的 an answer 中,number->list 过程定义如下:

(define (number->list n)
  (let loop ((n n)
             (acc '()))
    (if (< n 10)
        (cons n acc)
        (loop (quotient n 10)
              (cons (remainder n 10) acc)))))

这里使用了“named let”。我不明白这个名为 let 的工作原理。

我看到定义了一个循环,其中变量 n 等于 n,变量 acc 等于空列表。然后,如果 n 小于 10,则 n 被授予 acc。否则,"the loop" 应用 n 等于 n/10acc 等于 n/10 的余数和先前累积的东西的余数,并且然后调用自己。

我不明白loop为什么叫循环(什么是循环?),它是如何自动执行和调用自己的,以及它实际上是如何将每个数字乘以其适当的乘数相加形成以 10 为基数的数字。

我希望有人能对程序和上述问题有所启发,以便我更好地理解它。谢谢。

命名 let 背后的基本思想是它允许您创建一个内部函数,该函数可以调用自身并自动调用它。所以你的代码相当于:

(define (number->list n)
  (define (loop n acc)
    (if (< n 10)
        (cons n acc)
        (loop (quotient n 10)
              (cons (remainder n 10) acc))))
  (loop n '()))

希望这对您来说更容易阅读和理解。

然后,您可能会问为什么人们倾向于使用命名的 let 而不是定义一个内部函数并调用它。这与人们使用(未命名)let 的基本原理相同:它将两步过程(定义一个函数并调用它)变成一个单一的、方便的形式。


之所以称为循环,是因为函数在尾部位置调用了自己。这被称为 tail recursion。使用尾递归,递归调用 returns 直接到您的调用者,因此无需保留当前调用帧。您可以根据需要多次执行尾递归,而不会导致堆栈溢出。这样,它就像一个循环一样工作。


如果您想了解有关 named let 及其工作原理的更多信息,I wrote a blog post about it。 (不过,你不需要阅读它来理解这个答案。如果你好奇的话,它就在那里。)

正常的let用法可以被认为是匿名过程调用:

(let ((a 10) (b 20))
  (+ a b))

;; is the same as 
((lambda (a b)
   (+ a b))
 10
 20)

一个命名的 let 只是将该过程绑定到过程范围内的一个名称,因此它等于单个过程 letrec:

(let my-chosen-name ((n 10) (acc '()))
  (if (zero? n)
      acc
      (my-chosen-name (- n 1) (cons n acc)))) ; ==> (1 2 3 4 5 6 7 8 9 10)

;; Is the same as:
((letrec ((my-chosen-name 
          (lambda (n acc)
            (if (zero? n)
                acc
                (my-chosen-name (- n 1) (cons n acc))))))
  my-chosen-name)
 10
 '()) ; ==> (1 2 3 4 5 6 7 8 9 10)

请注意,letrec 的主体只是计算命名过程,因此该名称不在第一次调用的环境中。因此你可以这样做:

(let ((loop 10))
  (let loop ((n loop))
    (if (zero? n)
        '()
        (cons n (loop (- n 1))))) ; ==> (10 9 8 7 6 5 4 3 2 1)

过程loop只存在于内部let的body环境中,不隐藏外部let的变量loop

在您的示例中,名称 loop 只是一个名称。在 Scheme 中,每个循环最终都是通过递归完成的,但通常在尾递归时使用名称,因此是一个迭代过程。