nus-cs1101s-sicp-js-merge-4
NUS CS1101S:SICP JavaScript 描述(五)
译者:飞龙
现在我们可以如下定义整数:
const integers = pair(1, () => add_streams(ones, integers));
这定义了integers为一个流,其第一个元素为 1,其余部分是ones和integers的和。因此,integers的第二个元素是integers的第一个元素加 1,或 2;integers的第三个元素是integers的第二个元素加 1,或 3;依此类推。这个定义之所以有效,是因为在任何时候,integers流的足够部分已经生成,以便我们可以将其反馈到定义中以产生下一个整数。
我们可以以相同的方式定义斐波那契数:
const fibs = pair(0,
() => pair(1,
() => add_streams(stream_tail(fibs),
fibs)));
这个定义表示fibs是一个以 0 和 1 开头的流,这样流的其余部分可以通过将fibs加到自身移位一个位置来生成:
1 1 2 3 5 8 13 21 ... = stream_tail(fibs)
0 1 1 2 3 5 8 13 ... = fibs
0 1 1 2 3 5 8 13 21 34 ... = fibs
函数scale_stream在制定这种流定义时也很有用。这将流中的每个项目乘以给定的常数:
function scale_stream(stream, factor) {
return stream_map(x => x * factor,
stream);
}
例如,
const double = pair(1, () => scale_stream(double, 2));
产生 2 的幂的流:1, 2, 4, 8, 16, 32, ....
可以通过从整数开始并通过测试素数性进行过滤来给出素数流的另一种定义。我们需要第一个素数 2 来开始:
const primes = pair(2,
() => stream_filter(is_prime,
integers_starting_from(3)));
这个定义并不像看起来那么简单,因为我们将测试一个数n是否为素数,方法是检查是否可以被小于或等于
的素数(而不是任意整数)整除:
function is_prime(n) {
function iter(ps) {
return square(head(ps)) > n
? true
: is_divisible(n, head(ps))
? false
: iter(stream_tail(ps));
}
return iter(primes);
}
这是一个递归定义,因为primes是根据is_prime谓词定义的,而is_prime谓词本身使用primes流。这个函数之所以有效,是因为在任何时候,primes流的足够部分已经生成,以便我们可以测试下一个需要检查的数的素数性。也就是说,对于每个n,我们测试其是否为素数,要么n不是素数(在这种情况下,已经生成了一个可以整除它的素数),要么n是素数(在这种情况下,已经生成了一个素数,即小于n的素数,大于
的素数)。
练习 3.53
不运行程序的情况下,描述由以下定义的流的元素
const s = pair(1, () => add_streams(s, s));
练习 3.54
定义一个函数mul_streams,类似于add_streams,它产生其两个输入流的逐元素乘积。与integers流一起使用,完成以下流的定义,其第n个元素(从 0 开始计数)是n + 1的阶乘:
const factorials = pair(1, () => mul_streams(〈??〉, 〈??〉));
练习 3.55
定义一个名为partial_sums的函数,该函数以流S作为参数,并返回其元素为S[0],S[0] + S[1],S[0] + S[1] + S[2],...的流。例如,partial_sums(integers)应该是流1, 3, 6, 10, 15, ...。
练习 3.56
一个著名的问题,首次由 R. Hamming 提出,是按升序枚举所有没有除了 2、3 或 5 之外的质因数的正整数,而且没有重复。一个明显的方法是简单地依次测试每个整数,看它是否有除 2、3 和 5 之外的因子。但这非常低效,因为随着整数变大,符合要求的整数越来越少。作为替代方案,让我们称所需的数字流为S,并注意关于它的以下事实。
-
S以 1 开始。 -
scale_stream(S, 2)的元素也是S的元素。 -
scale_stream(S, 3)和scale_stream(S, 5)也是如此。 -
这些都是
S的元素。
现在我们只需要从这些来源中组合元素。为此,我们定义一个函数merge,它将两个有序流合并成一个有序结果流,消除重复项:
function merge(s1, s2) {
if (is_null(s1)) {
return s2;
} else if (is_null(s2)) {
return s1;
} else {
const s1head = head(s1);
const s2head = head(s2);
return s1head < s2head
? pair(s1head, () => merge(stream_tail(s1), s2))
: s1head > s2head
? pair(s2head, () => merge(s1, stream_tail(s2)))
: pair(s1head, () => merge(stream_tail(s1), stream_tail(s2)));
}
}
然后,可以使用merge构造所需的流,如下所示:
const S = pair(1, () => merge((??), (??)));
在上面标有(??)的地方填写缺失的表达式。
练习 3.57
使用基于add_streams函数的 fibs 声明计算第 n 个斐波那契数时执行了多少次加法?证明如果add_streams使用练习 3.50 中描述的stream_map_2_optimized函数,这个数字呈指数增长。
练习 3.58
给出函数计算的流的解释
function expand(num, den, radix) {
return pair(math_trunc((num * radix) / den),
() => expand((num * radix) % den, den, radix));
}
其中math_trunc丢弃其参数的小数部分,即除法的余数。expand(1, 7, 10)产生的连续元素是什么?expand(3, 8, 10)产生什么?
练习 3.59
在 2.5.3 节中,我们看到如何实现多项式算术系统,将多项式表示为项的列表。类似地,我们可以处理幂级数,例如

表示为无限流。我们将级数a[0] + a[1]x + a[2]x² + a[3]x³ + ...表示为其元素为系数a[0],a[1], a[2], a[3], ...的流。
-
a. 级数
a[0] + a[1]x + a[2]x² + a[3]x³ + ...的积分是级数c + a[0]x + 1/2 a[1]x² + 1/3 a[2]x³ + 1/4 a[3]x⁴ + ...定义一个函数
integrate_series,它以流a[0], a[1], a[2], ...作为输入,表示幂级数,并返回非常数项积分的系数流a[0], 1/2 a[1], 1/3 a[2], ...。(由于结果没有常数项,它不表示幂级数;当我们使用integrate_series时,我们将使用pair将适当的常数添加到流的开头。) -
b. 函数
x -> eˣ是它自己的导数。这意味着eˣ和eˣ的积分是相同的级数,除了常数项,它是e⁰= 1。因此,我们可以生成eˣ的级数为const exp_series = pair(1, () => integrate_series(exp_series));展示如何从正弦的导数是余弦和余弦的导数是负正弦这两个事实开始生成正弦和余弦的级数:
const cosine_series = pair(1, 〈??〉); const sine_series = pair(0, 〈??〉);
练习 3.60
使用练习 3.59 中级数表示为系数流的方式,通过add-streams实现级数相加。完成以下函数的声明以实现级数相乘:
function mul_series(s1, s2) {
pair(〈??〉, () => add_streams(〈??〉, 〈??〉));
}
您可以通过验证sin²x + cos²x = 1,使用练习 3.59 中的级数来测试您的函数。
练习 3.61
设S是一个幂级数(练习 3.59),其常数项为 1。假设我们想找到幂级数1/S,即级数X,使得S X = 1。将S写为1 + S[R],其中S[R]是常数项之后的S的部分。然后我们可以按如下方式解出X:
S · X = 1
(1 + S[R]) · X = 1
X + S[R] · X = 1
X = 1 – S[R] · X
换句话说,X是常数项为 1 的幂级数,其高阶项由S[R]的负数乘以X给出。使用这个想法编写一个名为invert_unit_series的函数,该函数计算常数项为 1 的幂级数S的1/S。您需要使用练习 3.60 中的mul_series。
练习 3.62
使用练习 3.60 和 3.61 的结果定义一个名为div_series的函数,该函数可以将两个幂级数相除。div_series函数应适用于任何两个级数,只要分母级数以非零常数项开头。(如果分母有零常数项,则div_series应发出错误信号。)展示如何使用div_series与练习 3.59 的结果一起生成正切的幂级数。
3.5.3 利用流范式
具有延迟求值的流可以是一个强大的建模工具,提供了许多局部状态和赋值的好处。此外,它们避免了引入赋值到编程语言中时伴随的一些理论上的纠缠。
流方法可以提供启发,因为它允许我们构建具有不同模块边界的系统,而不是围绕对状态变量的赋值组织的系统。例如,我们可以将整个时间序列(或信号)视为关注的焦点,而不是单个时刻的状态变量的值。这使得方便地组合和比较来自不同时刻的状态组件。
将迭代公式表述为流过程
在 1.2.1 节中,我们介绍了迭代过程,通过更新状态变量进行。我们现在知道,我们可以将状态表示为“无时间”的值流,而不是一组要更新的变量。让我们在重新访问 1.1.7 节中的求平方根函数时采用这种观点。回想一下,这个想法是通过反复应用改进猜测的函数来生成越来越好的x的平方根的序列:
function sqrt_improve(guess, x) {
return average(guess, x / guess);
}
在我们原始的sqrt函数中,我们让这些猜测成为状态变量的连续值。相反,我们可以生成无限的猜测流,从初始猜测值 1 开始:
function sqrt_stream(x) {
return pair(1, () => stream_map(guess => sqrt_improve(guess, x),
sqrt_stream(x)));
}
display_stream(sqrt_stream(2));
`1`
1.5
1.4166666666666665
1.4142156862745097
1.4142135623746899
...
我们可以生成越来越多的流项,以获得越来越好的猜测。如果愿意,我们可以编写一个函数,直到答案足够好为止一直生成项。(参见练习 3.64。)
我们可以以相同的方式处理的另一个迭代是基于我们在 1.3.1 节中看到的交替级数生成π的近似值:

首先生成级数的和项流(奇整数的倒数,交替符号)。然后我们取越来越多项的和的流(使用练习 3.55 的partial_sums函数)并将结果缩放 4 倍:
function pi_summands(n) {
return pair(1 / n, () => stream_map(x => - x, pi_summands(n + 2)));
}
const pi_stream = scale_stream(partial_sums(pi_summands(1)), 4);
display_stream(pi_stream);
`4`
2.666666666666667
3.466666666666667
2.8952380952380956
3.3396825396825403
2.9760461760461765
3.2837384837384844
3.017071817071818
...
这给我们提供了一个越来越好的π的近似流,尽管这些近似值收敛得相当慢。序列的八个项将π的值限制在 3.284 和 3.017 之间。
到目前为止,我们对状态流方法的使用与更新状态变量并没有太大不同。但是流使我们有机会做一些有趣的技巧。例如,我们可以使用序列加速器转换流,将近似值序列转换为收敛到与原始值相同的新序列,只是更快。
其中一种加速器,由十八世纪瑞士数学家 Leonhard Euler 提出,对于偏和交错级数的序列效果很好(交错符号的项的级数)。在欧拉的技术中,如果S[n]是原始和序列的第n项,则加速的序列具有项

因此,如果原始序列表示为值的流,则变换后的序列由
function euler_transform(s) {
const s0 = stream_ref(s, 0); // S[n][–1]
const s1 = stream_ref(s, 1); // S[n]
const s2 = stream_ref(s, 2); // S[n][+1]
return pair(s2 - square(s2 - s1) / (s0 + (-2) * s1 + s2),
memo(() => euler_transform(stream_tail(s))));
}
请注意,我们利用了第 3.5.1 节的记忆化优化,因为在接下来的内容中,我们将依赖于对生成的流的重复求值。
我们可以用我们对π的逼近序列来演示欧拉加速:
display_stream(euler_transform(pi_stream));
3.166666666666667
3.1333333333333337
3.1452380952380956
3.13968253968254
3.1427128427128435
3.1408813408813416
3.142071817071818
3.1412548236077655
...
更好的是,我们可以加速加速的序列,然后递归加速,依此类推。也就是说,我们创建了一个流的流(我们将其称为表格的结构),其中每个流都是前一个流的变换:
function make_tableau(transform, s) {
return pair(s, () => make_tableau(transform, transform(s)));
}
表格的形式
s[00] s[01] s[02] s[03] s[04] ...
s[10] s[11] s[12] s[13] ...
s[20] s[21] s[22] ...
...
最后,我们通过取表格的每一行的第一个项来形成一个序列:
function accelerated_sequence(transform, s) {
return stream_map(head, make_tableau(transform, s));
}
我们可以演示这种“超加速”π序列:
display_stream(accelerated_sequence(euler_transform, pi_stream));
`4`
3.166666666666667
3.142105263157895
3.141599357319005
3.1415927140337785
3.1415926539752927
3.1415926535911765
3.141592653589778
...
结果令人印象深刻。取序列的八个项可以得到π的正确值,精确到小数点后 14 位。如果我们只使用原始的π序列,我们需要计算大约10¹³个项(即,扩展系列直到单个项小于10^(–13))才能获得这么高的精度!
我们本可以在不使用流的情况下实现这些加速技术。但是流的表述特别优雅和方便,因为整个状态序列作为数据结构对我们可用,并且可以使用统一的一组操作进行操作。
练习 3.63
Louis Reasoner 对sqrt_stream函数生成的流的性能不满意,并尝试使用记忆化来优化它:
function sqrt_stream_optimized(x) {
return pair(1,
memo(() => stream_map(guess =>
sqrt_improve(guess, x),
sqrt_stream_optimized(x))));
}
Alyssa P. Hacker 提出
function sqrt_stream_optimized_2(x) {
const guesses = pair(1,
memo(() => stream_map(guess =>
sqrt_improve(guess, x),
guesses)));
return guesses;
}
并声称 Louis 的版本比她的要低效得多,因为它执行了冗余计算。解释 Alyssa 的答案。Alyssa 的方法如果没有记忆化,是否比原始的sqrt_stream更有效?
练习 3.64
编写一个名为stream_limit的函数,它接受一个流和一个数字(容差)作为参数。它应该检查流,直到找到两个连续的元素,它们的绝对值之差小于容差,并返回这两个元素中的第二个。使用这个函数,我们可以通过
function sqrt(x, tolerance) {
return stream_limit(sqrt_stream(x), tolerance);
}
练习 3.65
使用级数

