CS 61A  /  作业解析
LAB 10

Lab 10:解释器与尾调用ok 14 项通过

把「求值」这件事本身写成代码:Calculator 解释器、宏(macro)、尾递归。

对应讲次:Lecture 20 Interpreters、Lecture 21 Tail Calls and Macros 官方题面:cs61a.org/lab/lab10 代码:labs/lab10/lab10.py、labs/lab10/lab10.scm

0. 这份作业在练什么

到 Lab 10 为止,这门课一直在教你「读懂求值过程」:看到一个表达式,能在脑子里 按规则一步步算出它的值。Lab 10 把这件事翻了个面——不再是你按规则求值,而是你写一个程序, 让它按规则求值。这个程序就叫解释器(interpreter)。

这个翻面是有代价的:一旦你要写解释器,就必须把此前含混带过的东西全部说死。比如:

  • 「一个表达式」在内存里到底长什么样?—— 答:一个 Link 链表对象,第一个元素是算子,其余是算子数。
  • 「算子先于算子数求值」是谁保证的?—— 答:是解释器代码里那一行 calc_apply(calc_eval(operator), map_link(calc_eval, operands)) 的书写顺序保证的。
  • 「and 会短路」为什么不能靠普通的调用规则实现?—— 答:因为普通调用规则会把所有算子数都求值一遍, 而短路要求「求到一半就停」。所以 and 必须是特殊形式(special form),在解释器里单开一条分支。

作业一共 6 题,分成四组:

题名字类型练的是
Q1wwsd-quasiquote概念题(不计分)准引用 ` 与解引用 , 的求值规则
Q2using_link概念题(不计分)用 Link 表示一个 Scheme 表达式
Q3floor_div写 Python给解释器加一个内置过程;同时补全 calc_eval 的调用分支
Q4eval_and写 Python给解释器加一个特殊形式;短路语义
Q5repeat-lambda写 Scheme宏:把表达式当数据来构造代码
Q6concatenate写 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 '(1 x 3):普通引用,整个列表当数据。此时 x 甚至还没被 define, 但完全不报错——因为 x 根本没被求值,它只是一个符号(symbol)。输出 (1 x 3)。
2 (define x 2):Scheme 的 define 返回被定义的符号名,所以 REPL 显示 x, 不是 2,也不是空。这一点在 Q6 的 ok 测试里也考了。
3 `(1 x 3):准引用,但里面一个逗号都没有,等价于普通引用。输出 (1 x 3)—— 注意 不是 (1 2 3)。反引号本身不会让符号求值。
4 `(1 ,x 3):第二个位置开了洞,求 x 得 2,填回去。输出 (1 2 3)。
5 `(1 x ,3):这次逗号加在 3 上。3 求值还是 3,而 x 没有逗号, 保持符号原样。输出 (1 x 3)。这一行专门用来打消「有逗号出现,整行就都求值」的误解—— 解引用是逐位置生效的。
6 `(1 (,x) 3):解引用可以出现在任意嵌套深度。里层列表的第一个位置被求值成 2, 外层结构 (1 (…) 3) 保持不变。输出 (1 (2) 3)。
7 `(1 ,(+ x 2) 3):逗号后面可以是任意调用表达式,不只是变量名。 求 (+ 2 2) 得 4。输出 (1 4 3)。
8 (define y 3) 返回符号 y。
9 `(x ,(* y x) y):三个位置里只有中间那个求值,(* 3 2) 得 6; 两侧的 x 和 y 仍是裸符号。输出 (x 6 y)。 同一个名字 x,在有逗号的位置是「值 2」,在没逗号的位置是「符号 x」—— 这正是宏能做代码变换的根本原因。
10 `(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.restLink(Link('-', Link(2, Link(4))), Link(6, Link(8)))((- 2 4) 6 8)
p.rest.firstLink('-', Link(2, Link(4)))(- 2 4)
p.rest.rest.first66
p.rest.first.rest.first22
为什么第 6 问要强调「求值之前」

因为 (- 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:

逐步推演
1 s = '(' + repl_str('+') → s = "(+";rest = p.rest。
2 rest.first 是嵌套 Link,repl_str 对它调 str(), 递归得到 "(- 2 4)"。s = "(+ (- 2 4)"。
3 下一个结点 first = 6 → s = "(+ (- 2 4) 6"。
4 下一个结点 first = 8 → s = "(+ (- 2 4) 6 8";此时 rest = nil,退出循环。
5 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。

逐步推演
1 exp 是 Link,进入第一分支。operator = '//', operands = Link(100, Link(Link('+', Link(2, Link(3))), nil)),即 (100 (+ 2 3))。
2 '//' 既不是 'and' 也不是 'define',走 else 分支。
3 先算 calc_eval('//'):不是 Link,检查 '//' in OPERATORS → 真, 返回函数对象 floor_div。
4 再算 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))。
5 calc_apply(floor_div, Link(100, Link(5, nil))) → floor_div(Link(100, Link(5, nil)))。
6 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)。

