Lab 10:解释器与尾调用ok 14 项通过
把「求值」这件事本身写成代码:Calculator 解释器、宏(macro)、尾递归。
0. 这份作业在练什么
到 Lab 10 为止,这门课一直在教你「读懂求值过程」:看到一个表达式,能在脑子里 按规则一步步算出它的值。Lab 10 把这件事翻了个面——不再是你按规则求值,而是你写一个程序, 让它按规则求值。这个程序就叫解释器(interpreter)。
这个翻面是有代价的:一旦你要写解释器,就必须把此前含混带过的东西全部说死。比如:
- 「一个表达式」在内存里到底长什么样?—— 答:一个
Link链表对象,第一个元素是算子,其余是算子数。 - 「算子先于算子数求值」是谁保证的?—— 答:是解释器代码里那一行
calc_apply(calc_eval(operator), map_link(calc_eval, operands))的书写顺序保证的。 - 「
and会短路」为什么不能靠普通的调用规则实现?—— 答:因为普通调用规则会把所有算子数都求值一遍, 而短路要求「求到一半就停」。所以and必须是特殊形式(special form),在解释器里单开一条分支。
作业一共 6 题,分成四组:
| 题 | 名字 | 类型 | 练的是 |
|---|---|---|---|
| Q1 | wwsd-quasiquote | 概念题(不计分) | 准引用 ` 与解引用 , 的求值规则 |
| Q2 | using_link | 概念题(不计分) | 用 Link 表示一个 Scheme 表达式 |
| Q3 | floor_div | 写 Python | 给解释器加一个内置过程;同时补全 calc_eval 的调用分支 |
| Q4 | eval_and | 写 Python | 给解释器加一个特殊形式;短路语义 |
| Q5 | repeat-lambda | 写 Scheme | 宏:把表达式当数据来构造代码 |
| Q6 | concatenate | 写 Scheme | 尾递归改写,常数空间 |
做之前该掌握什么
- 链表
Link类(Lab 7 / Lab 8):first、rest、Link.empty, 以及「用while沿着rest往下走」和「用递归处理rest」这两种遍历方式。 - Scheme 的基本语法(Lecture 18、19):
define、lambda、if、begin、cons/car/cdr/null?/append、引用'。 - 递归的两种形态:「先递归再加工」(非尾递归)与「加工完再递归」(尾递归)。Q6 全靠这个。
- 解释器 =
eval与apply的互递归:eval遇到调用表达式就求出算子和各算子数,交给apply;apply执行过程体时又要回头调eval。 - 调用表达式与特殊形式的分界线,就是「算子数是否全部无条件求值」。
and、define、if都是特殊形式,因为它们要么短路、要么根本不想让某个算子数被求值。 - 宏在求值之前就动手:它拿到的是未求值的表达式本身,返回一段新的表达式,再由解释器去求值。
- 尾调用:递归调用返回后如果没有剩下的工作,当前帧就可以立刻丢掉,空间从 \(\Theta(n)\) 降到 \(\Theta(1)\)。
在 labs/lab10/ 目录下运行 python3 ok --local,结果为
14 个测试用例全部通过,本作业全部题目通过。下面贴出的每一段代码,
都与 labs/lab10/lab10.py 和 labs/lab10/lab10.scm 里的真实文件逐字一致。
1. Q1:WWSD 准引用 wwsd-quasiquote
题目要什么
这是一道 What Would Scheme Display(Scheme 会显示什么)概念题,不计分,但它是理解 Q5「宏」的前提。
题目给你一串 Scheme REPL 输入,你要说出每一行的输出。全部输入输出如下
(来自 labs/lab10/tests/wwsd-quasiquote.py,答案已解锁):
scm> '(1 x 3)
(1 x 3)
scm> (define x 2)
x
scm> `(1 x 3)
(1 x 3)
scm> `(1 ,x 3)
(1 2 3)
scm> `(1 x ,3)
(1 x 3)
scm> `(1 (,x) 3)
(1 (2) 3)
scm> `(1 ,(+ x 2) 3)
(1 4 3)
scm> (define y 3)
y
scm> `(x ,(* y x) y)
(x 6 y)
scm> `(1 ,(cons x (list y 4)) 5)
(1 (2 3 4) 5)
怎么想到的
先把三个符号的分工钉死,后面九行全是机械套用:
| 写法 | 名字 | 做什么 |
|---|---|---|
'expr | 引用 quote | 把 expr 原封不动当数据返回,内部一律不求值 |
`expr | 准引用 quasiquote | 默认同 ',但内部允许开洞 |
,sub | 解引用 unquote | 只在准引用内部有效:把 sub 求值,把结果填回这个位置 |
题面自己给了最好的类比:准引用就是 Scheme 版的 f-string。
Python 里 f"1 {x} 3" 中,引号里的东西默认是字面文本,只有 {} 里的才被求值填进去。
Scheme 里 `(1 ,x 3) 中,反引号后的东西默认是字面代码,只有 , 后面的才被求值填进去。
' 则相当于普通字符串 "1 {x} 3"——连 {} 都不认,全是字面。
唯一容易犯迷糊的一点是:解引用之后填回去的是「值」,不是「值的文本」。
所以最后一行 ,(cons x (list y 4)) 求出来是一个列表 (2 3 4),
它作为一个整体元素被塞进外层列表,于是结果是 (1 (2 3 4) 5),而不是把三个元素摊平的 (1 2 3 4 5)。
这个「不摊平」的性质在 Q5 里非常关键——那里我们要把 expr 整个塞进 (lambda () ...) 的位置。
逐行推演
'(1 x 3):普通引用,整个列表当数据。此时 x 甚至还没被 define,
但完全不报错——因为 x 根本没被求值,它只是一个符号(symbol)。输出 (1 x 3)。(define x 2):Scheme 的 define 返回被定义的符号名,所以 REPL 显示 x,
不是 2,也不是空。这一点在 Q6 的 ok 测试里也考了。`(1 x 3):准引用,但里面一个逗号都没有,等价于普通引用。输出 (1 x 3)——
注意 不是 (1 2 3)。反引号本身不会让符号求值。`(1 ,x 3):第二个位置开了洞,求 x 得 2,填回去。输出 (1 2 3)。`(1 x ,3):这次逗号加在 3 上。3 求值还是 3,而 x 没有逗号,
保持符号原样。输出 (1 x 3)。这一行专门用来打消「有逗号出现,整行就都求值」的误解——
解引用是逐位置生效的。`(1 (,x) 3):解引用可以出现在任意嵌套深度。里层列表的第一个位置被求值成 2,
外层结构 (1 (…) 3) 保持不变。输出 (1 (2) 3)。`(1 ,(+ x 2) 3):逗号后面可以是任意调用表达式,不只是变量名。
求 (+ 2 2) 得 4。输出 (1 4 3)。(define y 3) 返回符号 y。`(x ,(* y x) y):三个位置里只有中间那个求值,(* 3 2) 得 6;
两侧的 x 和 y 仍是裸符号。输出 (x 6 y)。
同一个名字 x,在有逗号的位置是「值 2」,在没逗号的位置是「符号 x」——
这正是宏能做代码变换的根本原因。`(1 ,(cons x (list y 4)) 5):先算 (list y 4) 得 (3 4),
再 (cons 2 (3 4)) 得 (2 3 4),作为一个元素填进中间位置。
输出 (1 (2 3 4) 5)。- 以为反引号就等于求值:把第 3 行答成
(1 2 3)。反引号只是「允许开洞」,不开洞时和'完全一样。 - 以为解引用会把结果摊平:把最后一行答成
(1 2 3 4 5)。摊平需要另一个记号,@(unquote-splicing), 本 lab 不涉及。 - 忘了
define的返回值:把第 2 行答成2或者留空。在 61A 的 Scheme 里,(define x 2)求值结果是符号x。
2. Q2:用 Link 表示表达式 using_link
题目要什么
同样是概念题(不计分),但它是 Q3、Q4 的地基。题目让你回答关于 Calculator 表达式
(+ (- 2 4) 6 8) 的六个选择题。labs/lab10/tests/using_link.py 里的题目与答案是:
| # | 问题 | 答案 |
|---|---|---|
| 1 | 哪个 Python 表达式构造出表示 (+ (- 2 4) 6 8) 的 Link? |
Link('+', Link(Link('-', Link(2, Link(4))), Link(6, Link(8)))) |
| 2 | 这个调用表达式的算子(operator)是什么? | + |
| 3 | 若该 Link 绑定到 p,怎么取出算子? | p.first |
| 4 | 怎么取出「装着全部算子数的列表」? | p.rest |
| 5 | 怎么只取出第一个算子数? | p.rest.first |
| 6 | (+ (- 2 4) 6 8) 求值之前的第一个算子数是什么? |
Link('-', Link(2, Link(4))) |
怎么想到的
核心只有一条规则,其余全是它的推论:
一个 Scheme 调用表达式 (op a1 a2 … an) 被读入(read / parse)之后,
变成一条链表:第一个结点装算子,后面每个结点各装一个算子数,最后一个结点的 rest 是 nil。
即 Link(op, Link(a1, Link(a2, … Link(an, nil))))。
如果某个算子数本身也是调用表达式,那它就是一个嵌套的 Link 对象,
整体占据一个结点的 first 位置。
拿到 (+ (- 2 4) 6 8),先数清楚它有几个「顶层元素」。用括号配对来数:
( + (- 2 4) 6 8 )
↑ ↑ ↑ ↑
算子 算子数1 数2 数3
是 4 个顶层元素,所以链表有 4 个结点。这一步最容易数错——初学者常把 (- 2 4)
里的 -、2、4 也当成顶层元素,数成 6 个,于是选了错误选项
Link('+', Link('-', Link(2, Link(4, Link(6, Link(8))))))。那个选项表示的是平铺的
(+ - 2 4 6 8),把嵌套结构彻底压没了。
第二个坑是引号。Calculator 里,算术运算符是用 Python 字符串表示的,
所以必须写 '+' 和 '-'。选项里那两个不带引号的
Link(+, …)、Link(-, …) 在 Python 里根本不是合法表达式(会 SyntaxError)。
而数字用 Python 数字表示,所以 2、4、6、8 不带引号。
一句话记忆:符号带引号,数字不带。
逐层拆开这条链表
把答案写成缩进形式,结构一目了然:
Link('+', <- p first = '+'(算子)
Link(Link('-', <- p.rest first = 嵌套表达式 (- 2 4)
Link(2, Link(4))),
Link(6, <- p.rest.rest first = 6
Link(8)))) <- p.rest.rest.rest first = 8, rest = nil
于是六个问题的答案自动落地:
| Python 表达式 | 值 | 打印出来(print) |
|---|---|---|
p.first | '+' | + |
p.rest | Link(Link('-', Link(2, Link(4))), Link(6, Link(8))) | ((- 2 4) 6 8) |
p.rest.first | Link('-', Link(2, Link(4))) | (- 2 4) |
p.rest.rest.first | 6 | 6 |
p.rest.first.rest.first | 2 | 2 |
因为 (- 2 4) 求值之后是 -2,而题目问的是解释器刚读完输入、还没开始算的时刻。
那时它仍然是一个 Link 对象。选项里那个 -2 就是给答成「求值之后」的人准备的陷阱。
这个区分是整个 Lab 的灵魂:代码在解释器眼里就是数据。
Link('-', Link(2, Link(4))) 是一段「尚未发生的计算」,
它要等到 calc_eval 走到它头上才会变成 -2。Q5 的宏之所以能工作,
靠的就是同一件事——宏拿到的也是这种「还没发生的计算」。
验证:手动追踪 print(p)
Link.__str__ 的写法是:先输出 ( 和 repl_str(self.first),
然后沿 rest 循环,每次加一个空格和 repl_str(rest.first),最后补 )。
对我们的 p:
s = '(' + repl_str('+') → s = "(+";rest = p.rest。rest.first 是嵌套 Link,repl_str 对它调 str(),
递归得到 "(- 2 4)"。s = "(+ (- 2 4)"。first = 6 → s = "(+ (- 2 4) 6"。first = 8 → s = "(+ (- 2 4) 6 8";此时 rest = nil,退出循环。nil is Link.empty 为真,不加点对(dotted pair)后缀,补 ),
得到 (+ (- 2 4) 6 8)——正是我们最开始输入的那串字符。能原样打印回来,说明这条链表确实是那个表达式的忠实表示。
- 把
p.rest当成「第一个算子数」。p.rest是剩下所有算子数组成的链表; 第一个算子数要再取一次.first。这个区分在 Q3、Q4 里天天用到——calc_eval里的operands = exp.rest传给map_link的正是一整条链表。 - 把
Link(2, Link(4))写成Link(2, 4)。后者的rest是数字4而不是链表, 打印出来是(2 . 4)(点对),不是列表(2 4)。Link的rest默认值是Link.empty, 所以链尾写Link(4)就够了,不必写Link(4, nil)——两者等价。
3. Q3:新过程 floor_div(含补全 calc_eval)
题目要什么
给 Calculator 语言加一个整除运算 //。它接受至少两个参数,
多参数时从左往右依次整除:(// a b c d) 等价于 Python 的 ((a // b) // c) // d。
calc> (// 1 1)
1
calc> (// 5 2)
2
calc> (// 28 (+ 1 1) 1)
14
题面明确提示:要同时改 calc_eval 和 floor_div 两处。
这一点很容易漏——起始文件里 calc_eval 处理调用表达式的那一行本来就是空的:
if isinstance(exp, Link):
operator = ____________
operands = ____________
if operator == 'and':
return eval_and(operands)
elif operator == 'define':
return eval_define(operands)
else:
return calc_apply(___________, ___________)
也就是说,整个解释器的「调用表达式」这条主干路径要你自己接上,
floor_div 只是接上之后顺便加的一个内置过程。floor_div 的 doctest 也分两半:
前五条直接调 floor_div,后三条走 calc_eval,后者只有主干接对了才过。
>>> floor_div(Link(23, Link(2, Link(5, nil))))
2
>>> calc_eval(Link("//", Link(100, Link(Link("+", Link(2, Link(3, nil))), nil))))
20
边界情况:题目说「假设每次调用 // 至少有两个参数」,所以不必处理
(// 5) 这种单参数情况,也不必处理零参数。这句话省掉了一堆判断。
怎么想到的
第一步:先想清楚 floor_div 拿到的是什么
看 doctest:floor_div(Link(23, Link(2, Link(5, nil)))) 返回 2。
传进来的是一条链表 (23 2 5),里面全是数字,不是表达式。
对比一下 OPERATORS 里已经写好的 addition(在 lab10.py 里以 base64 形式给出,
解码后是一个简单的 while 循环),它们的签名都是「一个参数 expr,是数字链表」。
所以 floor_div 的职责很窄:拿一串已经算好的数,做左折叠(fold left)。
求值这件事不归它管。
第二步:写折叠
「从左往右累积」有两种写法:递归和循环。
递归版大概是这样(示意,未采用):
def floor_div(args):
if args.rest is nil:
return args.first
return floor_div(Link(args.first // args.rest.first, args.rest.rest))
它能跑,但每一步都要新建一个 Link 对象,纯属浪费;而且读起来绕。
链表本来就适合用 while 走一遍,于是改成循环版:先把 args.first 当初值,
再沿 rest 一路除下去。
「左折叠」的循环骨架永远长这样:取第一个元素当累积器初值,从第二个元素开始遍历,
每一步把累积器和当前元素合并。addition、subtraction、multiplication、
division 四个已有实现全是这个骨架,floor_div 只是把中间的运算换成 //。
认出这个骨架,这题就没什么可想的了。
第三步:补 calc_eval,这才是真难点
四个空里,前两个不难:由 Q2 已知 operator = exp.first、operands = exp.rest。
难的是 calc_apply(____, ____)。第一反应是直接写
calc_apply(operator, operands)——毕竟名字都对上了。但这样一定会崩:
def calc_apply(op, args):
return op(args)
calc_apply 第一件事就是把 op 当函数调用。而 operator 是字符串 '//',
字符串不能调用,报错:
TypeError: 'str' object is not callable
所以第一个空必须是 calc_eval(operator)——让 calc_eval 走到
elif exp in OPERATORS: return OPERATORS[exp] 那条分支,把字符串 '//'
查表换成真正的 Python 函数 floor_div。
题面里那段 (define * +) 的例子就是在说这件事:* 默认求值成乘法过程,
但它随时可能被重新绑定成别的东西。所以解释器不能把 '*' 硬编码成乘法,
必须每次都去查它当前的值。这就是为什么 calc_eval 会被调用在算子上,
而不是只调用在算子数上。
第二个空同理:operands 里装的可能是表达式,比如
(// 100 (+ 2 3)) 的第二个算子数是嵌套的 Link('+', …)。
如果原样传给 floor_div,就会执行 100 // Link(...),报
TypeError: unsupported operand type(s) for //: 'int' and 'Link'。
所以每个算子数都要先 calc_eval 一遍。题面直接给了工具:map_link(f, s)
把函数 f 作用到链表 s 的每个元素上,返回新链表。于是第二个空是
map_link(calc_eval, operands)。注意 calc_eval 不带括号——
我们传的是函数本身,不是调用它的结果。
代码
calc_eval 的调用表达式分支(labs/lab10/lab10.py):
def calc_eval(exp):
if isinstance(exp, Link):
operator = exp.first # e.g (+ 1 2), + is the operator
operands = exp.rest # e.g (+ 1 2), 1 and 2 are operands
if operator == 'and': # and expressions
return eval_and(operands)
elif operator == 'define': # define expressions
return eval_define(operands)
else: # Call expressions
# The operator is a symbol (a Python string), so evaluate it to get
# the actual Python function, and evaluate every operand as well.
return calc_apply(calc_eval(operator), map_link(calc_eval, operands))
elif exp in OPERATORS: # Looking up procedures
return OPERATORS[exp]
elif isinstance(exp, int) or isinstance(exp, bool): # Numbers and booleans
return exp
elif exp in bindings: # Looking up variables
return bindings[exp]
floor_div:
def floor_div(args):
# args is a Scheme list of already-evaluated numbers.
# Fold left: keep floor-dividing the running result by each remaining arg.
result = args.first
rest = args.rest
while rest is not nil:
result = result // rest.first
rest = rest.rest
return result
还要把 // 注册进运算符表,否则 calc_eval('//') 查不到:
OPERATORS = { "//": floor_div, "+": addition, "-": subtraction, "*": multiplication, "/": division }
代码逐行讲
| 行 | 为什么这样写 |
|---|---|
operator = exp.first |
链表首结点存算子。此时它还是字符串,没被求值——必须如此,
否则下面的 if operator == 'and' 就没法比较了(特殊形式的名字要在求值之前识别)。 |
if operator == 'and' |
特殊形式的分派点。and 和 define 走各自的分支,
它们的算子数不会被无条件求值。 |
calc_eval(operator) |
把符号翻译成 Python 函数对象。走的是 elif exp in OPERATORS 分支。 |
map_link(calc_eval, operands) |
对每个算子数递归求值,得到一条纯数字的链表。这一步就是 eval 与 apply 互递归中
「eval 调 eval」的那一环——嵌套表达式在这里被展开。 |
result = args.first |
左折叠的初值。题目保证至少两个参数,所以 args 一定非空,不用先判空。 |
while rest is not nil |
用 is not 而不是 != :nil 就是 Link.empty,即空元组 (),
比同一性最稳妥(Link 类没定义 __eq__,这里两种写法碰巧都行,但 is 表达的意图更准)。 |
result = result // rest.first |
累积器在左、当前元素在右——顺序反了就变成 b // a,
(// 5 2) 会得到 0 而不是 2。 |
rest = rest.rest |
推进指针。忘了这一行就是死循环,ok 会挂在那里不动。 |
验证:手动追踪 calc_eval(Link("//", Link(100, Link(Link("+", Link(2, Link(3, nil))), nil))))
这个输入对应 Calculator 里的 (// 100 (+ 2 3)),doctest 说结果是 20。
exp 是 Link,进入第一分支。operator = '//',
operands = Link(100, Link(Link('+', Link(2, Link(3))), nil)),即 (100 (+ 2 3))。'//' 既不是 'and' 也不是 'define',走 else 分支。calc_eval('//'):不是 Link,检查 '//' in OPERATORS → 真,
返回函数对象 floor_div。map_link(calc_eval, operands)。map_link 递归处理:
- 第一个元素
100:calc_eval(100)→ 不是Link;100 in OPERATORS为假;isinstance(100, int)为真 → 返回100。 - 第二个元素
Link('+', Link(2, Link(3))):递归进入calc_eval。 它是Link,operator = '+',operands = (2 3), 走else:calc_apply(addition, (2 3))=addition(Link(2, Link(3)))=5。 map_link组装出新链表Link(100, Link(5, nil))。
calc_apply(floor_div, Link(100, Link(5, nil))) → floor_div(Link(100, Link(5, nil)))。floor_div 内部:result = 100,rest = Link(5, nil)。
第一轮:rest is not nil 为真 → result = 100 // 5 = 20,rest = nil。
第二轮判断:rest is not nil 为假 → 退出循环,返回 20。 ✓再验证多参数的那条:(// 100 2 2 2 2 2)。
| 轮次 | 进入时 result | rest.first | 退出时 result |
|---|---|---|---|
| 初始 | 100 | — | 100 |
| 1 | 100 | 2 | 50 |
| 2 | 50 | 2 | 25 |
| 3 | 25 | 2 | 12 |
| 4 | 12 | 2 | 6 |
| 5 | 6 | 2 | 3 |
返回 3,与 doctest 一致。注意第 3 轮 25 // 2 = 12——每一步都截断,
不是最后才截断。如果先算浮点 \(100/2^5 = 3.125\) 再取整,恰好也是 3,
但换成 (// 23 2 5) 就会露馅:逐步截断是 23//2 = 11、11//5 = 2;
先乘除后截断是 \(23/10 = 2.3 \to 2\)。这条 doctest 结果同为 2,属于巧合;
真正的区别在 (// 7 2 2):逐步是 7//2=3、3//2=1,
而 \(7/4 = 1.75 \to 1\)——也一样。要制造差异得取更大的数,但无论如何,
按题目定义老老实实逐步整除永远不会错。
- 只改了
floor_div,没改calc_eval:前 5 条 doctest 过,后 3 条挂。 错误信息是TypeError: 'str' object is not callable(如果calc_apply那两个空还空着, 则是语法错误)。题面的 Hint 已经明说要改两处。 - 用
/而不是//:(// 5 2)返回2.5,doctest 期望2。 Python 的/永远返回float。 map_link(calc_eval(operands), …)之类的手滑:map_link第一个参数要函数本身, 写成calc_eval(...)就变成先调用再传值了。- 忘了往
OPERATORS里加"//":calc_eval('//')四个分支全不满足, 函数走到底隐式返回None,然后calc_apply报TypeError: 'NoneType' object is not callable。
4. Q4:新特殊形式 eval_and
题目要什么
给 Calculator 加上 and 表达式,同时引入 Scheme 的布尔值 #t / #f
(用 Python 的 True / False 表示)。
calc> (and (= 1 1) 3)
3
calc> (and #f (+ 1 0))
#f
calc> (and 0 1 (+ 5 1)) ; 0 is a true value in Scheme!
6
把 eval_and 的 doctest 翻译成人话,一共考七件事:
| Calculator 输入 | 期望结果 | 考点 |
|---|---|---|
(and 1) | 1 | 只有一个算子数时,返回它的值(不是 True) |
(and #f "1") | False | 遇到假值立刻返回 #f,不再看后面——注意第二项是字符串 "1",
真去求值会走到 bindings 查找而找不到 |
(and 1 (// 5 2)) | 2 | 算子数可以是嵌套表达式,要递归求值 |
(and (+ 1 1) 3) | 3 | 全真时返回最后一个算子数的值,不是第一个 |
(and (- 1 0) (/ 5 2)) | 2.5 | 同上,返回值可以是浮点 |
(and 0 1) | 1 | 0 在 Scheme 里是真值,不短路 |
(and) | True | 零个算子数时返回 #t |
另外题面给了一条硬性提醒:判断假值必须用 val is scheme_f,
不能用 val == scheme_f。因为在 Python 里 0 == False 为真,
但 0 is False 为假。用 == 会让 (and 0 1) 错误地短路成 False。
怎么想到的
第一步:为什么 and 不能当普通调用处理
先问一个更根本的问题:为什么 calc_eval 要为 and 单开一条 if 分支,
而不是像 + 那样往 OPERATORS 里塞一个函数?
假设我们真的塞了个 def and_op(args): ...。那么 calc_eval 会走 else 分支,
执行 map_link(calc_eval, operands)——所有算子数在 and_op 被调用之前就已经全部求值完了。
到那时再想「短路」已经晚了,该崩的早崩了。用 doctest 里第二条验证:
calc_eval(Link("and", Link(False, Link("1", nil))))
第二个算子数是 Python 字符串 "1"。若强行求值:不是 Link,
"1" in OPERATORS 假,isinstance("1", int) 假,"1" in bindings 假 →
函数走到底返回 None。而期望的行为是「看到第一个 False 就停,压根不碰 "1"」。
凡是「不能无条件地把所有算子数都求值一遍」的形式,就必须是特殊形式,
在 eval 里单开分支,接过未求值的算子数,自己决定求哪些、按什么顺序求、求几个。
这解释了 61A 里所有特殊形式的存在理由:and/or 要短路;
if 只求一个分支;define 不能求值被定义的那个名字(它还没有值);
lambda 完全不求值函数体;quote 一个都不求。
第二步:想清楚返回什么值
Scheme 的 and 不返回布尔值,而是返回最后一个被求值的算子数的值。
这和 Python 的 and 行为一致(1 and 3 在 Python 里也是 3)。
把七条 doctest 归纳成规则:
#t(scheme_t)。#f → 立刻返回 #f,剩下的不看。#f → 返回最后一个算子数求值的结果。第三步:一个自然但错的写法
最直觉的循环是这样:
# 错误示范
def eval_and(expressions):
result = scheme_t
while expressions is not nil:
result = calc_eval(expressions.first)
if result is scheme_f:
return scheme_f
expressions = expressions.rest
return result
这一版其实是对的,逻辑完全等价。但它把「求值」和「判断是否最后一个」混在一起,
读的时候要在脑子里维护 result 的状态。仓库里采用的写法把两件事分开了,
意图更直白:对「除最后一个之外」的算子数做「求值 + 检查短路」;
最后一个算子数只求值,直接返回。这样一眼就能看出「最后一个不做短路检查」,
也就自动解释了 (and 0 1) 为什么返回 1——最后一个根本不参与判断。
循环条件写成 while expressions.rest is not nil(注意是 .rest),
意思是「只要后面还有东西,当前这个就不是最后一个」。循环退出时,
expressions 正停在最后一个结点上,直接 return calc_eval(expressions.first)。
这个「走到倒数第二个就停」的模式在链表题里很常用。
代码
scheme_t = True # Scheme's #t
scheme_f = False # Scheme's #f
def eval_and(expressions):
# `and` is a special form: evaluate the operands one at a time and stop
# (short-circuit) as soon as one of them evaluates to a false value.
if expressions is nil:
return scheme_t
while expressions.rest is not nil:
if calc_eval(expressions.first) is scheme_f:
return scheme_f
expressions = expressions.rest
return calc_eval(expressions.first)
代码逐行讲
| 行 | 为什么这样写 |
|---|---|
if expressions is nil: return scheme_t |
处理 (and)。这一行必须放在最前面:后面的 expressions.rest
在 expressions 是 nil(即空元组 ())时会报
AttributeError: 'tuple' object has no attribute 'rest'。 |
while expressions.rest is not nil |
循环体只处理「不是最后一个」的算子数。写成 while expressions is not nil
就会把最后一个也吃掉,循环结束后 expressions 已经是 nil,
再也拿不到「最后一个算子数的值」,只能返回 scheme_t——
于是 (and (+ 1 1) 3) 会错误地返回 True 而不是 3。 |
calc_eval(expressions.first) |
此刻才求值。逐个求,不是一次性 map_link 全求——这就是短路的实现。 |
is scheme_f |
题面点名的坑。scheme_f 就是 False。用 is 比较同一性:
只有真正的 False 对象才算假值,0、0.0、'' 一律算真值。 |
expressions = expressions.rest |
推进。这里改的是局部形参,不会影响调用方持有的链表——
Link 对象本身没被改动,只是名字重新绑定到了后半截。 |
return calc_eval(expressions.first) |
最后一个算子数:求值并原样返回,哪怕它是 #f。
这正是 (and 1) 返回 1、(and (/ 5 2)) 返回 2.5 的原因。 |
验证一:calc_eval(Link("and", Link(0, Link(1, nil)))) → 1
exp 是 Link;operator = 'and',operands = Link(0, Link(1, nil))。
命中 if operator == 'and' → 调 eval_and(Link(0, Link(1, nil)))。
注意这里没有经过 map_link,算子数还是原样的、未求值的。expressions 不是 nil,跳过第一个 if。expressions.rest 是 Link(1, nil),不是 nil → 进入循环体。calc_eval(0):不是 Link;0 in OPERATORS 为假;
isinstance(0, int) 为真 → 返回 0。
判断 0 is False → 假(0 和 False 是两个不同的对象)。
所以不短路。若这里写成 0 == False,就会是真,函数错误地返回 False。expressions = Link(1, nil)。再判循环条件:expressions.rest is nil → 退出循环。return calc_eval(1) → 1。 ✓验证二:calc_eval(Link("and", Link(False, Link("1", nil)))) → False
eval_and(Link(False, Link("1", nil)))。非 nil,进入循环
(rest 是 Link("1", nil),非 nil)。calc_eval(False):不是 Link;False in OPERATORS 为假
(字典的键都是字符串);isinstance(False, bool) 为真 → 返回 False。False is scheme_f → 真 → 立即 return scheme_f。"1" 从头到尾没被 calc_eval 碰过。如果碰了,
它会一路掉到函数末尾隐式返回 None,doctest 就挂了。
这条 doctest 存在的唯一目的就是证明你真的短路了。 ✓验证三:calc_eval(Link("and", Link(1, Link(Link("//", Link(5, Link(2, nil))), nil)))) → 2
对应 (and 1 (// 5 2))。第一轮:calc_eval(1) 得 1,不是 #f,推进;
循环条件不再满足;最后 calc_eval(Link('//', Link(5, Link(2))))
——这一步回到 Q3 的路径:calc_eval('//') 得 floor_div,
map_link(calc_eval, (5 2)) 得 (5 2),floor_div 算出 5 // 2 = 2。
返回 2。 ✓ 这条同时证明了 Q3 和 Q4 拼在一起是通的。
- 用
==比较假值:(and 0 1)返回False而不是1。 这是题面专门用加粗提醒的坑,ok 里也有专门的用例抓它。 - 返回
True而不是最后一个值:写成return scheme_t结尾,(and (+ 1 1) 3)会返回True而不是3。 Scheme 的and是「返回值」的,不是「返回真假」的。 - 忘了
(and)的情况:直接写while expressions.rest is not nil而不先判空,报AttributeError: 'tuple' object has no attribute 'rest'(因为nil是())。 - 在
eval_and里先map_link(calc_eval, expressions): 一次性求值全部算子数,短路就没了;第二条 doctest 直接挂。
5. Q5:宏 repeat
题目要什么
定义一个宏 repeat,接受一个数 n 和一个表达式 expr,
把 expr 求值 n 次,整体的值是最后一次求值的结果。
同时补全辅助过程 repeated-call:它接受数 n 和一个零参数过程 f,调用 f 共 n 次。
题面给了转换目标:(repeat (+ 2 3) (print 1)) 应该等价于
(repeated-call (+ 2 3) (lambda () (print 1)))
并要求 (repeat 2 (repeat 2 (print 'four))) 打印四次 four。骨架是:
(define-macro (repeat n expr)
`(repeated-call ,n ___))
(define (repeated-call n f)
(if (= n 1) ___ (begin ___ ___)))
ok 的两条测试用例(labs/lab10/tests/repeat-lambda.py):
scm> (repeat (+ 2 3) (print 1))
1
1
1
1
1
scm> (repeat 2 (repeat 2 (print 'four)))
four
four
four
four
边界:repeated-call 的 base case 是 n = 1,不是 n = 0。
题目没要求处理 n 为 0 或负数的情况。
怎么想到的
第一步:为什么必须是宏,普通函数不行?
先试着不用宏。写一个普通过程:
; 错误示范
(define (repeat n expr) ...)
Scheme 是先求值算子数再调用的。所以 (repeat 5 (print 1)) 会先把
(print 1) 求值——打印一次,得到值 undefined——然后把这个已经用过的值传进去。
函数体里再怎么折腾,也只是在反复摆弄一个死值,永远不会再打印第二次。
普通过程收到的是值;宏收到的是未求值的表达式本身。 当你需要「让一段代码延迟求值 / 多次求值 / 换个上下文求值」时,函数无能为力,必须用宏。
题面给的宏求值三步:
① 把宏的形参绑定到未求值的算子数表达式;
② 求值宏的体,得到一个新表达式;
③ 在原调用处的帧里求值这个新表达式。
第二步:宏该生成什么代码?
题面直接说了目标形状:(repeated-call ,n (lambda () ,expr))。
但要理解为什么是这个形状。
repeated-call 是个普通过程,它的参数会被求值。如果我们生成
(repeated-call 5 (print 1)),那 (print 1) 又会在传参时被求值一次,前功尽弃。
解决办法是把它裹进一个零参数 lambda:(lambda () (print 1))。
这个 lambda 表达式求值的结果是一个过程对象,创建过程不会执行函数体。
之后每写一次 (f),函数体 (print 1) 才被求值一次。
把表达式裹成 (lambda () expr) 是一个通用技巧,这种零参数过程叫 thunk,
作用是「把一次计算冻起来,想什么时候化冻就调用一次」。
Python 里的等价物是 lambda: expr,或者惰性求值时用的生成器。
第三步:逗号加在哪里?
这是全题最需要想清楚的地方。宏体是准引用的 ` (repeated-call ,n (lambda () ,expr)),
四个位置里两个带逗号、两个不带:
| 位置 | 带逗号吗 | 为什么 |
|---|---|---|
repeated-call | 不带 | 我们想生成的代码里就要有 repeated-call 这个符号。
如果加逗号,宏展开时就会去查 repeated-call 的值(一个过程对象),
再把过程对象塞进表达式里——虽然在 61A 的 Scheme 里也能凑合跑,但意图完全错了。 |
,n | 带 | n 是宏的形参,绑定着未求值的表达式(比如 (+ 2 3))。
加逗号是为了把这段表达式本身取出来填进生成的代码里。
不加逗号的话,生成的代码字面上就是符号 n,而调用处的帧里根本没有 n 这个名字。 |
lambda、() | 不带 | 这两个是我们要生成的代码骨架,纯字面。 |
,expr | 带 | 同 ,n:把调用者写的那段表达式取出来,放进 lambda 体的位置。 |
Q1 的第 9 行 `(x ,(* y x) y) → (x 6 y) 讲的就是这件事:
同一份准引用里,带逗号的位置放「求出来的东西」,不带逗号的位置放「字面符号」。
宏体就是靠这个混搭来拼装代码的。
第四步:repeated-call 的三个空
(if (= n 1) ___ (begin ___ ___))。题面的 Hint 说得很直白:
零参数过程的调用写作 (f);n 为 1 时调一次就完事;n 大于 1 时先调一次,再递归处理 n-1 次。
- base case:
(f)。注意不能写成f——那样返回的是过程对象本身,而不是调用它的结果, 也不会产生print的副作用。 begin第一个子表达式:(f),产生第一次副作用,返回值被丢弃。begin第二个子表达式:(repeated-call (- n 1) f),它的值就是begin的值, 也就是整个if的值,也就是「最后一次求值的结果」。
顺便注意:这个递归调用正好是尾调用——begin 的最后一个子表达式处在尾位置。
所以 (repeat 100000 ...) 不会爆栈。Q6 会把这个概念讲透。
代码
labs/lab10/lab10.scm:
; Evaluate expr n times. The macro wraps expr in a zero-argument lambda so
; that repeated-call can re-evaluate it on every call instead of just once.
(define-macro (repeat n expr)
`(repeated-call ,n (lambda () ,expr)))
; Call zero-argument procedure f n times and return the final result.
(define (repeated-call n f)
(if (= n 1)
(f)
(begin (f) (repeated-call (- n 1) f))))
验证一:(repeat (+ 2 3) (print 1))
n ← 表达式 (+ 2 3),
expr ← 表达式 (print 1)。
此刻 (+ 2 3) 还没算成 5,(print 1) 也还没打印。`(repeated-call ,n (lambda () ,expr))。
把 ,n 换成 (+ 2 3),把 ,expr 换成 (print 1),得到新表达式
(repeated-call (+ 2 3) (lambda () (print 1)))
这一步只是拼装了一段代码,什么都还没执行。repeated-call 求值成过程;
算子数 (+ 2 3) 求值成 5;
算子数 (lambda () (print 1)) 求值成一个过程对象——
创建它不执行 (print 1),所以此刻仍然一个字都没打印。repeated-call 的第 1 帧:n=5, f=<thunk>。
(= 5 1) 假 → (begin (f) (repeated-call 4 f))。
(f) → 打印 1(第 1 次)。n=4:打印 1(第 2 次),递归到 n=3。第 3 帧
n=3:打印 1(第 3 次),递归到 n=2。第 4 帧
n=2:打印 1(第 4 次),递归到 n=1。n=1:(= 1 1) 真 → (f) → 打印 1(第 5 次),
返回 print 的返回值 undefined。1,与测试用例一致。 ✓验证二:(repeat 2 (repeat 2 (print 'four))) 为什么打印四次
这条用例专门验证「expr 真的被重新求值了,而不是只算一次然后重复用它的值」。
n ← 2,expr ← 整段 (repeat 2 (print 'four))(未求值!)。
生成
(repeated-call 2 (lambda () (repeat 2 (print 'four))))(lambda () (repeat 2 (print 'four))) 变成一个 thunk,体内的
(repeat 2 ...) 还没展开也没求值。repeated-call 第 1 帧 n=2:走 begin,先 (f)。
调用 thunk 就是求值它的体 (repeat 2 (print 'four))——
此时内层宏才展开,生成 (repeated-call 2 (lambda () (print 'four))),
执行后打印 four four(第 1、2 次)。(repeated-call 1 f)。(= 1 1) 真 → 再 (f) 一次,
内层宏又展开一遍,再打印 four four(第 3、4 次)。four。 ✓ 若把 lambda 去掉、直接写 `(repeated-call ,n ,expr),
内层的 (repeat 2 (print 'four)) 会在传参时就求值完毕(打印 2 次),
然后 repeated-call 拿到的 f 是 undefined 而不是过程,
调用 (f) 直接报错。- 忘了包
(lambda () …):写成`(repeated-call ,n ,expr)。 表达式只被求值一次,且f不是过程,调用时报错(61A Scheme 会说SchemeError: cannot call: undefined之类)。 - 逗号加在 lambda 上:写成
,(lambda () expr)。 这会在宏展开时就去求值(lambda () expr),而宏体所在的帧里expr是绑定到表达式的形参, 语义完全乱掉。正确的是让lambda留在生成的代码里,只把expr解引用。 - base case 写成
(= n 0):那样n=1时会执行(begin (f) (repeated-call 0 f)),多调一次不说,n=0时返回的还是f那一分支的值, 次数和返回值都不对。题目的repeated-call骨架已经把 base case 定死在(= n 1)。 - base case 写
f而不是(f):最后一次不执行,只打印 \(n-1\) 次, 而且返回的是过程对象。 - 用
define而不是define-macro:见「第一步」的分析,表达式在传参时就被求值了。
6. Q6:尾递归 concatenate
题目要什么
写一个尾递归的 concatenate,接受 s(一个「由列表组成的列表」),
把里面所有元素拼成一个列表。提示是用内置的 append。
scm> (concatenate (list (list 1 2) (list 5 1)))
(1 2 5 1)
scm> (concatenate (list (list 1 2) (list)))
(1 2)
关键在第三条 ok 测试,它不检查返回值,只检查能不能跑完:
scm> (define (tail-list n so-far)
.... (if (= n 0)
.... so-far
.... (tail-list (- n 1) (cons 1 so-far))))
tail-list
scm> (define big-list (tail-list 100000 '()))
big-list
scm> (define result (concatenate (list big-list (list 1 2 3 4)))) ; Test for tail recursion
result
它先造一个十万元素的列表,再拿去 concatenate。如果你的实现不是尾递归, 这一步会因为递归深度太大而报错。所以「尾递归」不是可选的风格要求,是硬性通过条件。
边界情况:内层可以是空列表((list));s 本身也可能是空列表,这时应返回 ()。
怎么想到的
第一步:先写出最自然的(非尾递归)版本
「把 s 里所有列表接起来」的直觉递归是:
; 能出正确答案,但不是尾递归
(define (concatenate s)
(if (null? s)
'()
(append (car s) (concatenate (cdr s)))))
逻辑没问题:第一个子列表接在「其余部分拼好的结果」前面。但它不是尾调用——
(concatenate (cdr s)) 返回之后,还得再做一次 append。
按题面的判据「递归调用返回后还有活要干吗?有 → 不是尾调用」,这里明摆着还有活。
结果就是:每一层递归的帧都必须留着,等里层返回才能干自己的 append。
空间是 \(\Theta(n)\),n 是子列表个数。测试里 s 只有两个元素,
这个版本其实也过得去……但 append 本身可能不是尾递归的,
真正让第三条测试爆掉的是十万长的列表被反复 append。所以我们要老老实实按题目要求写尾递归。
第二步:尾递归的通用改法——加一个累积器
把非尾递归改成尾递归,标准手法只有一个:把「返回之后还要做的那件事」提前做掉,
把中间结果用一个额外参数带着走。这个额外参数通常叫 accumulator,这里叫 so-far。
问自己:递归返回后我要做什么?答:append 上当前这个子列表。
那就在递归调用之前就 append 好,把结果作为参数传下去。
于是递归调用变成函数体里的最后一件事,帧就可以立刻丢掉。
这也是为什么尾递归版本的辅助函数总要多一个参数——那个参数装的就是 「原本该由外层帧保存的中间状态」。状态从调用栈搬到了参数里。
但 concatenate 的签名是固定的,只能收一个参数。所以需要一个内部辅助过程
helper,带两个参数:remaining(还没处理的子列表)和 so-far(已经拼好的部分)。
外层 concatenate 只负责用初值 '() 启动它。
第三步:确认 append 的参数顺序
(append so-far (car remaining)) 还是 (append (car remaining) so-far)?
这决定了结果是正序还是乱序。so-far 装的是已经处理过的、靠前的元素,
新来的 (car remaining) 应该接在后面,所以是 (append so-far (car remaining))。
写反的话,(concatenate (list (list 1 2) (list 5 1))) 会得到 (5 1 1 2)——
子列表的顺序被整体倒过来了(每个子列表内部还是正序)。这是一个很容易犯、
但一跑测试就立刻暴露的错。
第四步:base case
(null? remaining) 为真时,说明所有子列表都已经拼进 so-far 了,直接返回 so-far。
这也顺带处理了「s 是空列表」:helper 第一次调用就命中 base case,返回初值 '()。
代码
labs/lab10/lab10.scm:
; Concatenate a list of lists into a single list, tail recursively:
; the recursive call to helper is the last thing helper does.
(define (concatenate s)
(define (helper remaining so-far)
(if (null? remaining)
so-far
(helper (cdr remaining) (append so-far (car remaining)))))
(helper s '()))
代码逐行讲
| 行 | 为什么这样写 |
|---|---|
(define (helper remaining so-far) …) |
定义在 concatenate 体内,所以它是局部的,不污染全局环境。
在 Scheme 里,内部 define 会在当前调用帧里建立绑定,
helper 的父帧就是这个调用帧。 |
(if (null? remaining) …) |
用 null? 判断空列表。不能用 (= remaining '())——
= 只用于数字比较,对列表会报错。 |
so-far(then 分支) |
base case 直接返回累积器。整个递归链上,最终答案就是这个值, 它从这里一路原样返回到最外层——正因为「原样返回、不加工」,才允许中间的帧被丢掉。 |
(helper (cdr remaining) (append so-far (car remaining))) |
递归调用整个就是 if 的 else 分支,而 if 又是函数体的最后一个表达式,
所以它处在尾位置(tail position)。两个参数在调用之前就都算完了,
调用返回后无事可做 → 尾调用成立。 |
(cdr remaining) |
丢掉已处理的子列表,问题规模减 1,保证终止。 |
(append so-far (car remaining)) |
把当前子列表接到累积结果后面。顺序不能反。
append 返回新列表,不修改原列表。 |
(helper s '()) |
启动:待处理的是全部 s,已拼好的是空列表。
这一行也在 concatenate 的尾位置。 |
一个表达式处在尾位置,意思是「它的值直接就是整个函数调用的值,中间不再经过任何加工」。
在 Scheme 里,尾位置包括:函数体的最后一个表达式;if 的两个分支(当 if 本身在尾位置时);
begin 的最后一个子表达式;and / or 的最后一个子表达式;cond 每个子句的最后一个表达式。
反过来,算子数的位置永远不是尾位置——因为算完还要拿去调用。
所以 (append so-far (concatenate (cdr s))) 里的 concatenate 调用不是尾调用。
验证:(concatenate (list (list 1 2) (list 5 1)))
输入是 ((1 2) (5 1))。逐帧展开:
f1: concatenate s = ((1 2) (5 1))
调用 (helper ((1 2) (5 1)) ()) <- 尾调用,f1 可以关闭
f2: helper remaining = ((1 2) (5 1)) so-far = ()
(null? remaining) -> #f
(car remaining) = (1 2)
(append () (1 2)) = (1 2)
(cdr remaining) = ((5 1))
调用 (helper ((5 1)) (1 2)) <- 尾调用,f2 可以关闭
f3: helper remaining = ((5 1)) so-far = (1 2)
(null? remaining) -> #f
(car remaining) = (5 1)
(append (1 2) (5 1)) = (1 2 5 1)
(cdr remaining) = ()
调用 (helper () (1 2 5 1)) <- 尾调用,f3 可以关闭
f4: helper remaining = () so-far = (1 2 5 1)
(null? remaining) -> #t
返回 (1 2 5 1)
输出 (1 2 5 1)。 ✓
注意 f2、f3 右边那句「可以关闭」。 这就是尾递归的全部意义:
在发起下一次调用的瞬间,当前帧里再没有任何将来还要用到的信息
(remaining 和 so-far 的新值都已经算好并交出去了),
所以解释器可以直接复用这个帧,而不是压一个新的上去。
任意时刻只存在一个 helper 帧,空间 \(\Theta(1)\)。
对比非尾递归版本处理同样输入:
f1: concatenate s = ((1 2) (5 1))
要算 (append (1 2) <待定>) <- f1 必须留着,等里层结果
f2: concatenate s = ((5 1))
要算 (append (5 1) <待定>) <- f2 必须留着
f3: concatenate s = ()
返回 ()
f2 恢复: (append (5 1) ()) = (5 1),返回
f1 恢复: (append (1 2) (5 1)) = (1 2 5 1),返回
三个帧同时存在,空间 \(\Theta(n)\)。子列表一多就爆栈。
验证:为什么 (concatenate (list (list 1 2) (list))) 得 (1 2)
第二轮时 (car remaining) 是 (),(append '(1 2) '()) 仍是 (1 2)——
append 遇到空列表什么也不加。所以空子列表被自然地跳过,不需要特判。
为什么十万元素那条测试能过
(concatenate (list big-list (list 1 2 3 4))) 中 s 只有两个元素,
所以 helper 只递归 3 层——就算不尾递归,栈深度也才 3。真正的压力在 append:
它要遍历十万长的 so-far。61A 的 Scheme 解释器实现了尾调用优化,
且 append 是内置过程(用 Python 实现,不吃 Scheme 的栈),因此没有问题。
这条测试的官方注释写的是 ; Test for tail recursion。
它确实能筛掉一部分不当实现(比如用非尾递归的方式逐个 cons 元素、
递归深度达到十万的写法),但对本题的两元素输入而言,
「三层帧 vs 一层帧」的差别并不足以撑爆栈。写成尾递归是题目的明确要求,
也是这一讲要练的思维方式——不要因为「反正测试也能过」就退回非尾递归版本。
append参数写反:(append (car remaining) so-far)让子列表整体倒序,得(5 1 1 2)。- 用
cons代替append:(cons (car remaining) so-far)是把整个子列表当一个元素塞进去,得到嵌套结构((5 1) (1 2))而不是拼平的列表。cons加一个元素,append拼两个列表——这是 Scheme 里最容易混的一对。 - 忘了在
concatenate末尾调用helper: 只写了内部define,函数返回符号helper。 - 用
(= remaining '())判空:61A Scheme 报SchemeError: cannot compare non-numeric values之类。判空列表一律用null?。 - 把累积器初值写成
'(()):那是「装着一个空列表的列表」, 第一次append就会多出一个()元素。空列表是'()。
7. 整份作业回顾
Lab 10 的六道题看起来分属三个话题,其实全在回答同一个问题: 「求值」这件事,可以被推迟、被绕过、被重新安排到什么程度?
| 题目 | 核心手法 | 迁移到哪里 |
|---|---|---|
Q1 wwsd-quasiquote |
准引用:默认不求值,逗号处开洞求值 | Q5 的宏体;一切「拼代码」的场景(模板、代码生成、f-string) |
Q2 using_link |
代码即数据:表达式在内存里就是一条 Link |
Project 4 Scheme 解释器的 Pair 表示;任何 AST 处理 |
Q3 floor_div |
调用表达式的求值规则:算子求值 + 全部算子数求值 + apply;链表左折叠 | Project 4 的 scheme_eval / scheme_apply |
Q4 eval_and |
特殊形式:接过未求值的算子数,自己控制求值顺序与次数 | Project 4 里 if、and、or、cond、let 的实现 |
Q5 repeat |
宏 + thunk:把表达式冻起来,需要时再化冻 | 惰性求值、回调、Python 的装饰器与 lambda: 延迟 |
Q6 concatenate |
累积器改写:把「返回后还要做的事」提前做,换来常数空间 | 所有需要处理长序列的递归;理解为什么迭代和尾递归本质相同 |
三条值得带走的结论
「算子先于算子数」「从左到右」这些规则,在 calc_eval 里就是那一行
calc_apply(calc_eval(operator), map_link(calc_eval, operands)) 的书写方式。
你完全可以写一个求值顺序不同的解释器。语言的语义,归根到底是解释器代码的行为。
判断一个东西该不该做成特殊形式,只问一句:它需要在某些情况下让某个算子数不被求值吗?
需要 → 特殊形式(if、and、define、quote、lambda);
不需要 → 普通过程(+、//、append)。
宏是这个思路推到极致的产物:连「返回值」都变成了「返回一段还没求值的代码」。
非尾递归靠调用栈保存中间结果;尾递归把中间结果显式地写成一个参数带着走。
两者算的是同一件事,但后者让每一帧在发起下一次调用时都变得「无话可说」,
于是帧可以被复用。看到 (f (g x) (h y)) 形式的递归,
就去想「能不能把外层那个 f 提前做掉」——这就是改写的全部套路。
动手自测
1. 在 Calculator 里给 or 写一个 eval_or,语义是「返回第一个非 #f 的值,全假则返回 #f」。它该是特殊形式还是普通过程?
必须是特殊形式——它要在遇到第一个真值时停止求值后面的算子数。
骨架与 eval_and 对称:
def eval_or(expressions):
while expressions is not nil:
value = calc_eval(expressions.first)
if value is not scheme_f:
return value
expressions = expressions.rest
return scheme_f
(这段是示意代码,不在 lab10 的要求范围内。)注意 or 要返回那个真值本身,
所以必须先把求值结果存进 value 再判断,不能像 and 那样丢掉。
2. `(a ,b (c ,d)) 在 b 为 1、d 为 2 时求值成什么?
(a 1 (c 2))。a 和 c 没有逗号,保持符号原样;
解引用在任意嵌套深度都生效。
3. 下面两个 Scheme 定义,哪个是尾递归?
(define (f n) (if (= n 0) 0 (+ 1 (f (- n 1)))))
(define (g n acc) (if (= n 0) acc (g (- n 1) (+ acc 1))))
g 是。f 里 (f (- n 1)) 返回后还要做 + 1,是算子数位置,不在尾位置;
g 里递归调用就是 if 分支的全部,且 if 在函数体末尾,是尾位置。
4. 如果把 calc_eval 里 else 分支改成 calc_apply(operator, map_link(calc_eval, operands))(算子不求值),跑 (+ 1 2) 会发生什么?
calc_apply 执行 op(args),而 op 是字符串 '+',
报 TypeError: 'str' object is not callable。
5. (repeat 1 (print 'hi)) 打印几次?
一次。展开成 (repeated-call 1 (lambda () (print 'hi))),
(= 1 1) 为真,走 base case (f),调用一次。