计算三个逼近自然对数 2 的序列,方式与我们上面对π所做的方式相同。这些序列收敛得有多快?
无限流的对
在第 2.2.3 节中,我们看到序列范式如何处理传统的嵌套循环,作为对成对序列定义的过程。如果我们将这种技术推广到无限流,那么我们可以编写不容易表示为循环的程序,因为“循环”必须在无限集合上进行。
例如,假设我们想要将第 2.2.3 节的prime_sum_pairs函数推广为生成所有整数(i, j)对的流,其中i ≤ j,使得i + j是素数。如果int_pairs是所有整数(i, j)对的序列,其中i ≤ j,那么我们所需的流就是简单的⁶⁷
stream_filter(pair => is_prime(head(pair) + head(tail(pair))),
int_pairs);
因此,我们的问题是生成int_pairs流。更一般地说,假设我们有两个流S = (S[i])和T = (T[j]),并想象一个无限的矩形数组
(S[0], T[0]) (S[0], T[1]) (S[0], T[2]) ...
(S[1], T[0]) (S[1], T[1]) (S[1], T[2]) ...
(S[2], T[0]) (S[2], T[1]) (S[2], T[2]) ...
...
我们希望生成一个包含数组中所有位于对角线上方或对角线上的成对的流,即成对
(S[0], T[0]) (S[0], T[1]) (S[0], T[2]) ...
(S[1], T[1]) (S[1], T[2]) ...
(S[2], T[2]) ...
...
(如果我们将S和T都作为整数流,那么这将是我们期望的流int_pairs。)
将成对的一般流称为pairs(S, T),并将其视为由三部分组成:对(S[0], T[0]),第一行中其余的对,以及剩余的对。
(S[0], T[0]) (S[0], T[1]) (S[0], T[2]) ...
(S[1], T[1]) (S[1], T[2]) ...
(S[2], T[2]) ...
...
观察到这种分解中的第三部分(不在第一行中的对)是(递归地)由stream_tail(S)和stream_tail(T)形成的对。还要注意第二部分(第一行的其余部分)是
stream_map(x => list(head(s), x),
stream_tail(t));
因此,我们可以按以下方式形成我们的成对流:
function pairs(s, t) {
return pair(list(head(s), head(t)),
() => combine-in-some-way(
stream_map(x => list(head(s), x),
stream_tail(t)),
pairs(stream_tail(s), stream_tail(t))));
}
为了完成函数,我们必须选择一种组合两个内部流的方法。一个想法是使用第 2.2.1 节中的append函数的流模拟:
function stream_append(s1, s2) {
return is_null(s1)
? s2
: pair(head(s1),
() => stream_append(stream_tail(s1), s2));
}
然而,这对于无限流来说是不合适的,因为它在合并第二个流之前从第一个流中取出所有元素。特别是,如果我们尝试使用以下方式生成所有正整数的成对:
pairs(integers, integers);
我们的结果流将首先尝试运行所有第一个整数等于 1 的对,因此永远不会产生任何其他第一个整数值的对。
为了处理无限流,我们需要设计一种组合顺序,以确保如果我们让程序运行足够长的时间,每个元素最终都会被访问到。实现这一点的一种优雅方法是使用以下interleave函数:
function interleave(s1, s2) {
return is_null(s1)
? s2
: pair(head(s1),
() => interleave(s2, stream_tail(s1)));
}
由于interleave从两个流中交替获取元素,因此第二个流的每个元素最终都会进入交错流中,即使第一个流是无限的。
因此,我们可以生成所需的成对流如下:
function pairs(s, t) {
return pair(list(head(s), head(t)),
() => interleave(stream_map(x => list(head(s), x),
stream_tail(t)),
pairs(stream_tail(s),
stream_tail(t))));
}
练习 3.66
检查流pairs(integers, integers)。您能对成对放入流中的顺序做出一般性评论吗?例如,大约有多少对在(1,100)之前?对(99,100)之前?对(100,100)之前?(如果您能在这里做出精确的数学陈述,那就更好了。但是,如果您发现自己陷入困境,请随时给出更多的定性答案。)
练习 3.67
修改pairs函数,使得pairs(integers, integers)将生成所有整数对(i, j)的流(不带条件i ≤ j)。提示:您需要混合另一个流。
练习 3.68
Louis Reasoner 认为从三个部分构建成对流是不必要复杂的。他建议不将对(S[0], T[0])与第一行中其余的对分开,而是建议使用整个第一行,如下所示:
function pairs(s, t) {
return interleave(stream_map(x => list(head(s), x),
t),
pair(stream_tail(s), stream_tail(t)));
}
这样行得通吗?考虑一下,如果我们使用 Louis 对pairs的定义来求值pairs(integers, integers)会发生什么。
练习 3.69
编写一个名为triples的函数,该函数接受三个无限流S、T和U,并生成三元组(S[i], T[j], U[k])的流,其中i ≤ j ≤ k。使用triples生成所有正整数的勾股三元组的流,即三元组(i, j, k),使得i ≤ j且i² + j² = k²。
练习 3.70
生成流时,以某种有用的顺序出现的整数对会更好,而不是通过特设的交错过程得到的顺序。如果我们定义一种方法来表明一个整数对“小于”另一个整数对,我们可以使用类似于练习 3.56 的merge函数的技术。这样做的一种方法是定义一个“加权函数”W(i, j),并规定如果W(i[1], j[1]) < W(i[2], j[2]),则(i[1], j[1])小于(i[2], j[2])。编写一个名为merge_weighted的函数,它类似于merge,但merge_weighted接受一个额外的参数weight,这是一个计算一对整数的权重的函数,并用于确定结果合并流中元素应该出现的顺序。使用这个方法,将pairs推广为一个名为weighted_pairs的函数,它接受两个流,以及一个计算加权函数的函数,并生成整数对的流,根据权重排序。使用你的函数生成
-
a. 所有正整数对
(i, j)的流,其中i ≤ j,根据和i + j进行排序 -
b. 所有正整数对
(i, j)的流,其中i ≤ j,i和j都不能被 2、3 或 5 整除,并且这些对根据和2i + 3j + 5ij进行排序。
练习 3.71
有时称为拉马努金数的数字可以用两种以上的方式表示为两个立方数的和,以纪念数学家斯里尼瓦萨·拉马努金。有序的整数对流为计算这些数字提供了一种优雅的解决方案。要找到一个可以用两种不同方式写成两个立方数的和的数字,我们只需要生成根据和i³ + j³(参见练习 3.70)加权的整数对流,然后在流中搜索具有相同权重的两个连续整数对。编写一个函数来生成拉马努金数。第一个这样的数字是 1,729。接下来的五个是什么?
练习 3.72
类似于练习 3.71,生成一个流,其中包含所有可以用三种不同方式写成两个平方和的数字(显示它们可以这样写成的方式)。
流作为信号
我们通过将流描述为信号处理系统中的“信号”的计算模拟来开始我们对流的讨论。实际上,我们可以直接使用流来模拟信号处理系统,将信号在连续时间间隔的值表示为流的连续元素。例如,我们可以实现一个积分器或求和器,对于输入流x = (x[i]),初始值C和小增量dt,累积和。

并返回值流S = (S[i])。以下的integral函数类似于整数流的“隐式样式”定义(第 3.5.2 节):
function integral(integrand, initial_value, dt) {
const integ = pair(initial_value,
() => add_streams(scale_stream(integrand, dt),
integ));
return integ;
}
图 3.32 是一个与integral函数对应的信号处理系统的图片。输入流通过dt进行缩放,并通过加法器,其输出再次通过相同的加法器传递。integ的定义中的自引用在图中通过将加法器的输出连接到其中一个输入的反馈环中得到反映。

图 3.32 integral函数视为信号处理系统。
练习 3.73
我们可以使用流来模拟电路,以表示一系列时间点上的电流或电压值。例如,假设我们有一个由电阻R和电容C串联组成的 RC 电路。电路对注入电流i的电压响应v由图 3.33 中的公式确定,其结构由附带的信号流图所示。

图 3.33 一个 RC 电路和相关的信号流图。
编写一个模拟这个电路的函数RC。RC应该以R、C和dt的值作为输入,并应该返回一个函数,该函数以表示当前i的流和电容器电压v[0]的初始值作为输入,并产生电压v的流作为输出。例如,您应该能够通过求值const RC1 = RC(5, 1, 0.5)来使用RC来模拟一个R=5欧姆、C=1法拉和 0.5 秒时间步长的 RC 电路。这将定义RC1作为一个函数,它接受表示电流时间序列的流和初始电容器电压,并产生电压的输出流。
练习 3.74
艾莉莎·P·黑客正在设计一个系统,用于处理来自物理传感器的信号。她希望产生的一个重要特性是描述输入信号的零交叉的信号。也就是说,结果信号应该在输入信号从负变为正时为+1,在输入信号从正变为负时为-1,否则为 0。(假设 0 输入的符号为正。)例如,具有其相关零交叉信号的典型输入信号可能是

在艾莉莎的系统中,传感器的信号表示为一个流sense_data,而流zero_crossings是相应的零交叉流。艾莉莎首先编写了一个名为sign_change_detector的函数,该函数将两个值作为参数并比较这些值的符号以产生适当的 0、1 或-1。然后她按照以下方式构造了她的零交叉流:
function make_zero_crossings(input_stream, last_value) {
return pair(sign_change_detector(head(input_stream), last_value),
() => make_zero_crossings(stream_tail(input_stream),
head(input_stream)));
}
const zero_crossings = make_zero_crossings(sense_data, 0);
艾莉莎的老板伊娃·卢·阿特走过来,建议这个程序大致等同于以下使用练习 3.50 中的stream_map_2函数的程序:
const zero_crossings = stream_map_2(sign_change_detector,
sense_data,
expression);
通过提供指定的expression来完成程序。
练习 3.75
很遗憾,艾莉莎在练习 3.74 中的零交叉检测器证明是不够的,因为传感器的嘈杂信号导致了虚假的零交叉。硬件专家莱姆·E·特维基建议艾莉莎在提取零交叉之前平滑信号以滤除噪音。艾莉莎接受了他的建议,并决定从通过将感应数据的每个值与前一个值进行平均构造的信号中提取零交叉。她向助手路易斯·里森纳解释了问题,后者试图实施这个想法,修改了艾莉莎的程序如下:
function make_zero_crossings(input_stream, last_value) {
const avpt = (head(input_stream) + last_value) / 2;
return pair(sign_change_detector(avpt, last_value),
() => make_zero_crossings(stream_tail(input_stream),
avpt));
}
这并没有正确实现艾莉莎的计划。找到路易斯安装的错误并修复它,而不改变程序的结构。(提示:您需要增加make_zero_crossings的参数数量。)
练习 3.76
伊娃·卢·阿特对路易斯在练习 3.75 中的方法提出了批评。他写的程序不是模块化的,因为它混合了平滑操作和零交叉提取。例如,如果艾莉莎找到了更好的方法来调节她的输入信号,提取器就不应该被改变。通过编写一个名为smooth的函数来帮助路易斯,该函数以流作为输入并产生一个流,其中每个元素都是两个连续输入流元素的平均值。然后使用smooth作为组件以更模块化的方式实现零交叉检测器。
3.5.4 流和延迟求值
在前一节的最后,integral函数展示了我们如何使用流来模拟包含反馈环的信号处理系统。图 3.32 中所示的加法器的反馈环是通过integral的内部流integ是根据自身定义的事实来建模的:
const integ = pair(initial_value,
() => add_streams(scale_stream(integrand, dt),
integ));
解释器处理这种隐式定义的能力取决于将对add_streams的调用包装在 lambda 表达式中所产生的延迟。没有这种延迟,解释器无法在求值对add_streams的调用之前构造integ,这将要求integ已经被定义。一般来说,这种延迟对于使用流来模拟包含循环的信号处理系统至关重要。没有延迟,我们的模型必须被制定为信号处理组件的任何输入在输出产生之前必须被完全求值。这将禁止循环。
很不幸,带有循环的系统的流模型可能需要超出迄今为止所见的流编程模式的延迟。例如,图 3.34 显示了一个信号处理系统,用于解决微分方程dy/dt = f(y),其中f是一个给定的函数。图中显示了一个映射组件,它将f应用于其输入信号,并以一种非常类似于实际用于解决这类方程的模拟计算机电路的反馈环路连接到积分器。

图 3.34 一个解方程dy/dt = f(y)的“模拟计算机电路”。
假设我们对y有一个初始值y[0],我们可以尝试使用以下函数来模拟这个系统
function solve(f, y0, dt) {
const y = integral(dy, y0, dt);
const dy = stream_map(f, y);
return y;
}
这个函数不起作用,因为在solve的第一行中,对integral的调用要求定义输入dy,而这直到solve的第二行才发生。
另一方面,我们的定义意图是有意义的,因为原则上我们可以开始生成y流而不知道dy。实际上,integral和许多其他流操作可以在只有关于参数的部分信息时生成部分答案。对于integral,输出流的第一个元素是指定的initial_value。因此,我们可以在不求值被积函数dy的情况下生成输出流的第一个元素。一旦我们知道y的第一个元素,solve的第二行中的stream_map就可以开始工作来生成dy的第一个元素,这将产生y的下一个元素,依此类推。
为了利用这个想法,我们将重新定义integral,以期望积分流作为延迟参数。函数integral将强制积分在需要生成输出流的第一个元素时才被求值:
function integral(delayed_integrand, initial_value, dt) {
const integ =
pair(initial_value,
() => {
const integrand = delayed_integrand();
return add_streams(scale_stream(integrand, dt),
integ);
});
return integ;
}
现在我们可以通过延迟y的声明中dy的求值来实现我们的solve函数:
function solve(f, y0, dt) {
const y = integral(() => dy, y0, dt);
const dy = stream_map(f, y);
return y;
}
一般来说,integral的每个调用者现在都必须延迟被积函数的参数。我们可以通过计算微分方程dy/dt = y的解在y = 1处的值来证明solve函数的工作:
stream_ref(solve(y => y, 1, 0.001), 1000);
2.716923932235896
练习 3.77
上面使用的integral函数类似于第 3.5.2 节中整数无限流的“隐式”定义。或者,我们可以给出更像integers-starting-from(也在第 3.5.2 节中)的integral的定义:
function integral(integrand, initial_value, dt) {
return pair(initial_value,
is_null(integrand)
? null
: integral(stream_tail(integrand),
dt * head(integrand) + initial_value,
dt));
}
在带有循环的系统中使用时,这个函数与我们原始版本的integral一样存在问题。修改函数,以便它期望integrand作为延迟参数,因此可以在上面显示的solve函数中使用。
练习 3.78
考虑设计一个信号处理系统来研究齐次二阶线性微分方程