轮次进入时 resultrest.first退出时 result
初始100—100
1100250
250225
325212
41226
5623

返回 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)10 在 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 归纳成规则:

1 没有算子数 → 返回 #t(scheme_t)。
2 从左往右求值,一旦某个算子数求出来是 #f → 立刻返回 #f,剩下的不看。
3 全都不是 #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

逐步推演
1 exp 是 Link;operator = 'and',operands = Link(0, Link(1, nil))。 命中 if operator == 'and' → 调 eval_and(Link(0, Link(1, nil)))。 注意这里没有经过 map_link,算子数还是原样的、未求值的。
2 expressions 不是 nil,跳过第一个 if。
3 循环判断:expressions.rest 是 Link(1, nil),不是 nil → 进入循环体。
4 calc_eval(0):不是 Link;0 in OPERATORS 为假; isinstance(0, int) 为真 → 返回 0。 判断 0 is False → 假(0 和 False 是两个不同的对象)。 所以不短路。若这里写成 0 == False,就会是真,函数错误地返回 False。
5 expressions = Link(1, nil)。再判循环条件:expressions.rest is nil → 退出循环。
6 return calc_eval(1) → 1。 ✓

验证二:calc_eval(Link("and", Link(False, Link("1", nil)))) → False

逐步推演
1 eval_and(Link(False, Link("1", nil)))。非 nil,进入循环 (rest 是 Link("1", nil),非 nil)。
2 calc_eval(False):不是 Link;False in OPERATORS 为假 (字典的键都是字符串);isinstance(False, bool) 为真 → 返回 False。
3 False is scheme_f → 真 → 立即 return scheme_f。
4 字符串 "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) 才被求值一次。

直觉:thunk

把表达式裹成 (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))

逐步推演
1 绑定形参(不求值):n ← 表达式 (+ 2 3), expr ← 表达式 (print 1)。 此刻 (+ 2 3) 还没算成 5,(print 1) 也还没打印。
2 求值宏体:`(repeated-call ,n (lambda () ,expr))。 把 ,n 换成 (+ 2 3),把 ,expr 换成 (print 1),得到新表达式
(repeated-call (+ 2 3) (lambda () (print 1)))
这一步只是拼装了一段代码,什么都还没执行。
3 在调用处的帧里求值这段新代码。这是一个普通调用表达式: 算子 repeated-call 求值成过程; 算子数 (+ 2 3) 求值成 5; 算子数 (lambda () (print 1)) 求值成一个过程对象—— 创建它不执行 (print 1),所以此刻仍然一个字都没打印。
4 repeated-call 的第 1 帧:n=5, f=<thunk>。 (= 5 1) 假 → (begin (f) (repeated-call 4 f))。 (f) → 打印 1(第 1 次)。
5 第 2 帧 n=4:打印 1(第 2 次),递归到 n=3。
第 3 帧 n=3:打印 1(第 3 次),递归到 n=2。
第 4 帧 n=2:打印 1(第 4 次),递归到 n=1。
6 第 5 帧 n=1:(= 1 1) 真 → (f) → 打印 1(第 5 次), 返回 print 的返回值 undefined。
7 一共打印 5 行 1,与测试用例一致。 ✓

验证二:(repeat 2 (repeat 2 (print 'four))) 为什么打印四次

这条用例专门验证「expr 真的被重新求值了,而不是只算一次然后重复用它的值」。

逐步推演
1 外层宏展开:n ← 2,expr ← 整段 (repeat 2 (print 'four))(未求值!)。 生成
(repeated-call 2 (lambda () (repeat 2 (print 'four))))
2 求值这段代码:(lambda () (repeat 2 (print 'four))) 变成一个 thunk,体内的 (repeat 2 ...) 还没展开也没求值。
3 repeated-call 第 1 帧 n=2:走 begin,先 (f)。 调用 thunk 就是求值它的体 (repeat 2 (print 'four))—— 此时内层宏才展开,生成 (repeated-call 2 (lambda () (print 'four))), 执行后打印 four four(第 1、2 次)。
4 回到外层,尾调用 (repeated-call 1 f)。(= 1 1) 真 → 再 (f) 一次, 内层宏又展开一遍,再打印 four four(第 3、4 次)。
5 合计 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),调用一次。