这是一个有趣的片段。我遇到这个问题是因为我正在寻找关于两者之间确切差异的讨论letrec
and letrec*
,以及不同版本的计划报告和不同的计划实现之间的差异。在尝试这个片段时,我做了一些研究,并将在这里报告结果。
如果您在心里回顾一下该片段的执行过程,您应该会注意到两个问题:
Q1.初始化子句的顺序是什么x
and y
评价?
Q2。是否首先评估所有初始化子句,并缓存其结果,然后再进行所有分配x
and y
之后执行?或者某些赋值是在某些初始化子句求值之前进行的吗?
For letrec
,Scheme 报告称 Q1 的答案是“未指定”。事实上,大多数实现都会按从左到右的顺序评估子句;但你不应该依赖这种行为。
方案R6RS和R7RS引入了新的绑定结构letrec*
它确实指定了从左到右的评估顺序。它在其他一些方面也与letrec
,正如我们将在下面看到的。
回到letrec
,该计划报告至少可以追溯到 R5RSseem指定 Q2 的答案是“在进行任何分配之前评估所有初始化子句”。我说“似乎指定”是因为语言对于这一点的要求并不像它可能的那样明确。事实上,很多Scheme的实现并不符合这个要求。这就是造成片段的“预期”和“观察到”行为之间差异的原因。
让我们回顾一下您的片段,并考虑到 Q2。首先,我们留出两个“位置”(参考单元格)x
and y
被束缚。然后我们评估初始化子句之一。假设这是一个子句x
,尽管正如我所说,与letrec
可能是其中之一。我们将这个评估的延续保存到cont
。这个评估的结果是 0。现在,根据 Q2 的答案,我们要么立即将该结果分配给x
或者我们缓存它以便稍后进行分配。接下来我们评估其他初始化子句。我们将其延续保存到cont
,覆盖前一个。此评估的结果是 0。现在所有初始化子句都已评估。根据 Q2 的答案,我们此时可以将缓存结果 0 分配给x
;或分配给x
可能已经发生了。在任何一种情况下,分配给y
现在发生。
然后我们开始评估主体(letrec (...) ...)
表达(第一次)。有一个延续存储在cont
,所以我们将其检索到c
,然后清除cont
and set!
每一个x
and y
到 1。然后我们用值 0 调用检索到的延续。这会回到最后评估的初始化子句——我们假设它是y
'是。然后使用我们提供给延续的参数来代替(call-with-current-continuation (lambda (c) (set! cont c) 0))
,并将被分配给y
。根据 Q2 的答案,将 0 分配给x
此时可能会或可能不会(再次)发生。
然后我们开始评估主体(letrec (...) ...)
表达(第二次)。现在cont
是#f,所以我们得到(+ x y)
。哪个将是(+ 1 0)
or (+ 0 0)
,取决于 0 是否被重新分配给x
当我们调用保存的延续时。
您可以通过用一些装饰您的片段来跟踪此行为display
调用,例如这样:
(let ((cont #f))
(letrec ((x (begin (display (list 'xinit x y cont)) (call-with-current-continuation (lambda (c) (set! cont c) 0))))
(y (begin (display (list 'yinit x y cont)) (call-with-current-continuation (lambda (c) (set! cont c) 0)))))
(display (list 'body x y cont))
(if cont
(let ((c cont))
(set! cont #f)
(set! x 1)
(set! y 1)
(c 'new))
(cons x y))))
我也更换了(+ x y)
with (cons x y)
,并用参数调用延续'new
代替0
.
我在 Racket 5.2 中使用几种不同的“语言模式”运行了该片段,也在 Chicken 4.7 中运行了该片段。这是结果。两种实现都评估了x
首先是 init 子句,然后y
第二条,尽管正如我所说,这种行为是未指定的。
球拍配#lang r5rs
and #lang r6rs
符合 Q2 的规范,因此我们得到了重新分配的“预期”结果0
当调用延续时到另一个变量。 (在尝试 r6rs 时,我需要将最终结果包装在display
看看会是什么。)
这是跟踪输出:
(xinit #<undefined> #<undefined> #f)
(yinit #<undefined> #<undefined> #<continuation>)
(body 0 0 #<continuation>)
(body 0 new #f)
(0 . new)
球拍配#lang racket
而鸡不符合这一点。相反,在评估每个初始化子句后,它会被分配给相应的变量。因此,当调用延续时,它最终只会将一个值重新分配给最终值。
这是跟踪输出,并添加了一些注释:
(xinit #<undefined> #<undefined> #f)
(yinit 0 #<undefined> #<continuation>) ; note that x has already been assigned
(body 0 0 #<continuation>)
(body 1 new #f) ; so now x is not re-assigned
(1 . new)
现在,至于计划报告真正需要什么。以下是 R5RS 的相关部分:
库语法:(letrec )
语法: 应采用以下形式
(( ) ...),
应该是一个或多个表达式的序列。这是一个错误
使 在绑定的变量列表中出现多次。
语义: 绑定到未定义的新位置
值, 在结果环境中进行评估(在某些情况下)
未指定的顺序),每个 被分配给
对应的 , 在结果环境中进行评估,并且
返回 中最后一个表达式的值。每个绑定
a 将整个 letrec 表达式作为其区域,使其成为可能
定义相互递归的过程。
(letrec ((even?
(lambda (n)
(if (zero? n)
#t
(odd? (- n 1)))))
(odd?
(lambda (n)
(if (zero? n)
#f
(even? (- n 1))))))
(even? 88))
===> #t
对 letrec 的一个限制非常重要:它必须能够评估
每个 不分配或引用值any。如果
违反了这个限制,那么它就是一个错误。限制是必要的
因为Scheme按值而不是按名称传递参数。在最
letrec 的常见用法,所有 都是 lambda 表达式,并且
自动满足限制。
“语义”部分的第一句话听起来像是需要完成所有作业after所有初始化子句都已被评估;不过,正如我之前所说,这并不像想象的那么明确。
在 R6RS 和 R7RS 中,规范这部分的唯一实质性更改是添加了以下要求:
每个 的后续部分不应被调用多次。
R6RS和R7RS还添加了another不过,绑定结构:letrec*
。这不同于letrec
有两种方式。首先,它指定了从左到右的评估顺序。相应地,上述“限制”可以适当放宽。现在可以引用已分配初始值的变量值:
必须能够在不分配或
指的是值相应的 或
中紧随其后的任何绑定的 .
第二个区别是关于我们的第二季度。和letrec*
,规范现在要求在评估每个初始化子句之后进行分配。这是 R7RS(草案 6)中“语义”的第一段:
语义: 绑定到新位置,每个
按从左到右的顺序分配给评估结果
对应的, 在结果中进行评估
环境, 中最后一个表达式的值为
回。尽管评估和分配顺序是从左到右,但每个
的绑定将整个 letrec* 表达式作为其区域,
使得定义相互递归过程成为可能。
所以鸡和球拍使用#lang racket
---以及许多其他实现---实际上似乎实现了它们的letrec
s as letrec*
s.