输出流,对y进行建模,是由一个包含循环的网络生成的。这是因为d²y/dt²的值取决于y和dy/dt的值,而这两者都是通过对d²y/dt²进行积分来确定的。我们想要编码的图表如图 3.35 所示。编写一个名为solve_2nd的函数,该函数以常数a、b和dt以及y和dy/dt的初始值y[0]和dy[0]作为参数,并生成y的连续值流。

图 3.35 用于解决二阶线性微分方程的信号流图。
练习 3.79
将练习 3.78 的solve_2nd函数泛化,以便用于解决一般的二阶微分方程d²y/dt² = f (dy/dt, y)。
练习 3.80
串联 RLC 电路由一个电阻、一个电容和一个电感器串联而成,如图 3.36 所示。如果R、L和C分别是电阻、电感和电容,那么这三个元件的电压(v)和电流(i)之间的关系由以下方程描述

电路连接规定了关系
i[R] = i[L] = –i[C]
v[C] = v[L] + v[R]
结合这些方程表明电路的状态(由电容器两端的电压v[C]和电感器中的电流i[L]总结)由一对微分方程描述

表示这个微分方程系统的信号流图如图 3.37 所示。

图 3.36 一个串联 RLC 电路。

图 3.37 一个用于解决串联 RLC 电路的信号流图。
编写一个名为RLC的函数,该函数以电路的参数R、L和C以及时间增量dt作为参数。类似于练习 3.73 中的RC函数,RLC应该生成一个函数,该函数接受状态变量的初始值,5v[C0]和i[L0],并生成状态v[C]和i[L]的流的一对(使用pair)。使用RLC,生成一对流,模拟具有R = 1欧姆、C = 0.2法拉、L = 1亨利、dt = 0.1秒和初始值i[L0] = 0安培和v[C0] = 10伏特的串联 RLC 电路的行为。
正常顺序求值
本节中的示例说明了延迟求值如何提供很大的编程灵活性,但这些示例也表明了这如何使我们的程序变得更加复杂。例如,我们的新integral函数赋予我们建模具有循环的系统的能力,但现在我们必须记住应该使用延迟的被积函数来调用integral,并且使用integral的每个函数都必须意识到这一点。实际上,我们创建了两类函数:普通函数和接受延迟参数的函数。通常情况下,创建不同类别的函数会迫使我们创建不同类别的高阶函数。⁷³
避免需要两种不同类别的函数的一种方法是使所有函数都采用延迟参数。我们可以采用一种求值模型,其中所有函数的参数都自动延迟,并且只有在实际需要时才强制参数(例如,当原始操作需要时)。这将使我们的语言转换为使用正则序求值,我们在 1.1.5 节介绍求值替换模型时首次描述了这一点。转换为正则序求值提供了一种统一而优雅的方式来简化延迟求值的使用,如果我们只关注流处理,这将是一种自然的策略。在 4.2 节中,我们在研究了求值器之后,将看到如何以这种方式转换我们的语言。不幸的是,在函数调用中包含延迟会破坏我们设计依赖事件顺序的程序的能力,例如使用赋值、改变数据或执行输入或输出的程序。即使在一对的尾部延迟也会造成很大的混乱,正如练习 3.51 和 3.52 所示。据人所知,可变性和延迟求值在编程语言中并不相容。
3.5.5 函数式程序的模块化和对象的模块化
正如我们在 3.1.2 节中看到的,引入赋值的主要好处之一是,我们可以通过将大系统的部分状态封装或“隐藏”在局部变量中来增加系统的模块化。流模型可以在不使用赋值的情况下提供等效的模块化。举例来说,我们可以从流处理的角度重新实现我们在 3.1.2 节中研究的π的蒙特卡洛估计。
关键的模块化问题是,我们希望隐藏随机数生成器的内部状态,不让使用随机数的程序知道。我们从一个名为rand_update的函数开始,它的连续值提供了我们的随机数供应,并用它来生成一个随机数生成器:
function make_rand() {
let x = random_init;
return () => {
x = rand_update(x);
return x;
};
}
const rand = make_rand();
在流的表述中,没有随机数生成器per se,只是通过连续调用rand_update产生的随机数流:
const random_numbers =
pair(random_init,
() => stream_map(rand_update, random_numbers));
我们用这个来构建在random_numbers流中对连续对执行的 Cesàro 实验结果的流:
function map_successive_pairs(f, s) {
return pair(f(head(s), head(stream_tail(s))),
() => map_successive_pairs(
f,
stream_tail(stream_tail(s))));
}
const dirichlet_stream =
map_successive_pairs((r1, r2) => gcd(r1, r2) === 1,
random_numbers);
现在dirichlet_stream被输入到monte_carlo函数中,它产生一个概率估计的流。然后将结果转换为π的估计流。这个程序的版本不需要一个告诉要执行多少次试验的参数。通过查看pi流的更远处,可以获得更好的π估计(通过进行更多的实验):
function monte_carlo(experiment_stream, passed, failed) {
function next(passed, failed) {
return pair(passed / (passed + failed),
() => monte_carlo(stream_tail(experiment_stream),
passed, failed));
}
return head(experiment_stream)
? next(passed + 1, failed)
: next(passed, failed + 1);
}
const pi = stream_map(p => math_sqrt(6 / p),
monte_carlo(dirichlet_stream, 0, 0));
这种方法具有相当的模块化,因为我们仍然可以制定一个通用的monte_carlo函数,可以处理任意的实验。但是没有赋值或局部状态。
练习 3.81
练习 3.6 讨论了将随机数生成器泛化,以允许重新设置随机数序列,从而产生可重复的“随机”数列。以此相同生成器的流形式进行一个流的表述,它在一个请求输入流上操作,请求是"generate"一个新的随机数或"reset"序列到指定值,并产生所需的随机数流。在你的解决方案中不要使用赋值。
练习 3.82
重新进行练习 3.5,使用流的术语进行蒙特卡洛积分。estimate_integral的流版本不会有一个告诉要执行多少次试验的参数。相反,它将产生基于越来越多试验的估计流。
时间的函数式编程视图
现在让我们回到本章开头提出的对象和状态问题,并从一个新的角度来审视它们。我们引入了赋值和可变对象,以提供一种模块化构建具有状态的系统的程序的机制。我们使用本地状态变量构建了计算对象,并使用赋值来修改这些变量。我们通过相应的计算对象的时间行为来模拟对象在世界中的时间行为。
现在我们已经看到,流提供了一种用本地状态模拟对象的替代方式。我们可以使用表示连续状态的时间历史的流来模拟变化的数量,例如某个对象的本地状态。实质上,我们使用流明确表示时间,这样我们就可以将模拟世界中的时间与求值过程中发生的事件序列分离开来。实际上,由于延迟求值的存在,模型中模拟的时间与求值过程中事件的顺序可能几乎没有关系。
为了对比这两种建模方法,让我们重新考虑一下监视银行账户余额的“取款处理器”的实现。在 3.1.3 节中,我们实现了一个简化版本的这样一个处理器:
function make_simplified_withdraw(balance) {
return amount => {
balance = balance - amount;
return balance;
};
}
对make_simplified_withdraw的调用会产生计算对象,每个对象都有一个名为balance的本地状态变量,该变量会随着对对象的连续调用而递减。该对象接受一个amount作为参数,并返回新的余额。我们可以想象银行账户的用户输入一系列输入到该对象中,并观察显示屏上显示的返回值序列。
或者,我们可以将取款处理器建模为一个函数,该函数以余额和要取款的金额流作为输入,并产生账户中连续余额的流:
function stream_withdraw(balance, amount_stream) {
return pair(balance,
() => stream_withdraw(balance - head(amount_stream),
stream_tail(amount_stream)));
}
函数stream_withdraw实现了一个明确定义的数学函数,其输出完全由其输入确定。然而,假设输入amount_stream是用户输入的连续值的流,而结果余额的流被显示。那么,从输入值和观察结果的用户的角度来看,流程过程与make_simplified_withdraw创建的对象具有相同的行为。然而,使用流版本,没有赋值,没有本地状态变量,因此也没有我们在 3.1.3 节中遇到的理论困难。然而,系统具有状态!
这真是非常了不起。即使stream_withdraw实现了一个行为不会改变的明确定义的数学函数,用户在这里的感知是在与一个具有变化状态的系统进行交互。解决这个悖论的一种方法是意识到是用户的时间存在给系统带来了状态。如果用户能够从交互中退出,并考虑余额流而不是单独的交易,系统将显得没有状态。⁷⁴
从复杂过程的某一部分的角度来看,其他部分似乎随时间变化。它们具有隐藏的时间变化的本地状态。如果我们希望编写模拟我们世界中这种自然分解的程序(从我们作为该世界一部分的视角来看),并在我们的计算机中使用结构,我们将创建不是功能性的计算对象——它们必须随时间变化。我们使用本地状态变量来模拟状态,并使用对这些变量的赋值来模拟状态的变化。通过这样做,我们使计算模型的执行时间成为我们所在世界的时间,因此我们在计算机中得到了“对象”。
用对象建模是强大且直观的,主要是因为这与我们与之交互的世界的感知相匹配。然而,正如我们在本章中反复看到的那样,这些模型引发了关于约束事件顺序和同步多个进程的棘手问题。避免这些问题的可能性刺激了函数式编程语言的发展,这些语言不包括任何关于赋值或可变数据的规定。在这样的语言中,所有函数都实现其参数的明确定义的数学函数,其行为不会改变。函数式方法对处理并发系统非常有吸引力。
另一方面,如果我们仔细观察,我们也可以看到与时间相关的问题潜入了函数式模型。当我们希望设计交互式系统,特别是模拟独立实体之间的交互时,一个特别棘手的领域就会出现。例如,再次考虑允许联合银行账户的银行系统的实现。在使用赋值和对象的传统系统中,我们将彼得和保罗共享一个账户的事实建模为彼得和保罗都将其交易请求发送到同一个银行账户对象,正如我们在 3.1.3 节中所看到的。从流的角度来看,本质上没有“对象”,我们已经指出银行账户可以被建模为一个处理交易请求流以产生响应流的过程。因此,我们可以通过合并彼得的交易请求流和保罗的请求流,并将结果馈送到银行账户流程,来建模彼得和保罗共有一个联合银行账户,如图 3.38 所示。

图 3.38 一个联合银行账户,通过合并两个交易请求流来建模。
这种表述的问题在于合并的概念。不能简单地通过交替从彼得和保罗那里获取交易请求来合并这两个流。假设保罗很少访问账户。我们几乎无法强迫彼得等待保罗访问账户,然后才能发出第二笔交易。无论如何实现这样的合并,它都必须以某种方式交错这两个交易流,这种方式受到“彼得和保罗感知的”真实时间的约束,即如果彼得和保罗相遇,他们可以同意某些交易在会面之前被处理,而其他交易在会面之后被处理。这正是我们在 3.4.1 节中需要处理的约束,我们发现需要引入显式同步来确保并发处理具有状态对象的事件的“正确”顺序。因此,在支持函数式风格的尝试中,合并来自不同代理的输入重新引入了函数式风格旨在消除的相同问题。
我们开始本章的目标是构建计算模型,其结构与我们试图建模的真实世界的感知相匹配。我们可以将世界建模为一组独立的、有时间限制的、相互作用的具有状态的对象,或者我们可以将世界建模为一个单一的、无时间的、无状态的统一体。每种观点都有强大的优势,但单独的观点都不完全令人满意。一个宏伟的统一尚未出现。
四、元语言抽象
译者:飞龙
...魔法就在于文字——Abracadabra,开门,以及其他——但一个故事中的魔法词在另一个故事中并不神奇。真正的魔法是理解哪些词起作用,何时起作用,以及为什么起作用;诀窍就是学会这个诀窍。
...而这些词是由我们字母表的字母组成的:我们可以用笔画出的几十个波浪线。这就是关键!如果我们能得到这个关键,也是宝藏!就好像——好像宝藏的关键 就是 宝藏!
——约翰·巴斯,《奇美拉》
在我们对程序设计的研究中,我们已经看到,专业程序员使用与所有复杂系统设计者使用的相同的一般技术来控制设计的复杂性。他们将原始元素组合成复合对象,将复合对象抽象成更高级的构建块,并通过采用适当的系统结构的大规模视图来保持模块化。在说明这些技术时,我们使用 JavaScript 作为描述过程和构建计算数据对象和过程的语言,以模拟现实世界中复杂现象。然而,随着我们面对越来越复杂的问题,我们会发现 JavaScript,或者任何固定的编程语言,都无法满足我们的需求。为了更有效地表达我们的想法,我们必须不断转向新的语言。建立新语言是控制工程设计复杂性的强大策略;通过采用新语言,我们经常可以增强处理复杂问题的能力,使我们能够以不同的方式描述(因此思考)问题,使用特别适合手头问题的原语、组合手段和抽象手段。¹
编程赋予了多种语言。有物理语言,比如特定计算机的机器语言。这些语言涉及数据和控制的表示,以存储的个别位和原始机器指令。机器语言程序员关心如何利用给定的硬件来建立系统和实用程序,以有效地实现资源有限的计算。高级语言建立在机器语言基础上,隐藏了关于数据表示和程序表示的担忧,这些语言具有组合和抽象的手段,比如函数声明,适用于系统的大规模组织。
元语言抽象——建立新语言——在所有工程设计领域都起着重要作用。对于计算机编程来说尤为重要,因为在编程中,我们不仅可以制定新语言,还可以通过构建求值器来实现这些语言。编程语言的求值器(或解释器)是一个函数,当应用于语言的语句或表达式时,执行求值该语句或表达式所需的操作。把这看作编程中最基本的想法绝非夸大:
确定编程语言中语句和表达式的含义的求值器只是另一个程序。
要理解这一点就是改变我们作为程序员的形象。我们开始把自己看作语言的设计者,而不仅仅是他人设计的语言的使用者。
事实上,我们几乎可以将任何程序视为某种语言的求值器。例如,第 2.5.3 节的多项式处理系统体现了多项式算术规则,并将其实现为对列表结构数据的操作。如果我们增加这个系统的函数来读取和打印多项式表达式,我们就有了一个处理符号数学问题的特定目的语言的核心。第 3.3.4 节的数字逻辑模拟器和第 3.3.5 节的约束传播器本身就是合法的语言,每种语言都有自己的原语、组合手段和抽象手段。从这个角度来看,应对大规模计算机系统的技术与构建新计算机语言的技术融为一体,计算机科学本身不再(也不会更少)只是构建适当描述性语言的学科。
我们现在开始了解语言建立在其他语言基础上的技术之旅。在本章中,我们将以 JavaScript 为基础,将求值器实现为 JavaScript 函数。我们将通过为 JavaScript 本身构建一个求值器来迈出理解语言如何实现的第一步。我们的求值器实现的语言将是 JavaScript 的一个子集。尽管本章描述的求值器是针对 JavaScript 的特定子集编写的,但它包含了为顺序机器编写程序设计语言的求值器的基本结构。(事实上,大多数语言处理器深藏其中一个小型求值器。)为了说明和讨论的目的,求值器已经简化,并且一些重要的特性被省略了,这些特性对于生产质量的 JavaScript 系统来说是重要的。然而,这个简单的求值器足以执行本书中大部分的程序。
将求值器作为 JavaScript 程序可访问的一个重要优势是,我们可以通过将其描述为对求值器程序的修改来实现替代求值规则。我们可以利用这种能力的一个地方是,以更好的效果获得对计算模型体现时间概念的额外控制,这在第 3 章的讨论中是如此核心。在那里,我们通过使用流来解耦世界中的时间表示与计算机中的时间,来减轻一些状态和赋值的复杂性。然而,我们的流程序有时会很笨重,因为它们受到 JavaScript 的应用顺序求值的限制。在 4.2 节中,我们将改变基础语言,以提供更优雅的方法,通过修改求值器来提供正则顺序求值。
第 4.3 节实现了一个更有雄心的语言变化,即语句和表达式具有多个值,而不仅仅是一个单一值。在这种非确定性计算语言中,自然地表达生成所有可能值的过程,然后搜索满足某些约束的值。就计算模型和时间而言,这就像时间分支成一组“可能的未来”,然后搜索适当的时间线。通过我们的非确定性求值器,跟踪多个值和执行搜索都由语言的基础机制自动处理。
在第 4.4 节中,我们实现了一种逻辑编程语言,其中知识是以关系的形式表达,而不是以输入和输出的计算形式。尽管这使得语言与 JavaScript 或任何传统语言都大不相同,但我们将看到逻辑编程求值器与 JavaScript 求值器共享基本结构。
4.1 元循环求值器
我们的 JavaScript 求值器将作为 JavaScript 程序实现。用 JavaScript 实现 JavaScript 程序的求值似乎是循环的。然而,求值是一个过程,因此用 JavaScript 描述求值过程是合适的,毕竟,这是我们描述过程的工具。³ 用相同语言编写的求值器被称为元循环求值器。
元循环求值器本质上是对 3.2 节中描述的求值环境模型的 JavaScript 表述。回想一下,该模型指定了函数应用的求值有两个基本步骤:
-
- 求值函数应用时,首先求值子表达式,然后将函数子表达式的值应用于参数子表达式的值。
-
2. 将复合函数应用于一组参数时,求值函数体在新环境中。为构造此环境,通过将函数对象的环境部分扩展为函数的参数绑定到应用函数的参数的帧。
这两条规则描述了求值过程的本质,即在环境中要求值的语句和表达式被简化为要应用于参数的函数,然后再简化为在新环境中要求值的新语句和表达式,依此类推,直到我们到达名称,其值在环境中查找,以及运算符和原始函数,这些直接应用(见图 4.1)。⁴ 这种求值循环将由求值器中两个关键函数evaluate和apply之间的相互作用体现出来,这些函数在 4.1.1 节中描述(见图 4.1)。

图 4.1 evaluate-apply循环揭示了计算机语言的本质。
求值器的实现将依赖于定义要求值的语句和表达式的语法的函数。我们将使用数据抽象使求值器独立于语言的表示。例如,我们不会选择将赋值表示为以名称开头后跟=的字符串,而是使用抽象谓词is_assignment来测试赋值,并使用抽象选择器assignment_symbol和assignment_value_expression来访问赋值的部分。4.1.2 节中提出的数据抽象层将使求值器保持独立于具体的语法问题,例如解释语言的关键字,以及表示程序组件的数据结构的选择。还有在 4.1.3 节中描述的操作,用于指定函数和环境的表示。例如,make_function构造复合函数,lookup_symbol_value访问名称的值,apply_primitive_function将原始函数应用于给定的参数列表。
4.1.1 求值器的核心
求值过程可以描述为evaluate和apply两个函数之间的相互作用。
函数evaluate
函数evaluate以程序组件(语句或表达式)和环境作为参数。它对组件进行分类并指导其求值。函数evaluate被构造为对要求值的组件的句法类型进行案例分析。为了保持函数的一般性,我们抽象地表达了组件类型的确定,不对各种组件类型的具体表示做出承诺。每种组件类型都有一个语法谓词来测试它,并选择其部分的抽象手段。这种抽象语法使我们可以通过使用相同的求值器,但使用不同的语法函数集合来轻松地看到如何改变语言的语法。
原始表达式
-
对于文字表达式,比如数字,
evaluate返回它们的值。 -
函数
evaluate必须在环境中查找名称以找到它们的值。
组合
-
对于函数应用,
evaluate必须递归求值应用的函数表达式和参数表达式。得到的函数和参数被传递给apply,后者处理实际的函数应用。 -
操作符组合被转换为函数应用,然后进行求值。
句法形式
-
条件表达式或语句需要对其部分进行特殊处理,以便在谓词为真时求值结果,否则求值替代方案。
-
λ表达式必须通过将λ表达式指定的参数和主体与求值的环境一起打包,转换为可应用的函数。
-
一系列语句需要按照它们出现的顺序求值其组件。
-
一个块需要在反映块内声明的所有名称的新环境中求值其主体。
-
返回语句必须产生一个值,该值成为导致返回语句求值的函数调用的结果。
-
函数声明被转换为常量声明,然后进行求值。
-
常量或变量声明或赋值必须调用
evaluate进行递归计算,以计算与正在声明或分配的名称关联的新值。必须修改环境以反映名称的新值。
这是evaluate的声明:
function evaluate(component, env) {
return is_literal(component)
? literal_value(component)
: is_name(component)
? lookup_symbol_value(symbol_of_name(component), env)
: is_application(component)
? apply(evaluate(function_expression(component), env),
list_of_values(arg_expressions(component), env))
: is_operator_combination(component)
? evaluate(operator_combination_to_application(component), env)
: is_conditional(component)
? eval_conditional(component, env)
: is_lambda_expression(component)
? make_function(lambda_parameter_symbols(component),
lambda_body(component), env)
: is_sequence(component)
? eval_sequence(sequence_statements(component), env)
: is_block(component)
? eval_block(component, env)
: is_return_statement(component)
? eval_return_statement(component, env)
: is_function_declaration(component)
? evaluate(function_decl_to_constant_decl(component), env)
: is_declaration(component)
? eval_declaration(component, env)
: is_assignment(component)
? eval_assignment(component, env)
: error(component, "unknown syntax – evaluate");
}
为了清晰起见,evaluate已经被实现为使用条件表达式的案例分析。这样做的缺点是,我们的函数只处理了一些可区分的语句和表达式类型,而且没有新的类型可以在不编辑evaluate的声明的情况下定义。在大多数解释器实现中,根据组件的类型进行分派是以数据导向的方式进行的。这允许用户添加evaluate可以区分的新类型的组件,而无需修改evaluate本身的声明。(见练习 4.3。)
名称的表示由语法抽象处理。在内部,求值器使用字符串来表示名称,我们将这样的字符串称为符号。函数evaluate中使用的symbol_of_name从名称中提取其表示的符号。
应用
函数apply接受两个参数,一个函数和一个应用该函数的参数列表。函数apply将函数分类为两种:它调用apply_primitive_function来应用原始函数;它通过求值组成函数主体的块来应用复合函数。复合函数的主体的求值环境是通过扩展函数携带的基本环境来构建的,以包括将函数的参数绑定到要应用函数的参数的帧。这是apply的声明:
function apply(fun, args) {
if (is_primitive_function(fun)) {
return apply_primitive_function(fun, args);
} else if (is_compound_function(fun)) {
const result = evaluate(function_body(fun),
extend_environment(
function_parameters(fun),
args,
function_environment(fun)));
return is_return_value(result)
? return_value_content(result)
: undefined;
} else {
error(fun, "unknown function type – apply");
}
}
为了返回一个值,JavaScript 函数需要求值一个返回语句。如果一个函数在不求值返回语句的情况下终止,将返回值undefined。为了区分这两种情况,返回语句的求值将返回表达式的结果包装成一个返回值。如果函数体的求值产生了这样一个返回值,就会检索返回值的内容;否则将返回值undefined。⁶
函数参数
当evaluate处理函数应用时,它使用list_of_values来生成要应用函数的参数列表。函数list_of_values以应用的参数表达式作为参数。它求值每个参数表达式并返回相应值的列表:⁷
function list_of_values(exps, env) {
return map(arg => evaluate(arg, env), exps);
}
条件语句
函数eval_conditional求值给定环境中条件组件的谓词部分。如果结果为真,则求值结果,否则求值替代结果:
function eval_conditional(component, env) {
return is_truthy(evaluate(conditional_predicate(component), env))
? evaluate(conditional_consequent(component), env)
: evaluate(conditional_alternative(component), env);
}
请注意,求值器不需要区分条件表达式和条件语句。
在eval_conditional中使用is_truthy突出了实现语言和实现语言之间的连接问题。conditional_predicate在正在实现的语言中进行求值,因此产生该语言中的一个值。解释器谓词is_truthy将该值转换为可以由实现语言中的条件表达式测试的值:真实的元循环表示可能与底层 JavaScript 的表示不同。⁸
序列
函数eval_sequence由evaluate用于求值顶层或块中的语句序列。它以语句序列和环境作为参数,并按照它们出现的顺序求值这些语句。返回的值是最终语句的值,但如果序列中任何语句的求值产生了返回值,那么将返回该值,并且忽略后续的语句。⁹
function eval_sequence(stmts, env) {
if (is_empty_sequence(stmts)) {
return undefined;
} else if (is_last_statement(stmts)) {
return evaluate(first_statement(stmts), env);
} else {
const first_stmt_value =
evaluate(first_statement(stmts), env);
if (is_return_value(first_stmt_value)) {
return first_stmt_value;
} else {
return eval_sequence(rest_statements(stmts), env);
}
}
}
块
函数eval_block处理块。在块中声明的变量和常量(包括函数)具有整个块作为它们的作用域,因此在求值块的主体之前会“扫描出”它们。块的主体是根据通过将每个本地名称绑定到特殊值"unassigned"来扩展当前环境的环境进行求值。这个字符串作为一个占位符,在声明求值之前,访问名称的值会导致运行时错误(见第 1 章脚注 56 中的练习 4.12)。
function eval_block(component, env) {
const body = block_body(component);
const locals = scan_out_declarations(body);
const unassigneds = list_of_unassigned(locals);
return evaluate(body, extend_environment(locals,
unassigneds,
env));
}
function list_of_unassigned(symbols) {
return map(symbol => "unassigned", symbols);
}
函数scan_out_declarations收集在函数体中声明的所有符号名称的列表。它使用declaration_symbol从找到的声明语句中检索表示名称的符号。
function scan_out_declarations(component) {
return is_sequence(component)
? accumulate(append,
null,
map(scan_out_declarations,
sequence_statements(component)))
: is_declaration(component)
? list(declaration_symbol(component))
: null;
}
我们忽略嵌套在另一个块中的声明,因为该块的求值会处理它们。函数scan_out_declarations只在序列中查找声明,因为条件语句、函数声明和 lambda 表达式中的声明总是在嵌套块中。
返回语句
函数eval_return_statement用于求值返回语句。正如在apply和序列的求值中所看到的,返回语句的求值结果需要是可识别的,以便函数体的求值可以立即返回,即使在返回语句之后还有语句。为此,返回语句的求值将返回表达式的求值结果包装在一个返回值对象中。¹⁰
function eval_return_statement(component, env) {
return make_return_value(evaluate(return_expression(component),
env));
}
赋值和声明
函数eval_assignment处理对名称的赋值。(为了简化我们的求值器的表示,我们不仅允许对变量进行赋值,还允许对常量进行错误的赋值。练习 4.11 解释了我们如何区分常量和变量,并防止对常量进行赋值。)函数eval_assignment调用值表达式上的evaluate来找到要赋值的值,并调用assignment_symbol来检索表示名称的符号。函数eval_assignment将符号和值传递给assign_symbol_value,以安装在指定环境中。赋值的求值返回被赋的值。
function eval_assignment(component, env) {
const value = evaluate(assignment_value_expression(component),
env);
assign_symbol_value(assignment_symbol(component), value, env);
return value;
}
常量和变量声明都由is_declaration语法谓词识别。它们的处理方式类似于赋值,因为eval_block已经将它们的符号绑定到当前环境中的"unassigned"。它们的求值将"unassigned"替换为值表达式的求值结果。
function eval_declaration(component, env) {
assign_symbol_value(
declaration_symbol(component),
evaluate(declaration_value_expression(component), env), env);
return undefined;
}
函数的返回值由return语句确定,因此eval_declaration中的返回值undefined只在声明发生在顶层,即在任何函数体之外时才重要。在这里,我们使用返回值undefined来简化表示;练习 4.8 描述了在 JavaScript 中求值顶层组件的真实结果。
练习 4.1
请注意,我们无法确定元循环求值器是从左到右还是从右到左求值参数表达式。它的求值顺序是从底层 JavaScript 继承的:如果map中pair的参数是从左到右求值的,那么list_of_values将从左到右求值参数表达式;如果pair的参数是从右到左求值的,那么list_of_values将从右到左求值参数表达式。
编写一个list_of_values的版本,无论底层 JavaScript 的求值顺序如何,都从左到右求值参数表达式。还要编写一个list_of_values的版本,从右到左求值参数表达式。
4.1.2 表示组件
程序员将程序编写为文本,即一系列以编程环境或文本编辑器输入的字符。要运行我们的求值器,我们需要从 JavaScript 值开始表示这个程序文本。在 2.3.1 节中,我们介绍了字符串来表示文本。我们希望求值诸如 1.1.2 节中的"const size = 2; 5 * size;"之类的程序。不幸的是,这样的程序文本并不能为求值器提供足够的结构。在这个例子中,程序部分"size = 2"和"5 * size"看起来相似,但含义完全不同。通过检查程序文本来实现抽象语法函数,如declaration_value_expression,将会很困难且容易出错。因此,在本节中,我们引入了一个名为parse的函数,将程序文本转换为标记列表表示,类似于 2.4.2 节的标记数据。例如,对上面的程序字符串应用parse会产生一个反映程序结构的数据结构:一个序列,其中包含一个将名称size与值 2 关联起来的常量声明和一个乘法。
parse("const size = 2; 5 * size;");
list("sequence",
list(list("constant_declaration",
list("name", "size"), list("literal", 2)),
list("binary_operator_combination", "*",
list("literal", 5), list("name", "size"))))
求值器使用的语法函数访问parse产生的标记列表表示。
求值器类似于第 2.3.2 节讨论的符号微分程序。这两个程序都操作符号数据。在这两个程序中,对对象进行操作的结果是通过递归地对对象的部分进行操作,并以一种取决于对象类型的方式将结果组合起来。在这两个程序中,我们使用数据抽象来将操作的一般规则与对象的表示方式分离开来。在微分程序中,这意味着相同的微分函数可以处理前缀形式的代数表达式,中缀形式的代数表达式,或者其他形式的代数表达式。对于求值器,这意味着被求值的语言的语法完全由parse和分类和提取parse产生的标记列表的部分的函数确定。
图 4.2 描述了由语法谓词和选择器形成的抽象屏障,这些语法谓词和选择器将求值器与程序的标记列表表示接口,这又与字符串表示由parse分隔开。下面我们描述程序组件的解析,并列出相应的语法谓词和选择器,以及如果需要的话的构造函数。

图 4.2 求值器中的语法抽象。
文字表达式
文字表达式被解析为带有标签"literal"和实际值的标记列表。
《literal-expression 》 = list("literal", value)
其中value是由literal-expression字符串表示的 JavaScript 值。这里《literal-expression》表示解析字符串literal-expression的结果。
parse("1;");
list("literal", 1)
parse("'hello world';");
list("literal", "hello world")
parse("null;");
list("literal", null)
文字表达式的语法谓词是is_literal。
function is_literal(component) {
return is_tagged_list(component, "literal");
}
它是根据函数is_tagged_list定义的,该函数标识以指定字符串开头的列表:
function is_tagged_list(component, the_tag) {
return is_pair(component) && head(component) === the_tag;
}
解析文字表达式产生的列表的第二个元素是其实际的 JavaScript 值。用于检索值的选择器是literal_value。
function literal_value(component) {
return head(tail(component));
}
literal_value(parse("null;"));
null
在本节的其余部分,我们只列出语法谓词和选择器,并省略它们的声明,如果它们只是访问明显的列表元素。
我们为文字提供了一个构造函数,这将很方便:
function make_literal(value) {
return list("literal", value);
}
名称
名称的标记列表表示包括标签"name"作为第一个元素和表示名称的字符串作为第二个元素。
《 name 》 = list("name", symbol)
其中symbol是一个包含构成程序中name的字符的字符串。名称的语法谓词是is_name。可以使用选择器symbol_of_name访问符号。我们为名称提供了一个构造函数,供operator_combination_to_application使用:
function make_name(symbol) {
return list("name", symbol);
}
表达式语句
我们不需要区分表达式和表达式语句。因此,parse可以忽略这两种组件之间的区别:
《 expression; 》 = 《 expression 》
函数应用
函数应用的解析如下:
《 fun-expr(arg-expr[1], ..., arg-expr[n]) 》=
list("application",
《 fun-expr 》,
list(《 arg-expr[1] 》, ..., 《 arg-expr[n] 》))
我们将is_application声明为语法谓词,function_expression和arg_expressions作为选择器。我们添加了一个函数应用的构造函数,供operator_combination_to_application使用:
function make_application(function_expression, argument_expressions) {
return list("application",
function_expression, argument_expressions);
}
条件
条件表达式的解析如下:
《 predicate ? consequent-expression : alternative-expression 》=
list("conditional_expression",
《 predicate 》,
《 consequent-expression 》,
《 alternative-expression 》)
类似地,条件语句的解析如下:
《 if (predicate) consequent-block else alternative-block 》=
list("conditional_statement",
《 predicate 》,
《 consequent-block 》,
《 alternative-block 》)
语法谓词is_conditional对两种条件都返回true,选择器conditional_predicate,conditional_consequent和conditional_alternative可以应用于两种条件。
Lambda 表达式
解析主体为表达式的 lambda 表达式,就好像主体由包含单个返回语句的块解析,返回表达式是 lambda 表达式的主体。
《 (name[1], ..., name[n]) => expression 》 =
《 (name[1], ..., name[n]) => { return expression ; } 》
解析主体为块的 lambda 表达式如下:
《 (name[1], ..., name[n]) => block 》=
list("lambda_expression",
list(《 name[1] 》, ..., 《 name[n] 》),
《 block 》)
语法谓词是is_lambda_expression,lambda 表达式的主体选择器是lambda_body。称为lambda_parameter_symbols的参数选择器还从名称中提取符号。
function lambda_parameter_symbols(component) {
return map(symbol_of_name, head(tail(component)));
}
函数function_decl_to_constant_decl需要一个 lambda 表达式的构造函数:
function make_lambda_expression(parameters, body) {
return list("lambda_expression", parameters, body);
}
序列
序列语句将一系列语句打包成一个单独的语句。语句序列的解析如下:
《 statement[1] ... statement[n] 》 =
list("sequence", list(《 statement[1] 》, ..., 《 statement[n] 》))
语法谓词是is_sequence,选择器是sequence_statements。我们使用first_statement检索语句列表的第一个语句,使用rest_statements检索剩余的语句。我们使用谓词is_empty_sequence测试列表是否为空,并使用谓词is_last_statement测试列表是否只包含一个元素。¹¹
function first_statement(stmts) { return head(stmts); }
function rest_statements(stmts) { return tail(stmts); }
function is_empty_sequence(stmts) { return is_null(stmts); }
function is_last_statement(stmts) { return is_null(tail(stmts)); }
块
块的解析如下:¹²
《 { statements } 》 = list("block", 《 statements 》 )
这里statements指的是一系列语句,如上所示。语法谓词是is_block,选择器是block_body。
返回语句
返回语句的解析如下:
《 return expression; 》 = list("return_statement", 《 expression 》 )
语法谓词和选择器分别是is_return_statement和return_expression。
赋值
赋值的解析如下:
《 name = expression 》 = list("assignment", 《 name 》 , 《 expression 》 )
语法谓词是is_assignment,选择器是assignment_symbol和assignment_value_expression。符号包装在表示名称的标记列表中,因此assignment_symbol需要将其解包。
function assignment_symbol(component) {
return symbol_of_name(head(tail(component))));
}
常量、变量和函数声明
常量和变量声明的解析如下:
《 const name = expression; 》 =
list("constant_declaration", 《 name 》, 《 expression 》)
《 let name = expression; 》 =
list("variable_declaration", 《 name 》, 《 expression 》)
选择器declaration_symbol和declaration_value_expression适用于两种情况。
function declaration_symbol(component) {
return symbol_of_name(head(tail(component)));
}
function declaration_value_expression(component) {
return head(tail(tail(component)));
}
函数function_decl_to_constant_decl需要一个常量声明的构造函数:
function make_constant_declaration(name, value_expression) {
return list("constant_declaration", name, value_expression);
}
函数声明的解析如下:
function name(name[1], ... name[n]) block 》=
list("function_declaration",
《 name 》,
list(《 name[1] 》, ..., 《 name[n] 》),
《 block 》)
语法谓词is_function_declaration识别这些。选择器是function_declaration_name,function_declaration_parameters和function_declaration_body。
语法谓词is_declaration对所有三种声明返回true。
function is_declaration(component) {
return is_tagged_list(component, "constant_declaration") ||
is_tagged_list(component, "variable_declaration") ||
is_tagged_list(component, "function_declaration");
}
派生组件
我们语言中的一些语法形式可以根据涉及其他语法形式的组件来定义,而不是直接实现。一个例子是函数声明,evaluate将其转换为值表达式为 lambda 表达式的常量声明。¹³
function function_decl_to_constant_decl(component) {
return make_constant_declaration(
function_declaration_name(component),
make_lambda_expression(
function_declaration_parameters(component),
function_declaration_body(component)));
}
以这种方式实现函数声明的求值简化了求值器,因为它减少了必须明确指定求值过程的语法形式的数量。
同样,我们定义操作符组合以函数应用的形式。操作符组合是一元或二元的,并且在标记列表表示中携带其操作符符号作为第二个元素:
《 unary-operator expression 》=
list("unary_operator_combination",
"unary-operator",
list(《 expression 》))
其中*是!(逻辑否定)或-unary(数值否定),并且
《 expression[1] binary-operator expression[2] 》=
list("binary_operator_combination",
"binary-operator",
list(《 expression[1] 》, 《 expression[2] 》))
其中binary-operator是+,-,*,/,%,===,!==,>,<,>=或<=。语法谓词是is_operator_combination,is_unary_operator_combination和is_binary_operator_combination,选择器是operator_symbol,first_operand和second_operand。
求值器使用operator_combination_to_application将操作符组合转换为一个函数应用,其函数表达式是操作符的名称:
function operator_combination_to_application(component) {
const operator = operator_symbol(component);
return is_unary_operator_combination(component)
? make_application(make_name(operator),
list(first_operand(component)))
: make_application(make_name(operator),
list(first_operand(component),
second_operand(component)));
}
我们选择将组件(如函数声明和操作符组合)实现为语法转换的派生组件。逻辑组合操作也是派生组件(参见练习 4.4)。
练习 4.2
parse的逆操作称为unparse。它以parse生成的标记列表作为参数,并返回一个符合 JavaScript 表示的字符串。
-
a. 按照
evaluate的结构(不包括环境参数),编写一个名为unparse的函数,但是产生一个表示给定组件的字符串,而不是对其进行求值。回想一下,从第 3.3.4 节中得知,操作符+可以应用于两个字符串以将它们连接起来,原始函数stringify将值(如 1.5、true、null和undefined)转换为字符串。请注意,通过使用括号(总是或在必要时)括起来,以保持操作符的优先级。 -
b. 在解决本节中的后续练习时,您的
unparse函数会派上用场。通过向结果字符串添加" "(空格)和"\n"(换行)字符,以遵循本书中 JavaScript 程序中使用的缩进样式,改进unparse。为了使文本更易于阅读,向程序文本中添加(或删除)此类空白字符称为美化打印。
练习 4.3
重写evaluate,以便以数据导向的方式进行分派。将此与练习 2.73 中的数据导向微分函数进行比较。 (您可以使用标记列表表示的标记作为组件类型。)
练习 4.4
回想一下,从第 1.1.6 节中得知,逻辑组合操作&&和||是条件表达式的语法糖:逻辑连接expression[1] && expression[2]是expression[1] ? expression[2] : false的语法糖,逻辑析取expression[1] || expression[2]是expression[1] ? true : expression[2]的语法糖。它们的解析如下:
《 expression[1] logical-operation expression[2] 》=
list("logical_composition",
"logical-operation",
list(《 expression[1] 》, 《 expression[2] 》))
其中logical-operation是&&或||。通过声明适当的语法函数和求值函数eval_and和eval_or,将&&和||作为求值器的新语法形式。或者,展示如何将&&和||实现为派生组件。
练习 4.5
-
a. 在 JavaScript 中,lambda 表达式不能具有重复参数。第 4.1.1 节中的求值器没有检查这一点。
-
修改求值器,以便任何尝试应用具有重复参数的函数都会发出错误信号。
-
实现一个
verify函数,检查给定程序中的任何 lambda 表达式是否包含重复参数。有了这样一个函数,我们可以在将其传递给evaluate之前检查整个程序。
为了在 JavaScript 的求值器中实现此检查,您更喜欢这两种方法中的哪一种?为什么?
-
-
b. 在 JavaScript 中,lambda 表达式的参数必须与 lambda 表达式的主体块中直接声明的名称不同(而不是在内部块中)。使用上面的首选方法来检查这一点。
练习 4.6
Scheme 语言包括一个名为let的变体。我们可以通过规定let声明隐式引入一个新的块,该块的主体包括声明和声明出现的语句序列中的所有后续语句,来近似 JavaScript 中let的行为。例如,程序
let* x = 3;
let* y = x + 2;
let* z = x + y + 5;
display(x * z);
显示 39 并且可以被视为一种简写
{
let x = 3;
{
let y = x + 2;
{
let z = x + y + 5;
display(x * z);
}
}
}
-
a. 在这样一个扩展的 JavaScript 语言中编写一个程序,当一些关键字
let的出现被替换为let*时,其行为会有所不同。 -
b. 通过设计合适的标记列表表示并编写解析规则,将
let*引入为一个新的语法形式。声明标记列表表示的语法谓词和选择器。 -
c. 假设
parse实现了您的新规则,请编写一个let_star_to_nested_let函数,以转换给定程序中的任何let*的出现,如上所述。然后,通过运行evaluate(let_star_to_nested_let(p))来求值扩展语言中的程序p。 -
d. 作为一种替代方案,考虑通过向
evaluate添加一个子句来实现let*,该子句识别新的语法形式并调用一个名为eval_let_star_declaration的函数。为什么这种方法行不通?
练习 4.7
JavaScript 支持重复执行给定语句的while循环。具体来说,
while (predicate) { body }
求值predicate,如果结果为true,则求值body,然后再次求值整个while循环。一旦predicate求值为false,while循环终止。
例如,回想一下第 3.1.3 节中迭代阶乘函数的命令式版本:
function factorial(n) {
let product = 1;
let counter = 1;
function iter() {
if (counter > n) {
return product;
} else {
product = counter * product;
counter = counter + 1;
return iter();
}
}
return iter();
}
我们可以使用while循环来制定相同的算法,如下所示:
function factorial(n) {
let product = 1;
let counter = 1;
while (counter <= n) {
product = counter * product;
counter = counter + 1;
}
return product;
}
当循环被解析如下:
《 while (predicate) block 》 =
list("while_loop", 《 predicate 》, 《 block 》)
-
a. 声明一个语法谓词和选择器来处理
while循环。 -
b. 声明一个名为
while_loop的函数,该函数接受谓词和主体作为参数,每个参数由一个没有参数的函数表示,并模拟while循环的行为。然后factorial函数如下所示:function factorial(n) { let product = 1; let counter = 1; while_loop(() => counter <= n, () => { product = counter * product; counter = counter + 1; }); return product; }你的
while_loop函数应该生成一个迭代过程(参见第 1.2.1 节)。 -
c. 通过定义一个转换函数
while_to_application,将while循环安装为一个派生组件,利用你的while_loop函数。 -
d. 当程序员在循环的主体内决定从包含循环的函数返回时,使用这种方法实现
while循环会出现什么问题? -
e. 改变你的方法来解决这个问题。直接为求值器安装
while循环,使用一个名为eval_while的函数如何? -
f. 遵循这种直接的方法,实现一个
break;语句,它立即终止它所在的循环。 -
g. 实现一个
predicate;语句,它只终止它所在的循环迭代,并继续求值while循环的谓词。
练习 4.8
函数主体的求值结果由其返回语句确定。继续参考脚注 9 和第 4.1.1 节中声明的求值,这个练习解决了一个问题,即由一系列语句(声明、块、表达式语句和条件语句)组成的 JavaScript 程序在任何函数主体之外的情况下应该是什么结果。
对于这样的程序,JavaScript 在产生值和不产生值的语句之间进行静态区分。(这里的“静态”意味着我们可以通过检查程序而不是运行它来进行区分。)所有声明都不产生值,所有表达式语句和条件语句都产生值。表达式语句的值是表达式的值。条件语句的值是执行的分支的值,如果该分支不产生值,则值为undefined。如果块的主体(语句序列)是产生值的,则块是产生值的,然后它的值是其主体的值。如果序列的任何组成语句是产生值的,则序列是产生值的,然后它的值是其最后产生值的组成语句的值。最后,如果整个程序不产生值,则其值为undefined。
-
a. 根据这个规范,以下四个程序的值是什么?
1; 2; 3; 1; { if (true) {} else { 2; } } 1; const x = 2; 1; { let x = 2; { x = x + 3; } } -
b. 修改求值器以符合这个规范。
4.1.3 求值器数据结构
除了定义组件的表示形式之外,求值器实现还必须定义求值器在程序执行过程中内部操作的数据结构,例如函数和环境的表示以及true和false的表示。
谓词的测试
为了将条件语句的谓词限制为适当的谓词(求值为布尔值的表达式),我们坚持要求is_truthy函数只应用于布尔值,并且我们只接受布尔值true为真值。is_truthy的相反称为is_falsy。
function is_truthy(x) {
return is_boolean(x)
? x
: error(x, "boolean expected, received");
}
function is_falsy(x) { return ! is_truthy(x); }
表示函数
为了处理原始数据,我们假设有以下函数可用:
-
apply_primitive_function(fun, args)将给定的原始函数应用于列表
args中的参数值,并返回应用的结果。 -
is_primitive_function(fun)测试
fun是否为原始函数。
这些处理原始数据的机制在 4.1.4 节中进一步描述。
使用构造函数make_function构建复合函数,由参数、函数体和环境组成:
function make_function(parameters, body, env) {
return list("compound_function", parameters, body, env);
}
function is_compound_function(f) {
return is_tagged_list(f, "compound_function");
}
function function_parameters(f) { return list_ref(f, 1); }
function function_body(f) { return list_ref(f, 2); }
function function_environment(f) { return list_ref(f, 3); }
表示返回值
我们在 4.1.1 节中看到,当遇到return语句时,序列的求值终止,如果函数体的求值没有遇到return语句,则函数应用的求值需要返回值undefined。为了识别返回语句导致的值,我们引入返回值作为求值器数据结构。
function make_return_value(content) {
return list("return_value", content);
}
function is_return_value(value) {
return is_tagged_list(value, "return_value");
}
function return_value_content(value) {
return head(tail(value));
}
环境操作
求值器需要操作来操作环境。如 3.2 节所述,环境是帧的序列,其中每个帧都是将符号与其对应值关联的绑定表。我们使用以下操作来操作环境:
-
lookup_symbol_value(symbol, env)返回在环境
env中绑定到symbol的值,如果symbol未绑定,则发出错误。 -
extend_environment(symbols, values, base-env)返回一个新环境,由一个新帧组成,其中列表
symbols中的符号绑定到列表values中的相应元素,封闭环境是环境base-env。 -
assign_symbol_value(symbol, value, env)找到
env中symbol绑定的最内层帧,并更改该帧,使symbol现在绑定到value,如果symbol未绑定,则发出错误。
为了实现这些操作,我们将环境表示为帧的列表。环境的封闭环境是列表的tail。空环境就是空列表。
function enclosing_environment(env) { return tail(env); }
function first_frame(env) { return head(env); }
const the_empty_environment = null;
每个环境的帧都表示为两个列表的对:一个是该帧中绑定的名称列表,另一个是相关值的列表。
function make_frame(symbols, values) { return pair(symbols, values); }
function frame_symbols(frame) { return head(frame); }
function frame_values(frame) { return tail(frame); }
通过将符号与值关联的新帧扩展环境,我们将一个由符号列表和值列表组成的帧添加到环境中。如果符号的数量与值的数量不匹配,则发出错误。
function extend_environment(symbols, vals, base_env) {
return length(symbols) === length(vals)
? pair(make_frame(symbols, vals), base_env)
: error(pair(symbols, vals),
length(symbols) < length(vals)
? "too many arguments supplied"
: "too few arguments supplied");
}
这是在 4.1.1 节中由apply使用的,将函数的参数绑定到其参数。
要在环境中查找符号,我们扫描第一个帧中的符号列表。如果找到所需的符号,我们返回值列表中的相应元素。如果在当前帧中找不到符号,则搜索封闭环境,依此类推。如果达到空环境,则发出"未绑定的名称"错误。
function lookup_symbol_value(symbol, env) {
function env_loop(env) {
function scan(symbols, vals) {
return is_null(symbols)
? env_loop(enclosing_environment(env))
: symbol === head(symbols)
? head(vals)
: scan(tail(symbols), tail(vals));
}
if (env === the_empty_environment) {
error(symbol, "unbound name");
} else {
const frame = first_frame(env);
return scan(frame_symbols(frame), frame_values(frame));
}
}
return env_loop(env);
}
要在指定的环境中为符号分配新值,我们扫描符号,就像在lookup_symbol_value中一样,并在找到时更改相应的值。
function assign_symbol_value(symbol, val, env) {
function env_loop(env) {
function scan(symbols, vals) {
return is_null(symbols)
? env_loop(enclosing_environment(env))
: symbol === head(symbols)
? set_head(vals, val)
: scan(tail(symbols), tail(vals));
}
if (env === the_empty_environment) {
error(symbol, "unbound name – assignment");
} else {
const frame = first_frame(env);
return scan(frame_symbols(frame), frame_values(frame));
}
}
return env_loop(env);
}
这里描述的方法只是表示环境的许多合理方法中的一种。由于我们使用数据抽象来将求值器的其余部分与表示的详细选择隔离开来,如果需要,我们可以更改环境表示。 (见练习 4.9。)在生产质量的 JavaScript 系统中,求值器环境操作的速度,特别是符号查找的速度,对系统的性能有重大影响。这里描述的表示虽然在概念上很简单,但并不高效,通常不会在生产系统中使用。¹⁶
练习 4.9
我们可以将框架表示为绑定的列表,其中每个绑定都是一个符号-值对,而不是将框架表示为列表对。重写环境操作以使用这种替代表示。
练习 4.10
函数lookup_symbol_value和assign_symbol_value可以用更抽象的函数来表达环境结构的遍历。定义一个捕获常见模式的抽象,并根据这个抽象重新定义这两个函数。
练习 4.11
我们的语言通过使用不同的关键字const和let区分常量和变量,并阻止对常量进行赋值。然而,我们的解释器并没有利用这种区别;函数assign_symbol_value将愉快地为给定的符号分配一个新值,而不管它是作为常量还是变量声明的。通过在尝试在赋值的左侧使用常量时调用函数error来纠正这个缺陷。您可以按照以下步骤进行:
-
引入谓词
is_constant_declaration和is_variable_declaration,允许您区分这两种类型。如 4.1.2 节所示,parse通过使用标签"constant_declaration"和"variable_declaration"来区分它们。 -
更改
scan_out_declarations和(如果必要)extend_environment,使常量在绑定它们的框架中与变量区分开来。 -
更改
assign_symbol_value,使其检查给定的符号是作为变量还是常量声明的,并在后一种情况下发出错误信号,不允许对常量进行赋值操作。 -
更改
eval_declaration,使其在遇到常量声明时调用一个新函数assign_constant_value,该函数不执行您在assign_symbol_value中引入的检查。 -
如果需要,更改
apply以确保仍然可以对函数参数进行赋值。
练习 4.12
-
a. JavaScript 的规范要求实现在尝试访问名称的值之前对其声明进行求值时发出运行时错误(请参见 3.2.4 节的末尾)。为了在求值器中实现这种行为,更改
lookup_symbol_value,如果它找到的值是"unassigned",则发出错误信号。 -
b.同样,如果我们尚未求值其
let声明,我们就不应该为变量分配新值。更改赋值的求值,以便在这种情况下,对使用let声明的变量进行赋值会发出错误信号。
练习 4.13
在我们在本书中使用的 ECMAScript 2015 的严格模式之前,JavaScript 变量的工作方式与 Scheme 变量有很大不同,这将使得将 Scheme 适应到 JavaScript 的工作变得不那么引人注目。
-
a.在 ECMAScript 2015 之前,JavaScript 中声明局部变量的唯一方法是使用关键字
var而不是关键字let。使用var声明的变量的作用域是立即周围的函数声明或 lambda 表达式的整个主体,而不仅仅是立即封闭的块。修改scan_out_declarations和eval_block,使得使用const和let声明的名称遵循var的作用域规则。 -
b. 在非严格模式下,JavaScript 允许未声明的名称出现在赋值语句的
=左侧。这样的赋值会将新的绑定添加到全局环境中。修改函数assign_symbol_value使赋值行为如此。严格模式禁止这样的赋值,旨在使程序更安全。通过阻止赋值向全局环境添加绑定来解决了什么安全问题?
4.1.4 作为程序运行求值器
有了求值器,我们手头上有了一个描述(用 JavaScript 表达)JavaScript 语句和表达式如何被求值的过程。将求值器表达为程序的一个优点是我们可以运行这个程序。这使我们在 JavaScript 中运行时,得到了 JavaScript 本身如何求值表达式的工作模型。这可以作为实验求值规则的框架,正如我们将在本章后面所做的那样。
我们的求值器程序最终将表达式简化为原始函数的应用。因此,我们运行求值器所需要的就是创建一个机制,调用底层 JavaScript 系统来模拟原始函数的应用。
每个原始函数名称和运算符都必须有一个绑定,这样当evaluate求值原始应用的函数表达式时,它将找到一个对象传递给apply。因此,我们建立了一个全局环境,将唯一对象与原始函数和运算符的名称相关联,这些名称可以出现在我们将要求值的表达式中。全局环境还包括undefined和其他名称的绑定,以便它们可以在要求值的表达式中用作常量。
function setup_environment() {
return extend_environment(append(primitive_function_symbols,
primitive_constant_symbols),
append(primitive_function_objects,
primitive_constant_values),
the_empty_environment);
}
const the_global_environment = setup_environment();
我们如何表示原始函数对象并不重要,只要apply能够使用is_primitive_function和apply_primitive_function函数识别和应用它们。我们选择将原始函数表示为以字符串"primitive"开头并包含在底层 JavaScript 中实现该原始函数的函数的列表。
function is_primitive_function(fun) {
return is_tagged_list(fun, "primitive");
}
function primitive_implementation(fun) { return head(tail(fun)); }
函数setup_environment将从列表中获取原始名称和实现函数:¹⁷
const primitive_functions = list(list("head", head ),
list("tail", tail ),
list("pair", pair ),
list("is_null", is_null ),
list("+", (x, y) => x + y ),
〈more primitive functions〉
);
const primitive_function_symbols =
map(f => head(f), primitive_functions);
const primitive_function_objects =
map(f => list("primitive", head(tail(f))),
primitive_functions);
与原始函数类似,我们通过函数setup_environment在全局环境中定义其他原始常量。
const primitive_constants = list(list("undefined", undefined),
list("math_PI", math_PI)
〈more primitive constants〉
);
const primitive_constant_symbols =
map(c => head(c), primitive_constants);
const primitive_constant_values =
map(c => head(tail(c)), primitive_constants);
要应用原始函数,我们只需使用底层 JavaScript 系统将实现函数应用于参数:¹⁸
function apply_primitive_function(fun, arglist) {
return apply_in_underlying_javascript(
primitive_implementation(fun), arglist);
}
为了方便运行元循环求值器,我们提供了一个驱动循环,模拟了底层 JavaScript 系统的读取-求值-打印循环。它打印一个提示符并将输入程序读取为一个字符串。它将程序字符串转换为标记列表表示的语句,如 4.1.2 节所述的过程,称为解析,由原始函数parse完成。我们在每个打印的结果之前加上一个输出提示,以区分程序的值和可能打印的其他输出。驱动循环获取前一个程序的程序环境作为参数。如 3.2.4 节末尾所述,驱动循环将程序视为在一个块中:它扫描出声明,通过包含每个名称绑定到"unassigned"的框架扩展给定的环境,并根据扩展的环境求值程序,然后将其作为参数传递给驱动循环的下一次迭代。
const input_prompt = "M-evaluate input: ";
const output_prompt = "M-evaluate value: ";
function driver_loop(env) {
const input = user_read(input_prompt);
if (is_null(input)) {
display("evaluator terminated");
} else {
const program = parse(input);
const locals = scan_out_declarations(program);
const unassigneds = list_of_unassigned(locals);
const program_env = extend_environment(locals, unassigneds, env);
const output = evaluate(program, program_env);
user_print(output_prompt, output);
return driver_loop(program_env);
}
}
我们使用 JavaScript 的prompt函数从用户那里请求并读取输入字符串:
function user_read(prompt_string) {
return prompt(prompt_string);
}
当用户取消输入时,函数prompt返回null。我们使用一个特殊的打印函数user_print,以避免打印复合函数的环境部分,这可能是一个非常长的列表(甚至可能包含循环)。
function user_print(string, object) {
function prepare(object) {
return is_compound_function(object)
? "< compound-function >"
: is_primitive_function(object)
? "< primitive-function >"
: is_pair(object)
? pair(prepare(head(object)),
prepare(tail(object)))
: object;
}
display(string + " " + stringify(prepare(object)));
}
现在我们需要做的就是初始化全局环境并启动驱动程序循环来运行求值器。以下是一个示例交互:
const the_global_environment = setup_environment();
driver_loop(the_global_environment);
M-求值输入:
function append(xs, ys) {
return is_null(xs)
? ys
: pair(head(xs), append(tail(xs), ys));
}
M-求值值:
undefined
M-求值输入:
append(list("a", "b", "c"), list("d", "e", "f"));
M-求值值:
["a", ["b", ["c", ["d", ["e", ["f", null]]]]]]
练习 4.14
Eva Lu Ator 和 Louis Reasoner 各自对元循环求值器进行实验。Eva 输入了map的定义,并运行了一些使用它的测试程序。它们都很好。相比之下,Louis 安装了map的系统版本作为元循环求值器的原语。当他尝试时,事情变得非常糟糕。解释为什么 Louis 的map失败,即使 Eva 的工作正常。
4.1.5 数据作为程序
在考虑一个求值 JavaScript 语句和表达式的 JavaScript 程序时,类比可能会有所帮助。程序含义的一个操作视图是,程序是对一个抽象(也许是无限大的)机器的描述。例如,考虑计算阶乘的熟悉程序:
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
我们可以将这个程序看作是一个包含递减、乘法和相等测试部分的机器的描述,还有一个两位置开关和另一个阶乘机器。(阶乘机器是无限的,因为它包含另一个阶乘机器。)图 4.3 是阶乘机器的流程图,显示了部件如何连接在一起。

图 4.3 阶乘程序,视为一个抽象机器。
以类似的方式,我们可以将求值器视为一个非常特殊的机器,它以描述一个机器作为输入。根据这个输入,求值器配置自身以模拟所描述的机器。例如,如果我们向求值器提供factorial的定义,如图 4.4 所示,求值器将能够计算阶乘。

图 4.4 求值器模拟阶乘机器。
从这个角度来看,我们的求值器被视为通用机器。当这些机器被描述为 JavaScript 程序时,它模仿其他机器。¹⁹这是令人震惊的。试着想象一个类似的电路求值器。这将是一个电路,它以编码其他电路计划的信号作为输入,比如一个滤波器。给定这个输入,电路求值器将表现得像一个具有相同描述的滤波器。这样一个通用电路几乎是难以想象的复杂。值得注意的是,程序求值器是一个相当简单的程序。²⁰
求值器的另一个引人注目的方面是,它充当了我们编程语言中操作的数据对象和编程语言本身之间的桥梁。想象一下,求值器程序(用 JavaScript 实现)正在运行,用户正在向求值器输入程序并观察结果。从用户的角度来看,输入程序如x * x;是编程语言中的一个程序,求值器应该执行它。然而,从求值器的角度来看,程序只是一个字符串,或者在解析后是一个标记列表表示,根据一套明确定义的规则进行操作。
用户的程序是求值器的数据并不一定会引起混淆。事实上,有时忽略这种区别并给用户明确地将一个字符串作为 JavaScript 语句进行求值的能力是很方便的,使用 JavaScript 的原始函数eval,它以字符串作为参数。它解析字符串,并且——只要它在语法上是正确的——在eval应用的环境中求值所得到的表示。因此,
eval("5 * 5;");
和
evaluate(parse("5 * 5;"), the_global_environment);
都将返回 25。²¹
练习 4.15
给定一个一参数函数f和一个对象a,如果求值表达式f(a)返回一个值(而不是以错误消息终止或永远运行),则称f在a上“停止”。证明不可能编写一个函数halts,它可以正确地确定对于任何函数f和对象a,f是否在a上停止。使用以下推理:如果你有这样一个函数halts,你可以实现以下程序:
function run_forever() { return run_forever(); }
function strange(f) {
return halts(f, f)
? run_forever();
: "halted";
}
现在考虑求值表达式strange(strange)并展示任何可能的结果(无论是停止还是永远运行)都违反了halts的预期行为。
4.1.6 内部声明
在 JavaScript 中,声明的作用域是紧邻声明的整个块,而不仅仅是从声明发生的地方开始的块的部分。本节将更详细地讨论这个设计选择。
让我们重新审视第 3.2.4 节中在函数f的主体中本地声明的相互递归函数is_even和is_odd。
function f(x) {
function is_even(n) {
return n === 0
? true
: is_odd(n - 1);
}
function is_odd(n) {
return n === 0
? false
: is_even(n - 1);
}
return is_even(x);
}
我们的意图是,函数is_even主体中的名称is_odd应该指的是在is_even之后声明的函数is_odd。名称is_odd的作用域是f的整个主体块,而不仅仅是从is_odd的声明发生的地方开始的f主体的部分。事实上,当我们考虑is_odd本身是根据is_even定义的时候——所以is_even和is_odd是相互递归的函数——我们看到这两个声明的唯一令人满意的解释是将它们视为is_even和is_odd同时添加到环境中。更一般地,在块结构中,局部名称的作用域是在求值声明的整个块中。
在第 4.1.1 节的元循环求值器中,块的求值通过扫描块中的声明并使用包含所有声明名称绑定的帧扩展当前环境来实现局部名称的同时作用域。因此,在求值块体的新环境中已经包含了is_even和is_odd的绑定,任何一个这些名称的出现都指向正确的绑定。一旦它们的声明被求值,这些名称就绑定到它们声明的值,即具有扩展环境作为环境部分的函数对象。因此,例如,当is_even在f的主体中被应用时,它的环境已经包含了符号is_odd的正确绑定,而在is_even的主体中求值名称is_odd会检索到正确的值。
练习 4.16
考虑第 1.3.2 节中的函数f_3:
function f_3(x, y) {
const a = 1 + x * y;
const b = 1 - y;
return x * square(a) + y * b + a * b;
}
-
a. 绘制在求值
f_3的返回表达式期间生效的环境的图表。 -
b. 在求值函数应用时,求值器创建两个帧:一个用于参数,一个用于在函数的主体块中直接声明的名称,而不是在内部块中声明的名称。由于所有这些名称具有相同的作用域,一个实现可以合并这两个帧。更改求值器,使得对主体块的求值不会创建新的帧。您可以假设这不会导致帧中出现重复的名称(练习 4.5 证明了这一点)。
练习 4.17
Eva Lu Ator 正在编写程序,其中函数声明和其他语句是交错的。她需要确保在应用函数之前对声明进行求值。她抱怨道:“为什么求值器不能处理这个琐事,并且将所有函数声明提升到它们出现的块的开头?块外的函数声明应该提升到程序的开头。”
-
a. 修改求值器以遵循 Eva 的建议。
-
b. JavaScript 的设计者决定遵循 Eva 的方法。讨论这个决定。
-
c. 此外,JavaScript 的设计者决定允许使用赋值重新分配函数声明的名称。相应地修改您的解决方案并讨论这一决定。
练习 4.18
在我们的解释器中,递归函数是通过一种迂回的方式获得的:首先声明将引用递归函数的名称,并将其分配给特殊值"unassigned";然后在该名称的范围内定义递归函数;最后将定义的函数分配给名称。当递归函数被应用时,主体中名称的任何出现都会正确地引用递归函数。令人惊讶的是,可以在不使用声明或赋值的情况下指定递归函数。以下程序通过应用递归阶乘函数计算 10 的阶乘:²³
(n => (fact => fact(fact, n))
((ft, k) => k === 1
? 1
: k * ft(ft, k - 1)))(10);
-
a. 通过求值表达式来检查这确实计算了阶乘。为计算斐波那契数设计一个类似的表达式。
-
b. 考虑上面给出的函数
f:function f(x) { function is_even(n) { return n === 0 ? true : is_odd(n - 1); } function is_odd(n) { return n === 0 ? false : is_even(n - 1); } return is_even(x); }填写缺失的表达式以完成对
f的替代声明,该声明没有内部函数声明:function f(x) { return ((is_even, is_odd) => is_even(is_even, is_odd, x)) ((is_ev, is_od, n) => n === 0 ? true : is_od(〈??〉, 〈??〉, 〈??〉), (is_ev, is_od, n) => n === 0 ? false : is_ev( 〈??〉, 〈??〉, 〈??〉)); }
顺序声明处理
我们 4.1.1 节的求值器设计对块的求值施加了运行时负担:它需要扫描块的主体以查找本地声明的名称,使用绑定这些名称的新框架扩展当前环境,并在此扩展环境中求值块主体。或者,块的求值可以使用空框架扩展当前环境。然后,块主体中每个声明的求值将向该框架添加一个新的绑定。为了实现这一设计,我们首先简化eval_block:
function eval_block(component, env) {
const body = block_body(component);
return evaluate(body, extend_environment(null, null, env);
}
函数eval_declaration不再能假定环境已经为该名称绑定。它不再使用assign_symbol_value来更改现有绑定,而是调用一个新函数add_binding_to_frame,将名称绑定到值表达式的值的第一个框架中的环境中。
function eval_declaration(component, env) {
add_binding_to_frame(
declaration_symbol(component),
evaluate(declaration_value_expression(component), env),
first_frame(env));
return undefined;
}
function add_binding_to_frame(symbol, value, frame) {
set_head(frame, pair(symbol, head(frame)));
set_tail(frame, pair(value, tail(frame)));
}
顺序声明处理后,声明的范围不再是直接包围声明的整个块,而只是从声明发生的地方开始的块的一部分。尽管我们不再具有同时的范围,但顺序声明处理将正确地求值本节开头的函数f的调用,但出于“意外”的原因:由于内部函数的声明首先出现,直到所有这些函数都声明完毕之前,不会求值对这些函数的任何调用。因此,is_odd在执行is_even时已经被声明。实际上,对于任何内部声明首先出现在主体中且声明的值表达式的求值实际上不使用任何声明的名称的函数,顺序声明处理将给出与我们在 4.1.1 节中的扫描名称求值器相同的结果。练习 4.19 展示了一个不遵守这些限制的函数的示例,因此替代求值器与我们的扫描名称求值器并不等价。
顺序声明处理比扫描名称更高效且更易于实现。但是,使用顺序处理时,名称引用的声明可能取决于求值块中语句的顺序。在练习 4.19 中,我们看到对于是否希望这样做的观点可能会有不同。
练习 4.19
Ben Bitdiddle,Alyssa P. Hacker 和 Eva Lu Ator 正在就求值程序的期望结果进行争论
const a = 1;
function f(x) {
const b = a + x;
const a = 5;
return a + b;
}
f(10);
Ben 断言应该使用声明的顺序处理结果:b被声明为 11,然后a被声明为 5,因此结果是 16。Alyssa 反对相互递归需要内部函数声明的同时作用规则,并且认为将函数名称与其他名称区别对待是不合理的。因此,她主张在第 4.1.1 节中实现的机制。这将导致在计算b的值时,a尚未被赋值。因此,在 Alyssa 看来,该函数应该产生错误。Eva 有第三种观点。她说,如果a和b的声明确实是同时的,那么在计算b时应该使用a的值 5。因此,在 Eva 看来,a应该是 5,b应该是 15,结果应该是 20。你支持这些观点中的哪一个(如果有的话)?你能想出一种实现内部声明的方法,使其符合 Eva 的期望吗?
4.1.7 将语法分析与执行分离
上面实现的求值器很简单,但非常低效,因为组件的语法分析与其执行交织在一起。因此,如果一个程序被执行多次,它的语法将被分析多次。例如,考虑使用以下factorial定义来求值factorial(4):
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
每次调用factorial时,求值器必须确定函数体是条件表达式并提取谓词。只有这样才能求值谓词并根据其值进行分派。每次求值表达式factorial(n - 1) * n或子表达式factorial(n - 1)和n - 1时,求值器必须执行evaluate中的情况分析,以确定表达式是一个应用程序,并必须提取其函数表达式和参数表达式。这种分析是昂贵的。重复执行它是浪费的。
我们可以通过安排事物,使得语法分析只执行一次,从而使求值器变得更加高效。我们将evaluate分成两部分,analyze函数只接受组件。它执行语法分析并返回一个新函数,执行函数,它封装了执行分析组件所需的工作。执行函数以环境作为参数并完成求值。这样做可以节省工作,因为analyze只会在组件上调用一次,而执行函数可能会被多次调用。
通过将分析和执行分开,evaluate现在变成了
function evaluate(component, env) {
return analyze(component)(env);
}
调用analyze的结果是要应用于环境的执行函数。analyze函数与第 4.1.1 节中的原始evaluate执行的情况分析相同,只是我们分派的函数只执行分析,而不是完全求值。
function analyze(component) {
return is_literal(component)
? analyze_literal(component)
: is_name(component)
? analyze_name(component)
: is_application(component)
? analyze_application(component)
: is_operator_combination(component)
? analyze(operator_combination_to_application(component))
: is_conditional(component)
? analyze_conditional(component)
: is_lambda_expression(component)
? analyze_lambda_expression(component)
: is_sequence(component)
? analyze_sequence(sequence_statements(component))
: is_block(component)
? analyze_block(component)
: is_return_statement(component)
? analyze_return_statement(component)
: is_function_declaration(component)
? analyze(function_decl_to_constant_decl(component))
: is_declaration(component)
? analyze_declaration(component)
: is_assignment(component)
? analyze_assignment(component)
: error(component, "unknown syntax – analyze");
}
这是最简单的语法分析函数,处理文字表达式。它返回一个执行函数,忽略其环境参数,只返回文字的值。
function analyze_literal(component) {
return env => literal_value(component);
}
查找名称的值仍然必须在执行阶段完成,因为这取决于知道环境。
function analyze_name(component) {
return env => lookup_symbol_value(symbol_of_name(component), env);
}
分析一个应用程序,我们分析函数表达式和参数表达式,并构造一个执行函数,该函数调用函数表达式的执行函数(以获取要应用的实际函数)和参数表达式的执行函数(以获取实际参数)。然后我们将这些传递给execute_application,这类似于第 4.1.1 节中的apply。execute_application函数与apply不同之处在于,复合函数的函数体已经被分析过,因此不需要进行进一步的分析。相反,我们只需在扩展环境上调用函数体的执行函数。
function analyze_application(component) {
const ffun = analyze(function_expression(component));
const afuns = map(analyze, arg_expressions(component));
return env => execute_application(ffun(env),
map(afun => afun(env), afuns));
}
function execute_application(fun, args) {
if (is_primitive_function(fun)) {
return apply_primitive_function(fun, args);
} else if (is_compound_function(fun)) {
const result = function_body(fun)
(extend_environment(function_parameters(fun),
args,
function_environment(fun)));
return is_return_value(result)
? return_value_content(result)
: undefined;
} else {
error(fun, "unknown function type – execute_application");
}
}
对于条件语句,我们在分析时提取并分析谓词、结果和替代。
function analyze_conditional(component) {
const pfun = analyze(conditional_predicate(component));
const cfun = analyze(conditional_consequent(component));
const afun = analyze(conditional_alternative(component));
return env => is_truthy(pfun(env)) ? cfun(env) : afun(env);
}
分析 lambda 表达式也实现了效率的主要提升:我们只对 lambda 主体进行一次分析,即使由 lambda 表达式的求值产生的函数可能被多次应用。
function analyze_lambda_expression(component) {
const params = lambda_parameter_symbols(component);
const bfun = analyze(lambda_body(component));
return env => make_function(params, bfun, env);
}
对一系列语句的分析更为复杂。序列中的每个语句都经过分析,产生一个执行函数。这些执行函数组合在一起,产生一个接受环境作为参数并按顺序调用每个单独执行函数的执行函数。
function analyze_sequence(stmts) {
function sequentially(fun1, fun2) {
return env => {
const fun1_val = fun1(env);
return is_return_value(fun1_val)
? fun1_val
: fun2(env);
};
}
function loop(first_fun, rest_funs) {
return is_null(rest_funs)
? first_fun
: loop(sequentially(first_fun, head(rest_funs)),
tail(rest_funs));
}
const funs = map(analyze, stmts);
return is_null(funs)
? env => undefined
: loop(head(funs), tail(funs));
}
块的主体只被扫描一次以获取局部声明。当调用块的执行函数时,这些绑定将被安装在环境中。
function analyze_block(component) {
const body = block_body(component);
const bfun = analyze(body);
const locals = scan_out_declarations(body);
const unassigneds = list_of_unassigned(locals);
return env => bfun(extend_environment(locals, unassigneds, env));
}
对于返回语句,我们分析返回表达式。返回语句的执行函数只是调用返回表达式的执行函数,并将结果包装在返回值中。
function analyze_return_statement(component) {
const rfun = analyze(return_expression(component));
return env => make_return_value(rfun(env));
}
函数analyze_assignment必须推迟实际设置变量,直到执行时才会提供环境。然而,分析赋值值表达式(递归地)在分析期间是效率的主要提升,因为赋值值表达式现在只会被分析一次。对于常量和变量声明也是如此。
function analyze_assignment(component) {
const symbol = assignment_symbol(component);
const vfun = analyze(assignment_value_expression(component));
return env => {
const value = vfun(env);
assign_symbol_value(symbol, value, env);
return value;
};
}
function analyze_declaration(component) {
const symbol = declaration_symbol(component);
const vfun = analyze(declaration_value_expression(component));
return env => {
assign_symbol_value(symbol, vfun(env), env);
return undefined;
};
}
我们的新求值器使用与 4.1.2、4.1.3 和 4.1.4 节中相同的数据结构、语法函数和运行时支持函数。
练习 4.20
扩展本节中的求值器以支持while循环。(见练习 4.7。)
练习 4.21
Alyssa P. Hacker 不明白为什么analyze_sequence需要这么复杂。所有其他分析函数都是相应求值函数(或 4.1.1 节中的evaluate子句)的直接转换。她期望analyze_sequence看起来像这样:
function analyze_sequence(stmts) {
function execute_sequence(funs, env) {
if (is_null(funs)) {
return undefined;
} else if (is_null(tail(funs))) {
return head(funs)(env);
} else {
const head_val = head(funs)(env);
return is_return_value(head_val)
? head_val
: execute_sequence(tail(funs), env);
}
}
const funs = map(analyze, stmts);
return env => execute_sequence(funs, env);
}
Eva Lu Ator 向 Alyssa 解释,文本中的版本在分析时更多地求值了序列的工作。Alyssa 的序列执行函数不是内置调用各个执行函数,而是按顺序循环调用这些函数:实际上,尽管序列中的各个语句已经被分析,但序列本身还没有被分析。
比较analyze_sequence的两个版本。例如,考虑常见情况(函数体的典型情况),即序列只有一个语句。Alyssa 程序生成的执行函数会做什么工作?上述文本中程序生成的执行函数又会做什么工作?这两个版本在包含两个表达式的序列中如何比较?
练习 4.22
设计并进行一些实验,比较原始的元循环求值器与本节中的版本的速度。使用你的结果来估计在各种函数中分析与执行所花费的时间比例。
4.2 惰性求值
现在我们有了一个表达为 JavaScript 程序的求值器,我们可以通过修改求值器来实验语言设计的替代选择。事实上,新语言通常是通过首先编写一个将新语言嵌入到现有高级语言中的求值器来发明的。例如,如果我们希望与 JavaScript 社区的其他成员讨论对 JavaScript 的某个修改方面,我们可以提供一个体现了这种改变的求值器。接收者可以使用新的求值器进行实验,并发送评论作为进一步的修改。高级实现基础不仅使得测试和调试求值器更容易;此外,嵌入使得设计者能够从基础语言中吸取特性,就像我们嵌入的 JavaScript 求值器使用了基础 JavaScript 的原语和控制结构一样。设计者只有在以后(如果有必要)才需要费力地在低级语言或硬件中构建完整的实现。在本节和下一节中,我们将探讨一些提供显著额外表达能力的 JavaScript 变体。
4.2.1 正常顺序和应用顺序
在第 1.1 节,我们开始讨论求值模型时,我们注意到 JavaScript 是一种 应用顺序 语言,也就是说,当函数被应用时,JavaScript 函数的所有参数都会被求值。相反,正常顺序 语言会延迟求值函数参数,直到实际参数值被需要为止。延迟求值函数参数直到最后可能的时刻(例如,直到它们被原始操作所需)被称为延迟求值。²⁹ 考虑函数
function try_me(a, b) {
return a === 0 ? 1 : b;
}
在 JavaScript 中求值try_me(0, head(null));会导致错误。使用延迟求值,就不会出现错误。求值该语句将返回 1,因为参数head(null)永远不会被求值。
利用延迟求值的一个例子是声明一个函数unless
function unless(condition, usual_value, exceptional_value) {
return condition ? exceptional_value : usual_value;
}
可以在诸如下面的语句中使用
unless(is_null(xs), head(xs), display("error: xs should not be null"));
这在应用顺序语言中不起作用,因为在调用unless之前通常值和异常值都会被求值(参见练习 1.6)。延迟求值的一个优点是,一些函数,比如unless,即使求值它们的一些参数会产生错误或不会终止,也可以进行有用的计算。
如果在求值参数之前进入函数体,则我们说该函数对该参数是 非严格 的。如果在进入函数体之前求值参数,则我们说该函数对该参数是 严格 的。³⁰ 在纯粹的应用顺序语言中,所有函数对每个参数都是严格的。在纯粹的正常顺序语言中,所有复合函数对每个参数都是非严格的,原始函数可以是严格的也可以是非严格的。还有一些语言(参见练习 4.29)允许程序员对他们定义的函数的严格性进行详细控制。
一个引人注目的例子是一个可以有用地变为非严格的函数pair(或者一般来说,几乎任何数据结构的构造函数)。即使元素的值未知,也可以进行有用的计算,将元素组合成数据结构并对生成的数据结构进行操作。例如,计算列表的长度而不知道列表中各个元素的值是完全有意义的。我们将在第 4.2.3 节中利用这个想法,将第 3 章的流实现为由非严格对组成的列表。
练习 4.23
假设(在普通的应用顺序 JavaScript 中)我们按上面所示定义unless,然后根据unless定义factorial如下
function factorial(n) {
return unless(n === 1,
n * factorial(n - 1),
1);
}
如果我们尝试求值factorial(5)会发生什么?我们的函数在正常顺序语言中会工作吗?
练习 4.24
Ben Bitdiddle 和 Alyssa P. Hacker 对于实现诸如unless之类的惰性求值的重要性存在分歧。Ben 指出可以在应用序中实现unless作为一个语法形式。Alyssa 反驳说,如果这样做,unless将只是语法,而不是可以与高阶函数一起使用的函数。在这个论点的双方填写细节。展示如何将unless实现为一个派生组件(类似于操作符组合),通过在evaluate中捕获函数表达式为unless的应用。给出一个可能有用的情况的例子,其中unless作为函数而不是语法形式可用。
4.2.2 惰性求值的解释器
而不仅仅是evaluate,我们使用
基本思想是,在应用函数时,解释器必须确定哪些参数需要求值,哪些需要延迟。延迟的参数不会被求值;相反,它们会被转换为称为thunk的对象。thunk 必须包含在需要时产生参数值所需的信息,就好像它在应用时已经被求值一样。因此,thunk 必须包含参数表达式和函数应用被求值的环境。
在 thunk 中求值表达式的过程称为forcing。通常情况下,只有在需要其值时才会强制执行 thunk:当它被传递给将使用 thunk 值的原始函数时;当它是条件语句的谓词的值时;当它是即将被应用为函数的函数表达式的值时。我们可以选择是否对 thunk 进行记忆化,类似于第 3.5.1 节中对流的优化。使用记忆化时,第一次强制执行 thunk 时,它会存储计算出的值。后续的强制执行只需返回存储的值,而不重复计算。我们将使我们的解释器进行记忆化,因为这对许多应用来说更有效率。然而,这里有一些棘手的考虑。
修改求值器
惰性求值和第 4.1 节中的求值器在evaluate和apply中对函数应用的处理上的主要区别。
evaluate的is_application子句变成
: is_application(component)
? apply(actual_value(function_expression(component), env),
arg_expressions(component), env)

浙公网安备 33010602011771号