CS 61A  /  作业解析
PROJECT 4

项目四:Scheme 解释器ok 235 项通过

用 Python 写一个能跑 Scheme 的解释器:eval 调用 apply,apply 又回头调用 eval,两个函数互相递归,撑起了一整门语言。

对应讲次:Lecture 20 · 解释器、Lecture 21 · 尾调用与宏 前置:Lecture 19 · Scheme 列表 官方题面:cs61a.org/proj/scheme 代码:proj/scheme/

0. 这份作业在练什么

前三个项目都是「用一门语言解决一个问题」:Hog 练控制流,Cats 练序列处理,Ants 练面向对象。 这个项目换了个方向——你要写的程序本身就是一门语言。给它一段 Scheme 源码,它得读懂、算出结果、把结果打印出来。

说得再具体一点:整个学期我们都在问「Python 拿到 (f x) 之后一步步做了什么」。 以前这个问题的答案藏在 CPython 里,你只能靠环境图去猜。现在你要把那个「一步步做什么」亲手写成 Python 代码。 写完之后,环境图不再是黑板上的示意图,而是你 scheme_classes.py 里那个 Frame 类的实例; 「查名字要沿着 parent 链往上找」也不再是一句口诀,而是你写的 lookup 里那一行 return self.parent.lookup(symbol)。

本次要点
  • eval / apply 互递归:求值一个调用表达式要先求值它的各个子表达式(eval → eval), 拿到过程和实参后交给 apply;apply 一个用户定义过程又要求值它的函数体(apply → eval)。 这个环是整个解释器的心脏。
  • 程序即数据:Scheme 源码 (+ 1 2) 在解释器内部就是一个链表 Link('+', Link(1, Link(2, nil)))。 读代码 = 遍历链表。这是 Lisp 家族最独特的一点。
  • 特殊形式(special form)vs 调用表达式(call expression): (if a b c) 不能先把 a b c 都算出来再「调用 if」,否则短路就没了。 凡是「不能无脑先求值所有子表达式」的,都得单独写一个 do_*_form 函数。
  • 作用域(scope)是可以选的:把新帧的 parent 设成「定义处的环境」就是词法作用域(lexical scoping), 设成「调用处的环境」就是动态作用域(dynamic scoping)。项目里的 lambda 和 mu 只差这一行。
  • 尾调用优化(tail call optimization):用 trampolining 把 Python 里无限增长的调用栈压成一个 while 循环。

做之前该掌握什么

需要的知识来自在项目里用来干什么
链表 Link 的遍历与构造Lecture 16、Lab 8Scheme 表达式和实参列表全是 Link
环境图、帧与 parent 链Lecture 3、Lecture 5Frame.lookup / make_child_frame 就是环境图的代码化
树递归 / 深度递归Lecture 11scheme_eval 本身就是一个树递归函数
Scheme 基本语法与列表操作Lecture 19、HW 5Phase 4 要用 Scheme 写四个过程
Python 的 *args 解包、异常处理Lecture 7调用内建过程时把 Scheme 列表摊成 Python 参数

整个项目分四个阶段:Phase 1(问题 1–5)让解释器能算内建过程调用, Phase 2(问题 6–10)加上用户自定义过程, Phase 3(问题 11–13)加上 mu 和逻辑特殊形式, Phase 4(问题 14–16)反过来用你写的解释器跑 Scheme 代码。 另外两道 Extra Challenge(EC1 的 let、EC2 的尾调用优化)不计分但极有价值,本仓库也都实现了。

本仓库在 proj/scheme/ 下执行 python3 ok --local 的结果是 235 个测试用例全部通过(其中 tests.scm 里 116 条 Scheme 级测试全过), 包含 EC1 与 EC2。下面贴出的每一段代码都是这份真实通过的实现。

1. 先读懂脚手架:解释器的骨架长什么样

这个项目不像前几个可以「看到函数签名就动手」。你得先弄明白发下来的十几个文件之间是什么关系, 否则第一题就不知道自己在改哪。官方也知道这一点,所以专门给了一组不计分的概念题 (python3 ok -q eval_apply -u)。我们把这些概念题当作导览来走一遍。

读取(read)→ 求值(eval)→ 打印(print)

你在 scm> 提示符后敲的一行字,要经过三道工序:

1 词法分析 + 语法分析:scheme_tokens.py 把字符串切成记号, scheme_reader.py 把记号串组装成嵌套的 Link。 这两个文件已经写好了,你不用碰。 经过这一步,"(+ 1 (* 2 3))" 变成 Link('+', Link(1, Link(Link('*', Link(2, Link(3, nil))), nil)))。
2 求值:scheme_eval(expr, env) 拿着这个 Link 和一个 Frame, 算出一个 Python 值。这是你要写的部分。
3 打印:repl_str(在 link.py 里)把 Python 值转回 Scheme 的显示形式: True → #t、None → 什么都不打印、Link → (1 2 3)。

概念题逐题拆

问:哪些类型的表达式用 Link 表示? 选项有「只有调用表达式」「只有特殊形式」「调用表达式和特殊形式」「所有表达式都是 Link」。 正确答案是调用表达式和特殊形式。

为什么不是「所有表达式」?因为原子(atom)不是 Link: 数字 2 就是 Python 的 int 2,符号 x 就是 Python 字符串 'x', 布尔 #t 就是 Python 的 True。 只有带括号的、非原子的表达式才是链表。为什么不是「只有调用表达式」? 因为 (if a b c) 也带括号,读取器不知道也不关心 if 是特殊形式—— 它只管把括号里的东西串成链表,区分特殊形式和调用表达式是求值阶段的事。

问:scheme_eval 函数体里哪个表达式负责「找出一个名字的值」? 答案是 env.lookup(expr)。这句话本身很浅,但它点出了一个关键设计: 名字的解析不是 scheme_eval 自己做的,而是委托给环境对象。 scheme_eval 只判断「这是不是一个符号」,具体怎么沿 parent 链往上找,是 Frame 的责任。 这就是抽象屏障——问题 1 你改 Frame.lookup 的时候,不需要动 scheme_eval 一个字。

问:怎么知道一个组合式(combination)是特殊形式? 答案是「检查列表的第一个元素是不是符号,并且该符号在 SPECIAL_FORMS 字典里」。 两个条件缺一不可。只检查「在字典里」不行:SPECIAL_FORMS 的键是字符串, 而 first 可能是一个 Link(比如 ((lambda (x) x) 3) 的第一个元素), 拿 Link 去查字典会因为 Link 没有 __hash__(它定义了 __eq__ 却没定义 __hash__)而报 TypeError。 只检查「是符号」更不行,那样 (+ 1 2) 也会被当成特殊形式。

问:应用内建过程和应用用户定义过程有什么区别?(多选)

陈述对不对理由
I. 用户定义过程会开一个新帧,内建过程不会对内建过程的「帧」是 Python 的调用帧,Scheme 层面看不见,也不需要 Frame 对象
II. 内建过程直接执行一个预先写好的 Python 函数;用户定义过程必须再去求值函数体里的表达式对这正是 scheme_apply 里两个分支的差别:一个 procedure.py_func(*args),一个 eval_all(procedure.body, ...)
III. 内建过程参数个数固定,用户定义过程不固定错反了。(+ 1 2 3 4) 说明内建的 + 参数个数可变;而 (lambda (x y) ...) 的形参个数是死的,多一个少一个都要 SchemeError

所以答案是 I and II。第 III 条特别值得警惕:它把「Python 函数用 *args 接收任意个参数」 和「Scheme 过程的元数(arity)」搞混了。

问:表达式 (1) 应该抛出什么异常? 答案是 SchemeError("1 is not callable")。 推理过程:(1) 是一个合法的列表,所以不会是 malformed list; 1 不是符号,所以不会走 lookup,也就不会是 unknown identifier; 它是自求值的,算出来还是 1;然后 1 被当作算子传给 scheme_apply, 而 scheme_apply 第一行就是 validate_procedure(procedure)——它发现 1 不是 Procedure 实例, 抛出 SchemeError。这道题其实在提醒你:算子求值完之后要交给 scheme_apply, 不要自己写 if isinstance(...) 去挡,否则错误消息就对不上了。

四个你要改的文件

文件负责什么涉及题号
scheme_classes.pyFrame(环境)、LambdaProcedure、MuProcedure 等数据结构1、8
scheme_eval_apply.pyscheme_eval、scheme_apply、eval_all、尾调用优化2、3、6、9、11、EC2
scheme_forms.py所有 do_*_form 特殊形式处理函数4、5、7、10、11、12、13、EC1
questions.scm用 Scheme 写的四个过程14、15、16
直觉

如果你把 scheme_eval 想成「读环境图上的一行代码」, 把 Frame 想成「环境图上的一个方框」,把 scheme_apply 想成「画一个新方框并把箭头连过去」, 那么这个项目就是把一整个学期画过的所有环境图,编码成 Python。 遇到卡壳的题,先在纸上画环境图,代码几乎会自己写出来。

2. 问题 1:Frame.define 与 Frame.lookup

题目要什么

把环境图里的「一个方框」实现出来。Frame 实例有两个属性: bindings 是一个 Python 字典,把 Scheme 符号(用 Python 字符串表示)映射到 Scheme 值; parent 是父帧,全局帧的 parent 是 None。

  • define(symbol, value):在当前这一帧里把 symbol 绑到 value。 注意是「当前这一帧」,不是「找到 symbol 所在的那一帧再改」——Scheme 的 define 永远是在当前帧新建/覆盖绑定。
  • lookup(symbol):从当前帧开始找,找不到就去 parent 帧找,一直找到全局帧。 全部找不到就抛 SchemeError,不是返回 None。

边界情况有两个容易被忽略的:其一,lookup 找到的必须是最内层的那个绑定 (同一个名字在多层帧里都有时,近的赢);其二,值可能是 False 或 None, 所以判断「找到没有」必须用 symbol in self.bindings,不能用 if self.bindings.get(symbol): 这种真值判断。ok 的第二个测试用例里就有 second_frame.define("y", False) 紧接着 second_frame.lookup("y") 要返回 False, 专门卡这个坑。

怎么想到的

define 没什么可想的,一行字典赋值。麻烦的是 lookup。

第一反应可能是写个 while 循环:

frame = self
while frame is not None:
    if symbol in frame.bindings:
        return frame.bindings[symbol]
    frame = frame.parent
raise SchemeError(...)

这么写完全正确,ok 也会通过。但停下来想一秒:「查当前帧,查不到就在 parent 帧里做同样的事」 ——这句话本身就是递归的定义。父帧也是一个 Frame,它也有 lookup 方法, 那为什么不直接调用它?

关键一步

「环境」这个概念本身就是递归定义的:一个环境 = 一个帧 + 它的父环境。 数据结构是递归的,处理它的函数就该是递归的。这也是为什么 Frame 类的文档注释说 「each frame also represents an environment starting with that frame」—— 一个帧对象既是「一个方框」,也是「从这个方框开始的整条链」。

写成递归还有一个隐性好处:base case 长什么样一目了然。 递归有两个出口——在本帧找到了(返回值),或者本帧没有且没有 parent 了(抛错)。 这两个出口在 while 版本里被循环条件搅在一起,容易写漏。

代码

    def define(self, symbol, value):
        """Define Scheme SYMBOL to have VALUE."""
        # BEGIN PROBLEM 1
        # A frame's bindings dict maps symbols (Python strings) to values.
        self.bindings[symbol] = value
        # END PROBLEM 1

    def lookup(self, symbol):
        """Return the value bound to SYMBOL. Errors if SYMBOL is not found."""
        # BEGIN PROBLEM 1
        # Search this frame first, then walk up the chain of parent frames.
        if symbol in self.bindings:
            return self.bindings[symbol]
        if self.parent is not None:
            return self.parent.lookup(symbol)
        # END PROBLEM 1
        raise SchemeError('unknown identifier: {0}'.format(symbol))

逐行说明:

  • self.bindings[symbol] = value:直接赋值而不是先检查是否存在。 Scheme 里 (define x 1) 之后再 (define x 2) 是合法的重新绑定, ok 的第一个用例就连着 define 了两次 x。
  • if symbol in self.bindings::用 in 而不是 .get(),理由上面说过——值可能是 False。
  • if self.parent is not None: return self.parent.lookup(symbol): 注意这里是 return,不是光调用。忘记 return 是最常见的错误, 结果是函数继续往下走,落到 raise,于是「明明父帧里有这个名字却说找不到」。
  • raise 写在 if 之外、方法末尾:只有当「本帧没有」且「没有父帧」时才会执行到这里。 这一行本来就在起始代码里,位置不用动——它同时兼任了两条路径失败后的汇合点。

验证

拿 ok 第四个测试用例走一遍,它构造了一棵帧树:

>>> first_frame = create_global_frame()
>>> first_frame.define("x", 1)
>>> second_frame = Frame(first_frame)
>>> third_frame = Frame(second_frame)
>>> fourth_frame = Frame(first_frame)
>>> fifth_frame = Frame(fourth_frame)
first_frame  (Global)   {x: 1}          parent = None
   ├── second_frame     {}              parent = first_frame
   │      └── third_frame  {}           parent = second_frame
   └── fourth_frame     {}              parent = first_frame
          └── fifth_frame {}            parent = fourth_frame
逐步推演

third_frame.lookup("x")(第一次,此时 second_frame 还是空的):

  1. third_frame.bindings 是 {},"x" not in {} → 不返回。
  2. third_frame.parent 是 second_frame,不是 None → return second_frame.lookup("x")。
  3. second_frame.bindings 是 {} → 不返回。parent 是 first_frame → return first_frame.lookup("x")。
  4. first_frame.bindings 是 {x: 1},"x" in 成立 → 返回 1。
  5. 逐层回代:second_frame.lookup 返回 1 → third_frame.lookup 返回 1。✓

接着 second_frame.define("x", 2),环境变成:

first_frame  {x: 1}
   ├── second_frame  {x: 2}
   │      └── third_frame {}
   └── fourth_frame  {}
          └── fifth_frame {}

再 third_frame.lookup("x"):third 空 → 上到 second,"x" in {x: 2} 成立, 立刻返回 2,不再继续往上。这就是「近的赢」。✓

而 fifth_frame.lookup("x"):fifth 空 → fourth 空 → first 有 x: 1 → 返回 1。 second_frame 里那个 x: 2 完全影响不到 fifth 这一支,因为它根本不在 fifth 的 parent 链上。✓

最后 first_frame.define("x", 4): fourth_frame.lookup("x") → fourth 空 → first 有 x: 4 → 4; 但 third_frame.lookup("x") 在 second_frame 就被截住了 → 还是 2。✓ 全局帧的改动只对「没被遮蔽(shadow)」的那些分支可见。

完成这一题后,启动 python3 scheme.py 已经能查到内建过程的名字了:

scm> +
#[+]
scm> odd?
#[odd?]
scm> hello
SchemeError

为什么 + 显示成 #[+]?因为 create_global_frame() 已经把所有内建过程 以 BuiltinProcedure 实例的形式 define 进了全局帧,而 BuiltinProcedure.__str__ 返回的就是 '#[{0}]'.format(self.name)。你的 lookup 把这个对象取了出来,REPL 把它打印了。 但你还不能调用它——那是下一题。

常见误区

误区一:在 lookup 里写 self.parent.lookup(symbol) 却忘了 return。 症状是所有跨帧查找都报 SchemeError: unknown identifier,而同帧查找正常—— 非常容易被误判成「parent 没接对」。

误区二:把 raise 那行放进 else 分支或者提到前面去。 它必须是「两条路都走不通」之后的兜底,位置错了会把正常的父帧查找也掐掉。

误区三:以为 define 要先检查名字是否已在某个祖先帧中定义过。 不需要。Scheme 的 define 是「在当前帧建绑定」,不是 Python 意义上的赋值语句, 更不是 set!。测试用例 first_frame.define("y", 0) 之后 fourth_frame.lookup("y") 仍然是 1(second_frame 里的),就是在验证这一点。

3. 问题 2:scheme_apply 的 BuiltinProcedure 分支

题目要什么

让 (+ 1 2) 真的能算出 3。scheme_apply(procedure, args, env) 收到三样东西: 一个过程对象、一个Scheme 列表形式的实参值(Link 或 nil)、当前环境。 当 procedure 是 BuiltinProcedure 时,它背后是一个普通的 Python 函数 (存在 procedure.py_func 里),你要做的是:

  1. 把 Scheme 列表 args 转成 Python 列表;
  2. 如果 procedure.need_env 为真,把 env 追加到列表末尾;
  3. 在给定的 try: 内部,用 * 解包调用 procedure.py_func 并返回结果。

已经给好的部分是:如果调用过程中抛 TypeError(说明参数个数不对), except 会把它转成 SchemeError('incorrect number of arguments')。

边界情况:args 可能是 nil(零个参数,比如 (+) 要返回 0); args.first 本身也可能是 nil(比如 (length nil),此时 Python 列表是 [nil],长度 1,不是 0)。 这两个「nil」意思完全不同,混起来就错。

怎么想到的

这题的核心矛盾是两个世界的参数表示法不一样。 Scheme 那边,实参是一条链表;Python 这边,函数要的是位置参数。 中间必须有个翻译层,而这个翻译层只能是「遍历链表,把每个 first 收进一个 Python list」。

为什么不能直接 procedure.py_func(args)? 因为那样 scheme_add 会收到一个 Link 对象当作它的第一个参数, 然后试图对 Link 做加法,报 TypeError——而这个 TypeError 会被 except 抓住, 伪装成「参数个数不对」。这是本题最阴险的一个陷阱:写错了不会告诉你写错了, 只会给你一条误导性的错误消息。

接着是 need_env。为什么有的内建过程需要环境?看 eval: (eval '(+ 1 2)) 要在当前环境里求值那个被引用的表达式, 所以 Python 侧的 scheme_eval(expr, env) 必须拿到 env。 把 env 加在末尾而不是开头,是因为 scheme_eval 的签名是 (expr, env)—— 第一个参数留给真正的 Scheme 实参。

关键一步

need_env 这个设计告诉你:「当前环境」不是全局可见的,它是显式传递的参数。 整个解释器里没有任何全局变量存「我现在在哪个帧」——环境永远作为参数一路传下去。 这正是为什么 scheme_eval、scheme_apply、eval_all、每个 do_*_form 的签名里都有一个 env。

最后一个坑:except TypeError 必须只抓 TypeError。 ok 里有两个用例专门测 (exit)——scheme_exit 抛的是 EOFError, 它必须原样冒泡上去,REPL 才能正常退出。如果你把 try 的范围写得太大, 或者写成 except Exception,(exit) 就会变成「参数个数不对」,退不出去。 还有一个用例用闭包做了个计数器,要求 py_func 只被调用一次—— 所以不要写「先试着调用一次看会不会出错,再调用一次拿返回值」这种代码。

代码

    if isinstance(procedure, BuiltinProcedure):
        # BEGIN PROBLEM 2
        # Built-ins are plain Python functions, so unpack the Scheme list of
        # arguments into a Python list first.
        python_args = []
        while args is not nil:
            python_args.append(args.first)
            args = args.rest
        if procedure.need_env:
            # Procedures like eval need the calling environment as a last arg.
            python_args.append(env)
        # END PROBLEM 2
        try:
            # BEGIN PROBLEM 2
            return procedure.py_func(*python_args)
            # END PROBLEM 2
        except TypeError as err:
            raise SchemeError('incorrect number of arguments: {0}'.format(procedure))
  • 这里只贴出本问要填的 BuiltinProcedure 分支; scheme_apply 三条分支都填满后的完整函数见第 12 节。
  • while args is not nil: 用 is not 而不是 !=。 nil 是 Link.empty,也就是空元组 ();而 Link 定义了 __eq__, 用 != 会触发结构比较,既慢又可能在嵌套结构上产生意外。整个项目里判断链表结束一律用 is nil / is not nil。
  • python_args.append(args.first):只取 first,不做任何求值。 到了 scheme_apply 这一步,实参已经是值了,不是表达式——求值是 scheme_eval 在调用 scheme_apply 之前干的。 这个先后顺序是问题 3 的内容,但现在就要在脑子里分清楚。
  • args = args.rest:这里重新绑定的是局部参数名 args, 没有修改任何 Link 对象。题目反复强调「不要修改传入的表达式」, 指的是不能写 args.first = ... 这种;重新绑定名字是安全的。
  • if procedure.need_env: 放在 try 外面。 放里面也能过,但语义上它属于「准备参数」而不是「调用」, 而且 try 块越小、except TypeError 误伤的可能性越低。
  • return procedure.py_func(*python_args):这一行必须在 try 里, 因为参数个数不匹配的 TypeError 正是在调用的一刻产生的。

验证

逐步推演

ok 用例:scheme_apply(plus, Link(2, Link(2, nil)), env),其中 plus = BuiltinProcedure(scheme_add),need_env 默认是 False。

步骤argspython_args
进入循环前Link(2, Link(2, nil))[]
第 1 轮后Link(2, nil)[2]
第 2 轮后nil[2, 2]
循环结束—need_env 为 False,不追加

然后 procedure.py_func(*[2, 2]) 即 scheme_add(2, 2) → 4。✓

再看 scheme_apply(plus, nil, env):循环一次都不进,python_args = [], 调用 scheme_add()。scheme_add 是用 *vals 定义的可变参数函数, 零个参数时返回加法单位元 0。✓ 这就是 (+) 等于 0 的来历。

对比 scheme_apply(length, Link(nil, nil), env): 循环跑一轮,python_args = [nil](列表里装着一个 nil,长度是 1), 调用 scheme_length(nil) → 0。✓ 如果你误以为「args.first is nil 就说明没有参数」而跳过, 就会调用 scheme_length(),抛 TypeError,变成 SchemeError,测试挂掉。

最后是 need_env 的用例: q = BuiltinProcedure(lambda g: g is env, True),args = nil。 循环不进,python_args = [];need_env 为真,追加 env → [env]; 调用 py_func(env),比较 env is env → True。✓

常见误区

误区一:写成 except TypeError 但把 while 循环也塞进了 try。 本身不会立刻出错,但一旦某个内建函数内部因为别的原因抛 TypeError, 你会得到「incorrect number of arguments」这条完全不着边际的消息, 调试时能浪费掉一小时。

误区二:用 while args != nil。当某个实参本身是复杂的嵌套 Link 时, Link.__eq__ 会递归比较整棵结构,性能变差;更糟的是它对非 Link 的 rest (点对,dotted pair)行为和 is 不一致。

误区三:把 env 插到 python_args 开头。 (eval '(+ 1 2)) 会变成 scheme_eval(env, Link('+', ...)), 参数顺序颠倒,报出的错误千奇百怪。

4. 问题 3:scheme_eval 求值调用表达式

题目要什么

补上 scheme_eval 里 else 分支——也就是「这个组合式不是特殊形式,那它就是一个调用表达式」的情况。 三步:

  1. 求值算子(operator),结果应该是一个 Procedure 实例;
  2. 求值所有算子数(operand),把结果收集成一个 Scheme 列表;
  3. 把过程、实参列表、当前环境交给 scheme_apply,返回它的结果。

题面给了两个工具:map_link(f, s) 对 Scheme 列表每个元素应用单参数 Python 函数、 返回新的 Scheme 列表;以及 scheme_apply。 两条硬性约束:不能修改传入的 expr;算子只能求值一次。

怎么想到的

「求值一个调用表达式」这句话,整个学期的环境图课都在讲: 先算算子,再算算子数,然后应用。现在只是把它写下来。真正需要动脑的是三个细节。

细节一:结果要是 Scheme 列表,不是 Python 列表。 为什么?因为 scheme_apply 的第二个参数按约定就是 Link 或 nil—— 问题 2 里你写的 while args is not nil 就是这么假设的。 如果你在这里传 Python 列表,问题 2 的循环会立刻爆炸。 两个函数是一对接口,谁也不能单方面改约定。

那怎么把「对每个元素求值」的结果攒成一个 Link?手写递归当然可以:

# 可以,但没必要
def eval_operands(rest, env):
    if rest is nil:
        return nil
    return Link(scheme_eval(rest.first, env), eval_operands(rest.rest, env))

但 link.py 里已经有 map_link 了,它干的正是这件事。用它就好。

细节二:算子只能求值一次。题面给了一个非常刁钻的测试:

(define x 0)
; expect x
((define x (+ x 1)) 2)
; expect SchemeError
x
; expect 1

这里的算子是 (define x (+ x 1)),一个有副作用的表达式。 求值它一次,x 变成 1,返回符号 'x';'x' 不是过程, 所以 scheme_apply 里的 validate_procedure 抛 SchemeError。 最终 x 是 1。可如果你写了类似

# 错误写法:算子被求值了两次
if not isinstance(scheme_eval(first, env), Procedure):
    raise SchemeError('not callable')
return scheme_apply(scheme_eval(first, env), args, env)

那 x 就会变成 2,测试失败。解法是把算子求值的结果存进一个局部变量, 之后只用这个变量。

细节三:求值顺序。ok 里有一组 Scheme 级测试用 print-then-return(打印第一个参数、返回第二个)精确地检查顺序:

scm> ((print-then-return 6 print) (print-then-return 1 "a"))
6
1
"a"

先打印 6,说明算子先于算子数被求值;再打印 1,说明算子数随后被求值; 最后 print 打印出 "a"。还有:

scm> (+ (print-then-return 1 1) (print-then-return 2 2))
1
2
3

1 在 2 之前,说明算子数从左到右求值。 这一条是 map_link 白送的——它是 Link(f(s.first), map_link(f, s.rest)), Python 的实参求值顺序是从左到右,所以 f(s.first) 一定先于递归调用发生。 但如果你自作聪明先构造再反转,顺序就反了。

关键一步

写下 procedure = scheme_eval(first, env) 这一行的时候,注意它是递归调用。 first 可能是符号 '+'(走 env.lookup 分支), 也可能是另一个组合式 (lambda (x) x)(走特殊形式分支), 甚至是 ((f) 3) 里的 (f)(又走一遍调用表达式分支)。 你不需要为这些情况分别写代码——递归会自动处理。 这就是 eval 的递归结构值钱的地方。

代码

填完之后,scheme_eval_apply.py 里这个函数的完整样子如下(连同脚手架原有的部分, 整段可以直接对照抄进文件):

def scheme_eval(expr, env, _=None): # Optional third argument is ignored
    """Evaluate Scheme expression EXPR in Frame ENV.

    >>> expr = read_line('(+ 2 2)')
    >>> expr
    Link('+', Link(2, Link(2)))
    >>> scheme_eval(expr, create_global_frame())
    4
    """
    # Evaluate atoms
    if scheme_symbolp(expr):
        return env.lookup(expr)
    elif self_evaluating(expr):
        return expr

    # All non-atomic expressions are lists (combinations)
    if not scheme_listp(expr):
        raise SchemeError('malformed list: {0}'.format(repl_str(expr)))
    first, rest = expr.first, expr.rest

    from scheme_forms import SPECIAL_FORMS # Import here to avoid a cycle when modules are loaded
    if scheme_symbolp(first) and first in SPECIAL_FORMS:
        return SPECIAL_FORMS[first](rest, env)
    else:
        # BEGIN PROBLEM 3
        # A call expression: evaluate the operator once, then every operand.
        procedure = scheme_eval(first, env)
        args = map_link(lambda operand: scheme_eval(operand, env), rest)
        return scheme_apply(procedure, args, env)
        # END PROBLEM 3
  • procedure = scheme_eval(first, env):结果存进局部名字,保证只求值一次。 不做任何类型检查——检查交给 scheme_apply 里的 validate_procedure, 这样错误消息才是官方期望的那条。
  • map_link(lambda operand: scheme_eval(operand, env), rest): map_link 只接受单参数函数,而 scheme_eval 需要两个参数, 所以要用 lambda 把 env 闭包进去。 这是本课「高阶函数 + 闭包」在真实代码里的一次朴素应用。
  • 顺序:算子那行在前,map_link 那行在后。 Python 是顺序执行的,这两行的先后就直接决定了 Scheme 的求值顺序。
  • rest 全程只读,map_link 返回的是新链表,原 expr 一个字节没动。 这满足了「不要修改传入表达式」的要求——而这个要求不是洁癖: 函数体在 LambdaProcedure.body 里是被反复求值的,改了它下次调用就错了。

验证

逐步推演

求值 (* (+ 3 2) (+ 1 7)),环境是全局帧 G。读取器给的是
Link('*', Link(Link('+', Link(3, Link(2))), Link(Link('+', Link(1, Link(7))))))。

  1. scheme_eval(整个表达式, G):不是符号、不自求值、是列表。 first = '*',rest = ((+ 3 2) (+ 1 7))。 '*' 是符号但不在 SPECIAL_FORMS 里 → 走 else。
  2. procedure = scheme_eval('*', G):'*' 是符号 → G.lookup('*') → BuiltinProcedure(scheme_mul)。
  3. map_link 开始工作,先处理 rest.first,也就是 (+ 3 2):
    • 递归 scheme_eval((+ 3 2), G):first = '+',不是特殊形式 → else。
    • procedure = G.lookup('+') → BuiltinProcedure(scheme_add)。
    • map_link 对 (3 2):scheme_eval(3, G) → self_evaluating 成立 → 3; 同理得 2。得到 Link(3, Link(2, nil))。
    • scheme_apply(加法, Link(3, Link(2)), G) → 问题 2 的代码 → scheme_add(3, 2) → 5。
  4. map_link 递归处理 ((+ 1 7)),同样得到 8。 两个结果串成 Link(5, Link(8, nil))。
  5. scheme_apply(乘法, Link(5, Link(8, nil)), G) → scheme_mul(5, 8) → 40。✓

注意第 3 步里 eval 调了 apply,apply 又(在别的分支里)会调 eval—— 这一题完成后,那个互递归的环第一次真正闭合了。

再追一个报错用例:(car car)。 算子 car 求值成 BuiltinProcedure,算子数 car 也求值成同一个 BuiltinProcedure, 于是调用 scheme_car(BuiltinProcedure)。scheme_car 内部会 validate_type(x, scheme_pairp, ...),发现不是 pair,抛 SchemeError。✓ 而 (odd? 1 2 3) 则是 scheme_oddp(1, 2, 3) 参数太多 → TypeError → 被 except 转成 SchemeError('incorrect number of arguments')。✓

常见误区

误区一:直接 return scheme_apply(scheme_eval(first, env), map_link(...), env) 写成一行。这本身是只求值一次、也是算子先于算子数(Python 从左到右求值实参), 所以能过测试。但一旦你为了调试插一句检查,很容易不小心写成两次调用。 拆成三行更安全,也更接近课上讲的三步骤。

误区二:用 Python 列表推导式收集实参再转链表,中间写了 reversed。 求值顺序会变成从右到左,(+ (print-then-return 1 1) (print-then-return 2 2)) 会先打印 2 再打印 1,测试失败——而且这种失败看起来完全像是「打印功能有 bug」。

误区三:在 else 分支里手动判断 if not isinstance(procedure, Procedure): raise SchemeError(...)。 消息文本对不上官方期望,而且如果你在判断时又调了一次 scheme_eval, 就会踩「算子被求值两次」的雷。

5. 问题 4:do_define_form 绑定一个符号

题目要什么

define 有两种用法:(define a (+ 2 3)) 把值绑到符号, (define (foo x) x) 造一个过程再绑上去。这一题只做第一种。

关键是搞清楚 do_define_form 收到的 expressions 到底是什么。 概念题问得很直白:「do_define_form 的 expressions 参数结构是什么?」 答案是 Link(A, Link(B, nil)),其中 A 是被绑定的符号,B 是一个表达式, 它的值要被绑到 A 上。

三个干扰项各错在哪,值得逐个看:

选项错在哪
Link(A, Link(B, nil)),B 是值B 是未求值的表达式。(define x (+ 7 3)) 里 B 是 Link('+', ...),不是 10。求值是你的活
Link(A, B)少了一层。Scheme 列表 (x expr) 是 Link('x', Link(expr, nil)),rest 必须还是个 Link
Link('define', Link(A, Link(B, nil)))把 define 本身也算进去了。scheme_eval 传的是 rest,符号 define 早被剥掉了

第二道概念题问「Frame 的哪个方法能把值绑到符号」,答案是 define—— 就是你在问题 1 写的那个。

do_define_form 完成后要返回被绑定的符号本身, 所以 REPL 里 (define x 5) 会回显 x。

怎么想到的

这题代码只有两行,思维量在于「先做什么后做什么」。

正确顺序是:先求值表达式,再绑定。 反过来想一下为什么不能先绑定一个占位符再求值——那就是另一种语义了。 考虑 (define x (+ x 1)),x 原来是 0: 如果先求值 (+ x 1),读到的是旧的 x = 0,结果 1,再绑定,得到 x = 1。 ok 的测试正是这么期望的(那个「算子只求值一次」的用例最后 x 是 1)。

其次,为什么这件事必须是特殊形式,不能是普通过程? 因为如果 define 是普通过程,scheme_eval 会先把它的两个算子数都求值—— 第一个算子数是符号 x,求值它会去 lookup('x'), 而 x 此刻还没定义,直接 SchemeError。 「有的子表达式不该被求值」正是特殊形式存在的唯一理由。

关键一步

expressions.rest.first 是那个要被求值的表达式。 写链表索引时在纸上画一遍:expressions 指向第一个结点(装着符号 x), expressions.rest 指向第二个结点,expressions.rest.first 才是第二个元素。 少写一个 .first,你就会把一个 Link 而不是表达式传给 scheme_eval。

代码

    validate_form(expressions, 2) # Checks that expressions is a list of length at least 2
    signature = expressions.first
    if scheme_symbolp(signature):
        # assigning a name to a value e.g. (define x (+ 1 2))
        validate_form(expressions, 2, 2) # Checks that expressions is a list of length exactly 2
        # BEGIN PROBLEM 4
        # (define <symbol> <expr>): evaluate the expression, bind, return name.
        env.define(signature, scheme_eval(expressions.rest.first, env))
        return signature
        # END PROBLEM 4
  • 这里只贴出本问要填的符号分支;do_define_form 的完整函数 (含问题 10 的过程简写分支)见第 11 节。
  • env.define(signature, scheme_eval(...)): Python 先求值实参,所以 scheme_eval 一定在 define 之前发生。顺序天然正确。
  • 绑定到 env——当前环境,不是全局帧。 所以在一个过程体里写 (define y 1),y 是这个调用帧的局部名字, 出了这个过程就没了。ok 问题 9 的用例 (define outer (lambda (x y) (define inner (lambda (z x) ...)) (inner x 10))) 就依赖这一点。
  • return signature:返回符号(一个 Python 字符串),不是值。 REPL 打印它,于是你看到 x。 顺带一提,这也是 (eval (define tau 6.28)) 返回 6.28 的原因: 内层 define 返回符号 'tau',eval 再求值这个符号, 查出刚绑上的 6.28。
  • 上面两行 validate_form 是给好的:先保证至少 2 个元素(否则 expressions.first 会崩), 确认是符号定义后再保证恰好 2 个元素——这样 (define x 2 y 4) 才会报 SchemeError。

验证

逐步推演

在全局帧 G 里连续执行三行:

scm> (define pi 3.14159)
scm> (define radius 10)
scm> (define area (* pi (* radius radius)))
  1. 第一行:scheme_eval 看到 first = 'define',在 SPECIAL_FORMS 里 → 调用 do_define_form(Link('pi', Link(3.14159, nil)), G)。 signature = 'pi' 是符号;scheme_eval(3.14159, G) → 自求值 → 3.14159; G.define('pi', 3.14159)。返回 'pi'。
  2. 第二行同理,G = {..., pi: 3.14159, radius: 10}。
  3. 第三行:scheme_eval(Link('*', Link('pi', Link(Link('*', ...)))), G) → 调用表达式 → G.lookup('*') 得乘法; 算子数 pi 求值成 3.14159,(* radius radius) 递归求值成 100; scheme_mul(3.14159, 100) → 314.159。 G.define('area', 314.159)。

接下来是这组测试最有意思的一步:

scm> (define radius 100)
radius
scm> area
314.159

area 没变。因为 define 绑的是那一刻算出来的值 314.159, 而不是「表达式 (* pi (* radius radius))」本身。 Scheme 里没有电子表格那种自动重算。这是「名字绑到值,不是绑到表达式」的直接后果。✓

再验一个报错:(define 0 1)。 signature = 0,scheme_symbolp(0) 为假(不是字符串), 也不是 Link,于是走到最后的 else: raise SchemeError('non-symbol: 0')。✓ (define error (/ 1 0)) 则是在 scheme_eval((/ 1 0), env) 里 被除法内建过程抛出 SchemeError,绑定根本没发生。✓

常见误区

误区一:写成 env.define(signature, expressions.rest.first),忘了求值。 症状是 (define x (+ 7 3)) 之后 x 打印出 (+ 7 3) 而不是 10。

误区二:写成 env.define(signature, scheme_eval(expressions.rest, env)), 少了 .first。expressions.rest 是 Link(10, nil), scheme_eval 会把它当成调用表达式 (10), 报 SchemeError: 10 is not callable——错误消息离真正的原因很远。

误区三:返回值写成 return scheme_eval(...) 或者干脆不返回。 REPL 会打印出 10 或者什么都不打印,而 ok 期望的是 x。

6. 问题 5:do_quote_form

题目要什么

一行代码,但概念密度很高。(quote x) 返回它的操作数本身,不求值。 读取器会把 'x 这种简写自动转成 (quote x),所以你只需要处理后者。

概念题问 do_quote_form 的 expressions 结构,答案是 Link(A, nil),A 是被引用的表达式。 干扰项 Link('quote', Link(A, nil)) 又一次犯了「没剥掉标识符」的错; 干扰项 A(直接就是表达式)忘了它被包在列表里。 起始代码里的 validate_form(expressions, 1, 1) 已经保证了恰好一个元素。

怎么想到的

难点不在写,在于理解「为什么需要 quote」。

Scheme 里,符号 x 求值会去查绑定,列表 (1 2) 求值会被当成调用表达式 (试图调用 1,报错)。可有时候你就是想把一段代码当数据用: 想要那个符号本身,想要那个列表本身。quote 就是「求值系统的逃生舱」。

一旦有了 quote,Lisp 的招牌特性「代码即数据(code as data)」就成立了。 问题 16 的 solution-code 之所以能把一段 Scheme 程序当链表来改写, 全靠 quote 把程序变成了普通数据。

关键一步

问自己:do_quote_form 里为什么不调用 scheme_eval? 因为整个特殊形式机制存在的意义就是「有些子表达式不该被求值」, 而 quote 是这个意义的极致——它的操作数一次都不求值。 所有 do_*_form 里,只有它连一次 scheme_eval 都不调。

代码

def do_quote_form(expressions, env):
    """Evaluate a quote form.

    >>> env = create_global_frame()
    >>> do_quote_form(read_line("((+ x 2))"), env) # evaluating (quote (+ x 2))
    Link('+', Link('x', Link(2)))
    """
    validate_form(expressions, 1, 1)
    # BEGIN PROBLEM 5
    # quote returns its single operand without evaluating it.
    return expressions.first
    # END PROBLEM 5

return expressions.first:取出唯一的元素,原样返回。 不复制、不遍历、不求值。env 参数完全用不上,但签名必须保留—— SPECIAL_FORMS 字典里所有函数都以 (rest, env) 被调用, 少一个参数会 TypeError。

验证

逐步推演

最能说明问题的是 ''hello,它的答案是 (quote hello) 而不是 hello。

  1. 读取器看到 ''hello。外层 ' 展开成 (quote ...), 内层 'hello 展开成 (quote hello)。 整体是 (quote (quote hello)),即 Link('quote', Link(Link('quote', Link('hello', nil)), nil))。
  2. scheme_eval:first = 'quote',在 SPECIAL_FORMS 里 → do_quote_form(rest, env),其中 rest = Link(Link('quote', Link('hello', nil)), nil)。
  3. return expressions.first → Link('quote', Link('hello', nil))。
  4. REPL 打印这个 Link,Link.__str__ 给出 (quote hello)。✓

关键在于:外层的 quote 把内层的 quote 当成了普通数据, 内层那个 quote 从头到尾没有作为特殊形式被执行过。 一层 quote 只挡一层求值。

再看一组更绕的:

scm> (cons 'car '('(4 2)))
(car (quote (4 2)))
scm> (eval (cons 'car '('(4 2))))
4
逐步推演
  1. 'car → (quote car) → 求值得符号 'car'。
  2. '('(4 2)):外层 ' 包住的是列表 ('(4 2)), 而 '(4 2) 本身就是 (quote (4 2))。 所以被引用的数据是 ((quote (4 2))),一个单元素列表, 元素是列表 (quote (4 2))。求值这个 quote 形式,原样返回它。
  3. (cons 'car ...) 把符号 car 接到这个列表前面,得到 (car (quote (4 2)))。第一行打印的就是它。✓
  4. 第二行把这坨东西喂给 eval。eval 是需要 need_env 的内建过程 (问题 2 里你处理过),它拿到当前环境, 求值 (car (quote (4 2))):算子 car → 内建 car; 算子数 (quote (4 2)) → 求值得列表 (4 2); scheme_car(Link(4, Link(2))) → 4。✓

这一串把「构造代码 → 求值代码」走了一遍。 你写的解释器现在能在运行时拼出程序然后执行它了。

常见误区

误区一:return expressions(忘了 .first)。 'hello 会打印成 (hello)——多了一层括号,因为你返回的是整个单元素列表。

误区二:以为要处理 ' 符号。不用。 scheme_reader.py 在读取阶段就把 'x 变成了 (quote x), 你的求值器只见得到后者。

误区三:担心「返回的是同一个 Link 对象,会不会被别人改坏」。 在这个项目里不会,因为所有代码都遵守「不修改表达式」的约定。 真要防御性复制反而会破坏 (quote x) 的对象同一性, 并且让问题 9 那道著名的自复制程序(quine)测试变得难以推理。

7. 问题 6:eval_all 与 begin

题目要什么

(begin e1 e2 ... en) 依次求值每个子表达式,整体的值是最后一个子表达式的值。 do_begin_form 已经写好了,它直接转调 eval_all(expressions, env), 所以真正要实现的是 eval_all。

规格:接收一个 Scheme 列表 expressions 和环境 env, 求值全部表达式,返回最后一个的值。 如果 expressions 是 nil,返回 Python 的 None(代表 Scheme 的 undefined)。 另有一条隐性要求,ok 用一个用例明确检查了:不能修改传入的链表。

为什么这个函数比看上去重要?因为它不只服务于 begin。 后面问题 9(调用 lambda 过程时求值函数体)、问题 13(cond 子句有多个结果表达式)、 EC1(let 体)全都要调它。「一串表达式,取最后一个的值」是 Scheme 里反复出现的模式。

怎么想到的

朴素写法是遍历整条链表,每个都求值,把结果存到变量里,循环结束返回最后存的那个:

# 能用,但有个瑕疵
result = None
while expressions is not nil:
    result = scheme_eval(expressions.first, env)
    expressions = expressions.rest
return result

这个版本连 nil 的情况都顺带处理了(循环不进,返回初始的 None), 非常紧凑。它能通过问题 6 的所有测试。

那为什么本仓库的实现要写成另一个样子?因为要为 EC2(尾调用优化)留位置。 在尾调用优化的框架下,最后一个表达式的地位和前面那些不同: 前面的表达式只是为了副作用(打印、define),它们的值被丢弃; 最后一个表达式处在尾位置(tail context)——它的值直接就是整个 eval_all 的值, 所以求值它的时候不需要保留当前这个 Python 帧。

要把「最后一个」和「前面的」区别对待,循环条件就得从 「expressions is not nil」改成「expressions.rest is not nil」: 循环处理到倒数第二个为止,剩下最后一个单独处理。 但这样一来,进循环前必须先排除 expressions is nil—— 否则 nil.rest 会报 AttributeError: 'tuple' object has no attribute 'rest' (别忘了 nil 就是空元组)。

关键一步

「最后一个元素要特殊处理」这个需求,会自然地把链表遍历的写法从 while s is not nil 变成 while s.rest is not nil, 并且强制你在前面加一个空表检查。这个模式在问题 12(and/or)里会原样再用两遍。

代码

def eval_all(expressions, env):
    """Evaluate each expression in the Scheme list EXPRESSIONS in
    Frame ENV (the current environment) and return the value of the last.

    >>> eval_all(read_line("(1)"), Frame(None))
    1
    >>> eval_all(read_line("(1 2)"), Frame(None))
    2
    """
    # BEGIN PROBLEM 6
    if expressions is nil:
        return None  # An empty body has the undefined value
    # Evaluate every expression but the last only for its side effects.
    while expressions.rest is not nil:
        scheme_eval(expressions.first, env)
        expressions = expressions.rest
    # The last expression is in a tail context, so its value is the result.
    return scheme_eval(expressions.first, env, True)
    # END PROBLEM 6
  • if expressions is nil: return None:必须放在最前面。 返回 None 而不是 nil——两者在这个解释器里含义不同, None 打印时什么都不显示(undefined),nil 打印成 ()。
  • while expressions.rest is not nil::只要还有下一个,当前这个就不是最后一个。
  • 循环体里 scheme_eval(...) 的返回值被丢弃。 这不是浪费——(begin (define x 3) x) 里第一个表达式的意义就是它的副作用。
  • expressions = expressions.rest:又一次是重新绑定局部名字。 ok 有个用例存了 s = Link(1, Link(2, Link(3, nil))), 调用后检查 s 还是 Link(1, Link(2, Link(3)))——这行写法保证了这一点。
  • return scheme_eval(expressions.first, env, True): 第三个参数 True 表示「这是尾调用」。 在你完成 EC2 之前,scheme_eval 的签名是 (expr, env, _=None),第三个参数被忽略, 写不写都一样;完成 EC2 后它才真正生效。 本仓库两者都实现了,所以这里带着 True。

验证

逐步推演

ok 的用例:

scm> (define x 0)
x
scm> (begin (define x (+ x 1))
....        (define x (+ x 10))
....        (define x (+ x 100))
....        (define x (+ x 1000)))
x
scm> x
1111

do_begin_form 把四个 define 组成的链表交给 eval_all:

轮次当前求值的表达式求值后 x 的值expressions.rest 是 nil 吗
进入前—0否,进循环
1(define x (+ x 1))1否
2(define x (+ x 10))11否
3(define x (+ x 100))111是 → 退出循环
循环后(define x (+ x 1000))1111返回它的值 'x'

REPL 打印 x(最后一个 define 返回的符号),随后查 x 得 1111。✓

注意每一轮的副作用都累积在同一个全局帧里, 所以第 2 轮读到的 x 是第 1 轮写进去的 1。 begin 不开新帧——它只是「顺序执行」,环境自始至终是同一个。

再验 (begin 30 '(+ 2 2)): 第一个表达式 30 自求值成 30,扔掉; 最后一个 '(+ 2 2) 即 (quote (+ 2 2)), 走 do_quote_form 返回未求值的链表,打印成 (+ 2 2)。✓ 干扰选项 4 是把 quote 当成没写;30 是把「取最后一个」记成了「取第一个」。

还有 (begin (print 3) (+ 2 3) (print 6)) 输出 3 换行 6: 三个表达式全被求值(所以 3 和 6 都打印了), 中间的 (+ 2 3) 算出 5 但没人要; 最后 (print 6) 打印 6 并返回 None,REPL 对 None 不显示任何东西。✓ 如果你写成「只求值最后一个」,3 就不会被打印。

常见误区

误区一:忘了 if expressions is nil 的判断,直接 while expressions.rest is not nil。 eval_all(nil, env) 会抛 AttributeError: 'tuple' object has no attribute 'rest'—— 这个报错信息里的 "tuple" 会让人一头雾水,直到想起 nil = Link.empty = ()。

误区二:空表时 return nil。 (begin) 会打印 (),而期望是什么都不打印。 更麻烦的是,一个空函数体的过程调用会返回 () 而不是 undefined。

误区三:写成 expressions.first = ... 之类去「消耗」链表。 这会永久破坏 LambdaProcedure.body——问题 12 有专门的测试 ((define (no-mutation) (and #t #t #t #t)) 调用后再打印这个过程, 函数体必须原封不动)就是为了抓这种错。

8. 问题 7:do_lambda_form

题目要什么

把 (lambda (x y) (+ x y)) 变成一个 LambdaProcedure 实例。 这个类要三样东西:

  • formals:形参名的 Scheme 列表,比如 Link('x', Link('y', nil));
  • body:函数体,是一个嵌套的 Scheme 列表—— 注意是「表达式们的列表」,不是「一个表达式」。 (lambda (x y) (+ x y)) 的 body 是 Link(Link('+', Link('x', Link('y', nil))), nil), 外面那层 Link 是「只有一个元素的列表」;
  • env:lambda 被定义时所处的环境。

函数体至少要有一个表达式((lambda (x)) 要报错),可以有多个 ((lambda (x) (+ x) (+ x x)) 合法)。起始代码里的 validate_form(expressions, 2) 保证了「形参表 + 至少一个体表达式」, validate_formals(formals) 保证形参是互不相同的符号。

怎么想到的

题目本身只要求你「构造并返回一个对象」,代码一行。 真正要想明白的是body 为什么要多包一层,以及env 为什么存的是定义处的环境。

先说 body。expressions 是 (formals body1 body2 ...), 所以 expressions.first 是形参表,expressions.rest 就是 「剩下所有体表达式组成的列表」。直接把 expressions.rest 交给 LambdaProcedure 就行——它天然已经是列表形式了。

如果你手贱写成 expressions.rest.first(只取第一个体表达式), 单表达式的 lambda 还能跑(因为问题 9 里 eval_all 会把它当列表遍历, 而一个 Link('+', Link('x', ...)) 恰好也是个链表,遍历它会求值 '+'、'x'…… 结果诡异地返回最后一个符号的值)。这种「错得不明显」的 bug 最难查。 ok 的 (define (foo x) 1 2 3 4 5) 用例专门验证多表达式函数体。

再说 env。这是词法作用域(lexical scoping)的全部秘密。 一个 lambda 表达式被求值的那一刻,它「记住」了自己所在的环境; 将来无论在哪里被调用,它的函数体都在这个记住的环境的子帧里执行。 这就是闭包(closure)。ok 有个用例把这件事讲得很清楚:

scm> (define x 5)
x
scm> (define outer (lambda (x) (lambda () (print x))))
outer
scm> (define inner (outer 2))
inner
scm> (inner)
2

内层 lambda 是在 outer 的调用帧(x = 2)里被求值的, 所以它的 env 指向那个帧。等到在全局环境里调用 (inner) 时, 打印的仍是 2 而不是 5。这个「2」完全由 do_lambda_form 里存的那个 env 决定。

关键一步

do_lambda_form 的 env 参数就是「定义处的环境」—— 因为 scheme_eval 是拿着当前环境去调用它的,而当前环境正是 lambda 出现的地方。 所以你什么都不用做,直接把 env 传给构造函数即可。 词法作用域看起来很玄,实现起来只是「把手上这个 env 存下来」。

代码

def do_lambda_form(expressions, env):
    """Evaluate a lambda form.

    >>> env = create_global_frame()
    >>> do_lambda_form(read_line("((x) (+ x 2))"), env) # evaluating (lambda (x) (+ x 2))
    LambdaProcedure(Link('x'), Link(Link('+', Link('x', Link(2)))), <Global Frame>)
    """
    validate_form(expressions, 2)
    formals = expressions.first
    validate_formals(formals)
    # BEGIN PROBLEM 7
    # The rest of the form is the (possibly multi-expression) body.
    return LambdaProcedure(formals, expressions.rest, env)
    # END PROBLEM 7
  • LambdaProcedure(formals, expressions.rest, env):三个参数一一对应。 expressions.rest 不加 .first,因为 body 本来就该是「表达式的列表」。
  • 没有任何 scheme_eval。lambda 表达式的求值不会执行函数体, 只是把它打包起来。函数体要等到被调用(问题 9)时才执行。
  • validate_form(expressions, 2) 在上面已经给好:至少 2 个元素。 所以 (lambda (x)) 会在这里就报 SchemeError—— 「一个 lambda 必须有函数体」这条规则是被 validate 挡住的,不用你写。

验证

逐步推演

ok 用例:do_lambda_form(read_line("(lambda (a b c) (+ a b c))").rest, env)。

  1. read_line("(lambda (a b c) (+ a b c))") 得到
    Link('lambda', Link(Link('a', Link('b', Link('c'))), Link(Link('+', Link('a', Link('b', Link('c')))))))。
  2. .rest 剥掉 'lambda',剩下 Link(形参表, Link(体表达式))。
  3. expressions.first = Link('a', Link('b', Link('c'))) → 这就是 formals。✓ (干扰项 Link('+', Link('a', ...)) 是把体表达式当成了形参表。)
  4. expressions.rest = Link(Link('+', Link('a', Link('b', Link('c'))))) → 这就是 body。✓ 外层 Link 表示「列表里有一个元素」,内层 Link 才是表达式 (+ a b c)。 干扰项 Link('+', Link('a', Link('b', Link('c')))) 少了一层, 它是「一个表达式」而不是「表达式的列表」。
  5. lambda_proc.env is env → True:存的是同一个对象引用,不是副本。✓

现在在 REPL 里输入一个 lambda 表达式,会看到它原样打印出来:

scm> (lambda (x y) (+ x y))
(lambda (x y) (+ x y))
scm> (lambda (x) (+ x) (+ x x))
(lambda (x) (+ x) (+ x x))
scm> (lambda () 2)
(lambda () 2)
scm> (lambda (x))
SchemeError

为什么能原样打印?看 LambdaProcedure.__str__: return str(Link('lambda', Link(self.formals, self.body)))—— 它把 formals 和 body 重新拼回一个链表再打印。 这也是检验你 body 存对没存对的最快方法: 如果 (lambda (x) (+ x) (+ x x)) 打印成 (lambda (x) (+ x)), 说明你只存了第一个体表达式;如果打印成 (lambda (x) + x), 说明你多剥了一层。第三个用例 (lambda () 2) 则确认零形参时 formals 是 nil 而不是别的什么,validate_formals(nil) 是通过的。

常见误区

误区一:LambdaProcedure(formals, expressions.rest.first, env)。 单表达式函数体的 lambda 打印出来少一层括号, 真正调用时 eval_all 会遍历表达式内部而不是遍历表达式列表, 结果往往是「(f 3) 返回 3」这种莫名其妙的行为。 好消息是 LambdaProcedure.__init__ 里有 validate_type(body, scheme_listp, 1, 'LambdaProcedure'), 如果体表达式不是合法列表(比如是个符号),构造时就会报错。

误区二:传 env.parent 或者全局帧。 闭包立刻失效,上面那个 (inner) 用例会打印 5 而不是 2, 或者干脆报 unknown identifier。

误区三:在这里就试图求值函数体。 (define (foo) (/ 1 0)) 会立刻抛除零错误, 而 ok 期望的是定义成功、打印 foo——只有真的调用 (foo) 才该炸。

9. 问题 8:Frame.make_child_frame

题目要什么

这就是「画一个新方框,箭头指向父帧,把形参和实参写进去」这件事的代码版。 方法接收两个 Scheme 列表:formals(符号)和 vals(值),返回一个新的 Frame:

  1. 实参个数和形参个数不一致 → 抛 SchemeError;
  2. 建一个以 self 为父帧的新 Frame;
  3. 按位置一一绑定:formals 的第一个绑到 vals 的第一个,依此类推;
  4. 返回新帧。

第 1 步在起始代码里已经写好了(用 len_link 比较长度)。 边界情况:formals 和 vals 都可能是 nil(零参数过程), 这时应该返回一个空的子帧而不是报错; vals 的元素本身可能是 Link(把列表当参数传), 不能对它们做任何解包。

怎么想到的

「两个等长链表,按位置配对」——这是链表遍历里最标准的一个模式: 两个指针同步前进。因为长度已经被检查过相等, 所以只需要判断其中一个是不是走到头了。

更值得琢磨的是「为什么是 self 当父帧」。 方法名叫 make_child_frame,调用方式是 某个帧.make_child_frame(...), 所以 self 是谁,取决于调用者传的是谁。 在问题 9 里你会写 procedure.env.make_child_frame(...), self 就是定义处的环境 → 词法作用域; 在问题 11 里你会写 env.make_child_frame(...), self 就是调用处的环境 → 动态作用域。 这个方法本身不决定作用域规则,它只是忠实地「以我为父」建一个帧。 把选择权留给调用者,正是它能同时服务两种作用域的原因。

关键一步

绑定用 child.define(...) 而不是 child.bindings[...] = ...。 不是因为后者不能用(它其实一样),而是因为复用你在问题 1 写好的抽象。 一旦哪天绑定需要额外逻辑(比如记日志),只改一处就够了。 这门课反复讲的「抽象屏障」在这里有一次微型实践。

代码

scheme_classes.py 里 Frame 类的这个方法,完整写出来是这样 (前两行的长度检查是脚手架给好的,缩进是类方法的四格):

    def make_child_frame(self, formals, vals):
        """Return a new local frame whose parent is SELF, in which the symbols
        in a Scheme list of formal parameters FORMALS are bound to the Scheme
        values in the Scheme list VALS. Both FORMALS and VALS are represented
        as Links. Raise an error if too many or too few vals are given.

        >>> env = Frame(None)
        >>> from scheme_reader import read_line
        >>> formals, expressions = read_line('(a b c)'), read_line('(1 2 3)')
        >>> env.make_child_frame(formals, expressions)
        <{a: 1, b: 2, c: 3} -> <Global Frame>>
        """
        if len_link(formals) != len_link(vals):
            raise SchemeError('Incorrect number of arguments to function call')
        # BEGIN PROBLEM 8
        child = Frame(self)
        # Walk formals and vals in lockstep, binding each name to its value.
        while formals is not nil:
            child.define(formals.first, vals.first)
            formals, vals = formals.rest, vals.rest
        return child
        # END PROBLEM 8
  • child = Frame(self):Frame.__init__ 只做两件事—— self.bindings = {}、self.parent = parent。 传 self 进去就把 parent 箭头连好了。
  • while formals is not nil::只判断 formals,因为长度已确认相等。 判断 vals 也行,但没必要写两个条件。
  • formals, vals = formals.rest, vals.rest: 用元组同时赋值,两个指针一起前进。 写成两行也可以,但要注意别写成 formals = formals.rest 然后 vals = formals.rest(打字错误,会错得很难查)。
  • 整个过程只读 formals 和 vals。 ok 有专门的用例在调用后检查这两个链表没被改动—— 因为 formals 是从 LambdaProcedure.formals 直接传过来的, 改了它,这个过程下次就废了。
  • return child:返回新帧,不返回 self。

验证

逐步推演

ok 用例:global_frame.make_child_frame(Link('a', Link('b', Link('c', nil))), Link(1, Link(2, Link(3, nil))))。

轮次formals.firstvals.firstchild.bindings
循环前——{},parent = 全局帧
1'a'1{a: 1}
2'b'2{a: 1, b: 2}
3'c'3{a: 1, b: 2, c: 3}
4formals is nil,退出,返回 child

环境图:

<Global Frame>   {+: #[+], -: #[-], ... , car: #[car], ...}   parent = None
      ↑
   child          {a: 1, b: 2, c: 3}                           parent = Global

随后 global_frame.lookup('a') → 全局帧里没有 a,且 parent is None → SchemeError。✓ 子帧的绑定对父帧不可见——箭头是单向的。 而 frame.lookup('a') → 在自己的 bindings 里找到 → 1。✓

再验三个边界:

  • make_child_frame(nil, nil):len_link(nil) == len_link(nil) == 0,不报错; 循环一次不进;返回一个空的子帧,frame.parent is global_frame 为 True。✓ 零参数过程 (lambda () 2) 就走这条路。
  • make_child_frame(Link('a', nil), Link(1, Link(2, Link(3, nil)))): 1 != 3 → SchemeError。✓ 这就是 ((lambda (x) x) 1 2 3) 报错的地方。
  • formals = ('a' 'b'),vals = ((1) (2))(值是两个链表): frame.lookup('a') → Link(1)。✓ 绑定时对值不做任何处理,是什么就存什么。 然后 frame2 = frame.make_child_frame(nil, nil), frame2.lookup('a') 仍能拿到 Link(1)—— 说明 parent 链正确地把查找转发上去了(这条依赖你问题 1 写的 lookup)。✓
常见误区

误区一:写 child = Frame(self.parent)。 新帧变成了 self 的兄弟而不是孩子, 所有在 self 里定义的名字(比如闭包捕获的变量)都查不到了。 症状是嵌套函数一调用就 unknown identifier。

误区二:把长度检查删掉或者改成 <。 ((lambda (x y) x) 1) 会在循环里对 nil 取 .first, 报 AttributeError 而不是 SchemeError, ok 的错误类型对不上。

误区三:想「顺便」在这里绑定内建过程或者拷贝父帧的绑定。 不要。子帧应该是空的,查不到的名字交给 parent 链去解决—— 这正是 lookup 递归的意义。如果你拷贝, 父帧后续的 define 就不会被子帧看到,环境语义整个坏掉。

10. 问题 9:scheme_apply 的 LambdaProcedure 分支

题目要什么

让用户定义的过程真的能被调用。两步:

  1. 调用合适的父帧的 make_child_frame,把形参绑到实参值上;
  2. 在这个新帧里用 eval_all 求值函数体,返回结果。

题面给了一条极重要的提示:新帧的父帧应该是「lambda 被定义时」的那个帧, 而不是 scheme_apply 收到的 env——后者是「调用发生时」的环境。

怎么想到的

代码只有两行,但这两行是整个项目最容易写错、也最能检验你是否真懂环境图的地方。

手边有两个候选的父帧:procedure.env(问题 7 存进去的定义处环境) 和 env(问题 3 里 scheme_eval 传下来的调用处环境)。选哪个?

用一个具体例子逼自己想清楚:

scm> (define n 5)
n
scm> (define add-n (lambda (x) (+ x n)))
add-n
scm> (add-n 6)
11

add-n 的函数体里有个自由变量 n。求值 (+ x n) 时, x 能在新帧里查到,n 查不到,要沿 parent 链往上找。 这里全局帧无论如何都在链上,所以两种选法结果一样,区分不出来。 换个例子:

scm> (define x 5)
x
scm> (define outer (lambda (x) (lambda () (print x))))
outer
scm> (define inner (outer 2))
inner
scm> (inner)
2

调用 (inner) 是在全局环境里发生的。 如果新帧的父帧取 env(= 全局帧),那查 x 会查到 5; 如果取 procedure.env(= outer 的调用帧,里面 x = 2),查到 2。 ok 期望的是 2,所以答案是 procedure.env。

关键一步

把这句话背下来:词法作用域 = 新帧的 parent 是过程被定义的环境。 「定义」这个词在代码里就落在 procedure.env 上。 如果你在 scheme_apply 里写了 env.make_child_frame, 你实现的就是动态作用域了——那是问题 11 里 mu 要干的事。

第二步「用 eval_all 求值函数体」几乎是白送的: procedure.body 已经是「表达式的列表」, eval_all 的语义正好是「依次求值,返回最后一个的值」—— 这也正是 Scheme 过程的返回值规则。两者是为彼此设计的。

代码

    elif isinstance(procedure, LambdaProcedure):
        # BEGIN PROBLEM 9
        # Lexical scoping: the new frame's parent is the frame where the
        # lambda was *defined*, not the one where it is called.
        call_frame = procedure.env.make_child_frame(procedure.formals, args)
        return eval_all(procedure.body, call_frame)
        # END PROBLEM 9
  • 这里只贴出本问要填的 LambdaProcedure 分支; scheme_apply 的完整函数见第 12 节。
  • procedure.env.make_child_frame(...):注意接收者是 procedure.env。 这一个属性访问,就是词法作用域的全部实现。
  • 参数是 procedure.formals 和 args。 args 是问题 3 里 map_link 算出来的值的列表, 不是表达式——所以这里不需要再求值。
  • eval_all(procedure.body, call_frame):第二个参数是新帧, 不是 env 也不是 procedure.env。 函数体必须在能看见形参的那个帧里执行。
  • 参数个数检查在 make_child_frame 里已经做了,这里不用重复。

验证

逐步推演

完整追踪 (define inner (outer 2)) 和 (inner)。 起始状态:全局帧 G 里 x: 5,outer: LambdaProcedure(formals=(x), body=((lambda () (print x))), env=G)。

  1. scheme_eval((outer 2), G) → 调用表达式 → procedure = G.lookup('outer'),args = Link(2, nil) → scheme_apply(outer过程, Link(2), G)。
  2. 走 LambdaProcedure 分支:procedure.env 是 G, 所以 call_frame = G.make_child_frame((x), (2)),记作 f1:
    G     {x: 5, outer: ..., ...}      parent = None
      ↑
    f1    {x: 2}                       parent = G
    
  3. eval_all(((lambda () (print x))), f1):只有一个表达式,直接求值它。 scheme_eval((lambda () (print x)), f1) → lambda 是特殊形式 → do_lambda_form((() ((print x))), f1) → 返回 LambdaProcedure(formals=nil, body=((print x)), env=f1)。 注意 env 存的是 f1,因为求值这个 lambda 时的当前环境就是 f1。
  4. 这个过程对象被 G.define('inner', ...) 绑到全局的 inner。 此刻 f1 虽然「调用结束了」,但因为 inner 的 env 指着它, 它不会消失——这就是闭包保活。
  5. (inner):scheme_eval((inner), G) → procedure = G.lookup('inner'),args = nil → scheme_apply(inner过程, nil, G)。 这里的 env 是 G,但我们不用它。
  6. call_frame = procedure.env.make_child_frame(nil, nil), 即 f1.make_child_frame(nil, nil),得到 f2:
    G     {x: 5, outer: ..., inner: ...}
      ↑
    f1    {x: 2}          parent = G
      ↑
    f2    {}              parent = f1
    
  7. eval_all(((print x)), f2) → scheme_eval((print x), f2) → 调用表达式 → 算子数 x → f2.lookup('x'): f2 里没有 → 上到 f1,{x: 2} 命中 → 2。 scheme_print(2) 打印 2,返回 None。✓

如果第 6 步写成 env.make_child_frame(...),f2 的 parent 就是 G, lookup('x') 会直接在 G 里命中 x: 5,打印 5。测试失败。

再看一个更能暴露问题的用例:

scm> (define outer (lambda (x y)
....   (define inner (lambda (z x)
....     (+ x (* y 2) (* z 3))))
....   (inner x 10)))
outer
scm> (outer 1 2)
17

调用 (outer 1 2):新帧 f1 里 x: 1, y: 2,parent 是 G。 在 f1 里定义 inner,它的 env 是 f1。 然后 (inner x 10):实参先在 f1 里求值,x → 1,10 → 10。 调用 inner,新帧 f2 里 z: 1, x: 10,parent 是 inner.env = f1。 求值 (+ x (* y 2) (* z 3)): x 在 f2 命中 → 10(f1 里的 x: 1 被遮蔽了); y 在 f2 没有 → f1 命中 → 2;z 在 f2 命中 → 1。 结果 10 + 4 + 3 = 17。✓

这个用例同时考了「形参遮蔽外层同名变量」和「自由变量沿链上溯」, 一个数字对不上就说明父帧接错了。

常见误区

误区一:用 env.make_child_frame。 简单测试全过(因为大多数调用恰好发生在定义处的环境或其后代里), 但 (inner) 那个闭包用例和 ((apply-twice double) 5) 那类组合子用例会挂。 这是本项目最经典的一个 bug。

误区二:eval_all(procedure.body, env)——帧建了却没用。 函数体查不到形参,报 unknown identifier: x。

误区三:把 args 再求值一遍 (map_link(lambda a: scheme_eval(a, env), args))。 (square 21) 这种数字参数看不出问题(数字自求值), 但 (f 'hello) 会试图查找符号 hello,报错; 更糟的是有副作用的实参会执行两次。

11. 问题 10:define 的过程简写形式

题目要什么

让 (define (f x) (* x 2)) 和 (define f (lambda (x) (* x 2))) 等价。 回到 do_define_form,补上第二个分支:signature 是一个 Link 的情况。

此时 expressions 的结构是 Link(签名, 体表达式们), 其中签名是 Link(过程名, 形参们)。要做四件事:找出名字、形参、函数体; 造一个 LambdaProcedure;绑定;返回名字。

必须支持多表达式函数体:

scm> (define (g y) (print y) (+ y 1))
g
scm> (g 3)
3
4

并且 ok 有一条用例检查不能修改传入的表达式: inp == read_line("(define (f x) x)") 在求值之后仍须为 True。

怎么想到的

题面给了两条路:一是拼出一个 (define _ (lambda ...)) 形式再递归调用自己, 二是直接实现。第二条更直白,但先想清楚数据长什么样。

把 (define (f x) (+ x 2)) 的 expressions(已剥掉 define)画出来:

expressions = Link( Link('f', Link('x', nil)),        <- signature
                    Link( Link('+', Link('x', Link(2, nil))), nil) )   <- body

对照一下 do_lambda_form 需要的输入((lambda (x) (+ x 2)) 剥掉 lambda 之后):

Link( Link('x', nil),                                  <- formals
      Link( Link('+', Link('x', Link(2, nil))), nil) )  <- body

两者只差第一个元素:一个是 (f x),一个是 (x)。 而 (x) 正是 signature.rest! body 部分(expressions.rest)一模一样,直接复用。

关键一步

Link(signature.rest, expressions.rest) 就是一个现成的、 可以直接喂给 do_lambda_form 的参数。 你不需要手动构造 LambdaProcedure,也不需要重新做 validate_formals—— do_lambda_form 里都有。把新问题化归成已解决的问题, 这是这门课从第一周就在讲的思路,在这里得到一次干净的应用。

为什么这样做比直接 LambdaProcedure(formals, expressions.rest, env) 好? 因为 do_lambda_form 里有 validate_form(expressions, 2) 和 validate_formals(formals)。ok 有一条用例 (define (f 1 2 3) 4) 期望 SchemeError—— 形参写成了数字,必须被拦下来。如果你绕过 do_lambda_form, 就得自己记得调 validate_formals。

另外注意 Link(...) 是新建一个结点, 原来的 signature 和 expressions 一个字节没动, 「不许修改输入」的要求自动满足。 如果你写成 signature.first = ... 之类的原地改法, 第二次求值同一段代码就会得到不同结果。

代码

这一问填完,scheme_forms.py 里的 do_define_form 才算完整。 把问题 4 的符号分支和这一问的过程分支放在一起,整个函数是这样:

def do_define_form(expressions, env):
    """Evaluate a define form.
    >>> env = create_global_frame()
    >>> do_define_form(read_line("(x 2)"), env) # evaluating (define x 2)
    'x'
    >>> scheme_eval("x", env)
    2
    >>> do_define_form(read_line("(x (+ 2 8))"), env) # evaluating (define x (+ 2 8))
    'x'
    >>> scheme_eval("x", env)
    10
    >>> # problem 10
    >>> env = create_global_frame()
    >>> do_define_form(read_line("((f x) (+ x 2))"), env) # evaluating (define (f x) (+ x 8))
    'f'
    >>> scheme_eval(read_line("(f 3)"), env)
    5
    """
    validate_form(expressions, 2) # Checks that expressions is a list of length at least 2
    signature = expressions.first
    if scheme_symbolp(signature):
        # assigning a name to a value e.g. (define x (+ 1 2))
        validate_form(expressions, 2, 2) # Checks that expressions is a list of length exactly 2
        # BEGIN PROBLEM 4
        # (define <symbol> <expr>): evaluate the expression, bind, return name.
        env.define(signature, scheme_eval(expressions.rest.first, env))
        return signature
        # END PROBLEM 4
    elif isinstance(signature, Link) and scheme_symbolp(signature.first):
        # defining a named procedure e.g. (define (f x y) (+ x y))
        # BEGIN PROBLEM 10
        # signature is (name . formals) and expressions.rest is the body, so
        # (define (f x) body) means the same as (define f (lambda (x) body)).
        name, formals = signature.first, signature.rest
        procedure = do_lambda_form(Link(formals, expressions.rest), env)
        env.define(name, procedure)
        return name
        # END PROBLEM 10
    else:
        bad_signature = signature.first if isinstance(signature, Link) else signature
        raise SchemeError('non-symbol: {0}'.format(bad_signature))

新增的是中间那个 elif 分支,逐行看:

  • name, formals = signature.first, signature.rest: 把签名拆成头和尾。(f x y) 的头是过程名,尾是形参表。 起了名字而不是到处写 signature.first,是为了让下面三行读起来像自然语言。
  • Link(formals, expressions.rest):现造一个链表结点, 把形参表接到函数体列表前面,拼成 do_lambda_form 期待的形状。
  • do_lambda_form(..., env):env 原样传下去, 所以新过程的 env 就是 define 发生的那个环境。词法作用域继续成立。
  • env.define(name, procedure):和问题 4 的分支一样,绑在当前帧。
  • return name:返回符号,REPL 打印过程名。
  • 外层的 elif 条件 isinstance(signature, Link) and scheme_symbolp(signature.first) 是给好的:签名得是列表,且列表第一个元素得是符号。 (define ((f) x) 1) 这种会落到 else 里报 non-symbol。

验证

逐步推演

求值 (define (g y) (print y) (+ y 1)),环境 G。

  1. scheme_eval 认出 define 是特殊形式, 调用 do_define_form(expressions, G),其中
    expressions = Link(Link('g', Link('y')), Link((print y), Link((+ y 1))))。
  2. signature = Link('g', Link('y'))。 scheme_symbolp 为假(它是 Link 不是字符串),走 elif: isinstance(signature, Link) 真,scheme_symbolp('g') 真。
  3. name = 'g',formals = Link('y', nil)。
  4. expressions.rest = Link((print y), Link((+ y 1)))—— 两个体表达式。
  5. Link(formals, expressions.rest) = Link(Link('y'), Link((print y), Link((+ y 1)))), 喂给 do_lambda_form: validate_form(..., 2) 通过(长度 3 ≥ 2), validate_formals(Link('y')) 通过, 返回 LambdaProcedure(Link('y'), Link((print y), Link((+ y 1))), G)。
  6. G.define('g', 该过程),返回 'g'。REPL 打印 g。✓

接着 (g 3):

  1. 算子 g → 上面那个过程;实参 Link(3, nil)。
  2. 问题 9 的分支:call_frame = G.make_child_frame(Link('y'), Link(3)) → {y: 3},parent = G。
  3. eval_all(Link((print y), Link((+ y 1))), call_frame): expressions.rest is not nil → 进循环 → 求值 (print y),打印 3,返回值丢弃; expressions 前进到 Link((+ y 1)),.rest is nil → 退出循环。
  4. 求值最后一个 (+ y 1) → 3 + 1 = 4,返回。REPL 打印 4。✓

输出正是先 3 后 4。 如果你在问题 7 里只存了第一个体表达式,这里就只会打印 3,然后回显 None。

另一组 ok 用例是直接检查回显形式:

scm> (define (f x y) (+ x y))
f
scm> f
(lambda (x y) (+ x y))

f 打印成 (lambda (x y) (+ x y)) 而不是 (f (x y) (+ x y)),正说明了「简写形式只是语法糖, 内部存的就是一个普通的 lambda 过程,过程本身不记得自己叫什么名字」。 干扰项 (define f (lambda (x y) (+ x y))) 则是把「求值前的源码」当成了「求值后的值」。

常见误区

误区一:Link(formals, expressions.rest.first)。 多剥了一层,body 变成单个表达式而不是表达式列表, LambdaProcedure.__init__ 里的 validate_type(body, scheme_listp, 1, ...) 有时能挡住(报 SchemeError), 有时挡不住(体表达式本身恰好是合法列表),后者更麻烦。

误区二:直接 LambdaProcedure(signature.rest, expressions.rest, env), 跳过 do_lambda_form。功能对,但漏掉了 validate_formals, (define (f 1 2 3) 4) 会「成功」定义出一个形参是数字的过程,ok 报错。

误区三:为了「复用问题 4 的分支」, 去构造 Link('lambda', Link(formals, expressions.rest)) 然后 Link(name, Link(那个lambda表达式, nil)) 再递归调 do_define_form。 这条路(题面提到的第一种解法)也是对的, 但要小心多包/少包一层,而且多绕一圈 scheme_eval,更容易错。

12. 问题 11:mu 与动态作用域

题目要什么

实现一种动态作用域(dynamic scoping)的过程。 mu 不是标准 Scheme 的东西,是这个项目为了讲清作用域概念发明的。

两处要改:

  • scheme_forms.py 里的 do_mu_form:返回一个 MuProcedure。 这个类只有 formals 和 body 两个属性,没有 env。
  • scheme_eval_apply.py 里 scheme_apply 的 MuProcedure 分支: 调用时,新帧的父帧是调用表达式被求值时所处的环境。

题面给的例子:

scm> (define f (mu () (* a b)))
f
scm> (define g (lambda () (define a 4) (define b 5) (f)))
g
scm> (g)
20

f 的函数体里 a 和 b 都是自由变量, 在全局环境里根本不存在。但因为 (f) 是在 g 的调用帧里被求值的, 而那个帧里恰好有 a = 4、b = 5,所以能算出 20。

怎么想到的

这一题的代码和问题 7、问题 9 几乎逐字相同,只差一个词。 难的不是写,是想清楚「为什么 MuProcedure 不需要存 env」。

顺着推:LambdaProcedure 存 env,是因为调用时要用它当父帧, 而那时候「定义处」早就不在调用栈上了,不存就找不回来。 MuProcedure 呢?它要的父帧是「调用处的环境」, 而调用处的环境正是 scheme_apply 的 env 参数—— 调用发生的那一刻,它就在你手上,不需要提前存。

关键一步

把 lambda 和 mu 的差别压缩成一句话:

lambda(词法作用域)mu(动态作用域)
过程对象存什么formals、body、envformals、body
调用时新帧的 parentprocedure.env(定义处)env(调用处)
自由变量在哪里找源码上包着它的那些作用域运行时调用链上的那些帧
光看源码能不能确定能不能,取决于谁调用了它

为什么现实中的语言几乎都选词法作用域?因为动态作用域下, 一个过程的行为取决于「谁调用了它」,你没法只看这个过程的源码就理解它。 把上面例子里的 g 改个形参名,f 就崩了——而 f 的代码一个字没动。 这在大型程序里是灾难。但动态作用域也不是全无用处: 异常处理的 handler 查找、某些语言的「特殊变量 / parameterize」用的就是这个思路。

代码

def do_mu_form(expressions, env):
    """Evaluate a mu form."""
    validate_form(expressions, 2)
    formals = expressions.first
    validate_formals(formals)
    # BEGIN PROBLEM 11
    # A mu procedure stores no environment: its caller supplies one.
    return MuProcedure(formals, expressions.rest)
    # END PROBLEM 11

补上这个分支之后,scheme_apply 的三条分支就都填满了。完整的函数是这样:

def scheme_apply(procedure, args, env):
    """Apply Scheme PROCEDURE to argument values ARGS (a Scheme list) in
    Frame ENV, the current environment."""
    validate_procedure(procedure)
    if not isinstance(env, Frame):
       assert False, "Not a Frame: {}".format(env)
    if isinstance(procedure, BuiltinProcedure):
        # BEGIN PROBLEM 2
        # Built-ins are plain Python functions, so unpack the Scheme list of
        # arguments into a Python list first.
        python_args = []
        while args is not nil:
            python_args.append(args.first)
            args = args.rest
        if procedure.need_env:
            # Procedures like eval need the calling environment as a last arg.
            python_args.append(env)
        # END PROBLEM 2
        try:
            # BEGIN PROBLEM 2
            return procedure.py_func(*python_args)
            # END PROBLEM 2
        except TypeError as err:
            raise SchemeError('incorrect number of arguments: {0}'.format(procedure))
    elif isinstance(procedure, LambdaProcedure):
        # BEGIN PROBLEM 9
        # Lexical scoping: the new frame's parent is the frame where the
        # lambda was *defined*, not the one where it is called.
        call_frame = procedure.env.make_child_frame(procedure.formals, args)
        return eval_all(procedure.body, call_frame)
        # END PROBLEM 9
    elif isinstance(procedure, MuProcedure):
        # BEGIN PROBLEM 11
        # Dynamic scoping: the new frame's parent is the calling environment.
        call_frame = env.make_child_frame(procedure.formals, args)
        return eval_all(procedure.body, call_frame)
        # END PROBLEM 11
    else:
        assert False, "Unexpected procedure: {}".format(procedure)
  • MuProcedure(formals, expressions.rest):两个参数,没有第三个。 env 参数在 do_mu_form 里被彻底忽略——这就是「不需要记住定义处」的字面体现。
  • env.make_child_frame(...):接收者是 env, 和问题 9 的 procedure.env.make_child_frame(...) 只差这一处。 把这两行并排放着看,动态作用域和词法作用域的区别就一目了然了。
  • eval_all(procedure.body, call_frame):这一行和问题 9 完全一样。 函数体的求值方式不受作用域规则影响,只是「在哪个帧里求值」变了。

验证

逐步推演

ok 用例:

scm> (define y 1)
scm> (define f (mu (x) (+ x y)))
scm> (define g (lambda (x y) (f (+ x x))))
scm> (g 3 7)
13
  1. G 里 y: 1;f: MuProcedure((x), ((+ x y)))(无 env); g: LambdaProcedure((x y), ((f (+ x x))), G)。
  2. (g 3 7) → scheme_apply(g过程, Link(3, Link(7)), G) → 词法作用域 → call_frame = G.make_child_frame((x y), (3 7)),记作 f1:
    G    {y: 1, f: ..., g: ...}
      ↑
    f1   {x: 3, y: 7}          parent = G
    
  3. eval_all(((f (+ x x))), f1) → scheme_eval((f (+ x x)), f1)。 算子 f → f1.lookup('f') → f1 没有 → G 有 → MuProcedure。 算子数 (+ x x) 在 f1 里求值 → 3 + 3 = 6。
  4. scheme_apply(mu过程, Link(6, nil), f1)—— 注意 env 是 f1,因为这个调用表达式正是在 f1 里被求值的。
  5. Mu 分支:call_frame = f1.make_child_frame((x), (6)),记作 f2:
    G    {y: 1, ...}
      ↑
    f1   {x: 3, y: 7}     parent = G
      ↑
    f2   {x: 6}           parent = f1     ← parent 是 f1,不是 G
    
  6. eval_all(((+ x y)), f2): x 在 f2 命中 → 6; y 在 f2 没有 → 上到 f1,y: 7 命中 → 7。 结果 6 + 7 = 13。✓

如果 f 是用 lambda 定义的,f2 的 parent 会是 G, y 就会查到全局的 1,结果是 7。 同一段函数体,同一个实参,两种作用域给出两个不同答案—— 这就是这道题存在的全部意义。

再看一个把两种作用域混起来的用例:

scm> (define (f x) (mu () (lambda (y) (+ x y))))
f
scm> (define (g x) (((f (+ x 1))) (+ x 2)))
g
scm> (g 3)
8
逐步推演
  1. (g 3) → g 的调用帧 fg:{x: 3},parent = G。
  2. 求值 (f (+ x 1)):实参 (+ x 1) 在 fg 里算 → 4。 f 是 lambda 过程,新帧 ff:{x: 4},parent = G。 在 ff 里求值 (mu () (lambda (y) (+ x y))) → 返回 MuProcedure(nil, ((lambda (y) (+ x y)))),不记住 ff。
  3. 外层的 (...) 调用这个 mu 过程,零参数。 这次调用是在 fg 里发生的(整个 (((f ...))) 表达式都在 g 的体里), 所以新帧 fm 的 parent 是 fg,而不是 ff。
  4. 在 fm 里求值 (lambda (y) (+ x y)), 得到一个 lambda 过程,它会记住 fm。
  5. 最后 (那个lambda (+ x 2)):实参在 fg 里算 → 3 + 2 = 5。 lambda 过程的新帧 fl:{y: 5},parent = fm。
  6. 求值 (+ x y):y 在 fl 命中 → 5; x 在 fl 没有 → fm 没有 → fg 命中 → 3。 结果 3 + 5 = 8。✓

如果第 3 步的 mu 变成 lambda,parent 会是 ff(x: 4),答案就是 9。 差一个字,差一个数。

常见误区

误区一:给 MuProcedure 传三个参数。 MuProcedure.__init__ 只接受两个,直接 TypeError: __init__() takes 3 positional arguments but 4 were given。 这个报错反而是好事——它在提醒你「mu 不该有 env」。

误区二:Mu 分支里复制粘贴问题 9 的代码忘了改, 写成 procedure.env.make_child_frame(...)。 MuProcedure 没有 env 属性,报 AttributeError: 'MuProcedure' object has no attribute 'env'。 同样是好事,比静默给出错误答案强。

误区三:以为 (mu ()) 应该合法。 不合法,validate_form(expressions, 2) 要求至少形参表 + 一个体表达式, 所以它抛 SchemeError——和 (lambda (x)) 报错的理由一样。

13. 问题 12:and 与 or 的短路

题目要什么

实现两个逻辑特殊形式。规则:

andor
零个子表达式返回 #t(Python True)返回 #f(Python False)
从左到右求值,遇到什么就停遇到假值就返回它遇到真值就返回它
全部没触发短路返回最后一个的值返回最后一个的值

三个必须记牢的点:

  1. Scheme 里只有 #f 是假值。0、nil、空字符串统统是真值。 所以 (or #f (- 1 1) 1) 返回 0——因为 0 是真的。 必须用 is_scheme_true / is_scheme_false(它们就是 val is not False / val is False), 不能用 Python 的 if value:。
  2. 返回的是那个值本身,不是布尔。(and 4 5 6) 是 6,不是 #t。
  3. 短路意味着后面的子表达式一次都不能被求值。 (and #t #f 42 (/ 1 0)) 必须返回 #f 而不是报除零错误。

怎么想到的

先问:为什么 and 必须是特殊形式,不能写成普通过程? 因为普通过程的算子数会被 scheme_eval 全部先求值一遍—— (/ 1 0) 就炸了。短路本身就是「不求值某些子表达式」, 而这正是特殊形式的定义。

然后是控制结构。「从左到右,遇到某种值就提前返回,否则返回最后一个的值」—— 这个模式和问题 6 的 eval_all 高度相似:都要把最后一个特殊对待。 所以直接照搬那个骨架:

if expressions is nil:
    return <单位元>
while expressions.rest is not nil:
    value = scheme_eval(expressions.first, env)
    if <应该短路>:
        return value
    expressions = expressions.rest
return scheme_eval(expressions.first, env, True)

为什么最后一个要拿出循环外?因为它的处理规则不同: 无论真假都直接返回它的值,不需要判断。 (and 3 2 #f) 返回 #f,(and 3 2 1) 返回 1—— 最后一位不管是什么都原样返回。 如果你写成统一处理,(and 3 2 1) 会因为「没触发短路」而不知道返回什么。

关键一步

(and) 返回 #t、(or) 返回 #f 不是随便定的, 是单位元(identity):(and x) 应该等于 x, 而把 #t 和任何东西 and 起来还是那个东西; #f 对 or 同理。这和 (+) 是 0、(*) 是 1 完全同一个道理。 记住「单位元」这个词,三处规则一次记牢。

代码

def do_and_form(expressions, env):
    # BEGIN PROBLEM 12
    if expressions is nil:
        return True  # (and) is #t
    # Return the first false value; otherwise the value of the last operand.
    while expressions.rest is not nil:
        value = scheme_eval(expressions.first, env)
        if is_scheme_false(value):
            return value  # short-circuit: later operands are never evaluated
        expressions = expressions.rest
    return scheme_eval(expressions.first, env, True)
    # END PROBLEM 12

def do_or_form(expressions, env):
    # BEGIN PROBLEM 12
    if expressions is nil:
        return False  # (or) is #f
    # Return the first true value; otherwise the value of the last operand.
    while expressions.rest is not nil:
        value = scheme_eval(expressions.first, env)
        if is_scheme_true(value):
            return value  # short-circuit: later operands are never evaluated
        expressions = expressions.rest
    return scheme_eval(expressions.first, env, True)
    # END PROBLEM 12

两个函数结构完全对称,只差两处:初始返回值(True vs False) 和短路条件(is_scheme_false vs is_scheme_true)。逐行看:

  • return True / return False:返回 Python 的布尔。 题面明确说了「用 Python 的 True 表示 #t」, repl_str 会把它们打印成 #t / #f。
  • value = scheme_eval(...) 存进变量再判断: 这样短路时能直接 return value(返回那个值), 而不是重新求值一遍。有副作用的表达式重新求值会出大问题。
  • is_scheme_false(value):等价于 value is False。 写成 if not value: 的话,(and 0 1 2 3) 会在 0 处短路返回 0, 而正确答案是 3。这是本题最高频的错误。
  • expressions = expressions.rest:只重绑名字,不改链表。 ok 有两条用例专门测这个: (define (no-mutation) (and #t #t #t #t)) 调用之后再打印 no-mutation, 函数体必须还是四个 #t。
  • 最后一行的第三个参数 True:最后一个子表达式处在尾位置。 (or #f (or #f (or #f f))) 这种深嵌套在 EC2 完成后会变成常数空间。

验证

逐步推演

ok 用例,起始 x 为 0:

scm> (and (define x (+ x 1))
....      (define x (+ x 10))
....      #f
....      (define x (+ x 100))
....      (define x (+ x 1000)))
#f
scm> x
11
轮次求值的表达式valueis_scheme_falsex
1(define x (+ x 1))'x'(符号)否,继续1
2(define x (+ x 10))'x'否,继续11
3#fFalse是 → 返回 False11
4、5从未被求值11

返回 False,打印 #f;x 停在 11。✓ 注意第 1、2 轮的 value 是符号 'x'——一个非空字符串, is_scheme_false('x') 为假,所以继续。这个用例用副作用精确地测出了 「短路之后到底还执行了几个表达式」。

把 #f 去掉呢?五个 define 全部执行,x 变成 1111, 返回最后一个的值 'x',打印 x。ok 的另一条用例正是这样。✓

再逐个核对几条 or 的:

表达式输出为什么
(or 5 2 1)5第一个就是真值,短路返回 5,后面不求值
(or 0 1 2)00 在 Scheme 里是真值,第一个就短路
(or #f (- 1 1) 1)0#f 不短路;(- 1 1) 得 0,是真值 → 返回 0
(or (< 2 3) (> 2 3) 2 'a)#t(< 2 3) 求值成 True,直接返回,打印 #t
(or (false-fn) 'yay)yay(false-fn) 返回 #f,不短路;最后一个原样返回符号
(and 4 5 (+ 3 3))64、5 都是真值,返回最后一个的值 6
(eq? (and #t) #t)#t只有一个子表达式,循环不进,直接返回它的值
常见误区

误区一:用 Python 真值判断。 if not value: return value 会让 (and 0 1 2 3) 返回 0(应为 3)、 (or nil 1) 返回 1(应为 (),因为 nil 是真值)。 这个 bug 只在特定输入下暴露,很容易漏测。

误区二:短路时返回 False / True 而不是 value。 (and 1 #f) 恰好也是 #f,看不出区别; 但 or 的 (or 5 2 1) 会返回 #t 而不是 5,立刻挂。

误区三:忘了 expressions is nil 的前置判断, (and) 直接 AttributeError。 或者把它写成 if expressions is nil: return None, (and) 什么都不打印,而期望是 #t。

误区四:把循环写成 while expressions is not nil 然后在循环外用一个变量记住最后一个值。这样最后一个子表达式也会被短路判断, (and 3 2 #f) 恰好对,但 (or (< 2 3) 2) 之类会返回错值—— 更重要的是最后一个表达式失去了尾位置,EC2 的优化就白做了。

14. 问题 13:cond

题目要什么

cond 是多分支判断。每个子句(clause)形如 (判断式 结果表达式...), 从上往下找第一个判断式为真的子句,返回它的结果。else 子句的判断式恒真。

特殊情况是这题的全部难点:

  • 判断式为真但没有结果表达式 → 返回判断式的值。 (cond ((= 4 4))) 返回 #t;(cond (12)) 返回 12。
  • 结果有多个表达式 → 全部求值,返回最后一个的值(用 eval_all)。
  • 没有任何真判断式、也没有 else → 返回 None(undefined,REPL 什么都不打印)。
  • 只有 else 且它没有结果表达式 → 返回 #t。

起始代码已经写好了外层循环、else 的识别、以及「else 必须是最后一个子句」的检查。 你只需要填「判断式为真之后怎么算结果」这一小块。

怎么想到的

先看起始代码里 else 是怎么处理的:

        if clause.first == 'else':
            test = True
            ...
        else:
            test = scheme_eval(clause.first, env)
        if is_scheme_true(test):
            # 你要填的地方

它把 else 直接当成 test = True。这一手很关键,因为它把 「else 子句」和「普通真子句」统一成了同一条路径, 你只写一份处理逻辑就够了。

那么「只有 else 且没有结果表达式时返回 #t」这条规则怎么来的? 它不是一条独立规则——它就是「判断式为真但没结果表达式 → 返回判断式的值」 套用在 test = True 上的结果。规则合并之后,你只需要写:

if clause.rest is nil:
    return test
return eval_all(clause.rest, env)
关键一步

看出「四条特殊规则其实只有两条」是这题的分水岭。 (cond (else)) 返回 #t,和 (cond ((= 4 4))) 返回 #t, 是同一行代码干的:前者 test 被起始代码设成了 True, 后者 test 是 (= 4 4) 求值的结果 True。 而 (cond (12)) 返回 12 也是同一行——只不过 test 是 12。

「没有真判断式返回 None」这条也不用你写: 外层 while 循环跑完所有子句都没 return,Python 函数自然返回 None。 不要画蛇添足加一句 return nil。

最后,为什么结果部分要用 eval_all 而不是 scheme_eval(clause.rest.first, env)? 因为 (cond ((= 4 4) 'here (+ 40 2))) 要返回 42—— 两个结果表达式都要求值,取最后一个。这正是 eval_all 的语义。 又一次,eval_all 被复用了。

代码

def do_cond_form(expressions, env):
    """Evaluate a cond form.

    >>> do_cond_form(read_line("((#f (print 2)) (#t 3))"), create_global_frame())
    3
    """
    while expressions is not nil:
        clause = expressions.first
        validate_form(clause, 1)
        if clause.first == 'else':
            test = True
            if expressions.rest != nil:
                raise SchemeError('else must be last')
        else:
            test = scheme_eval(clause.first, env)
        if is_scheme_true(test):
            # BEGIN PROBLEM 13
            if clause.rest is nil:
                # A clause with no result expressions evaluates to its test.
                return test
            return eval_all(clause.rest, env)
            # END PROBLEM 13
        expressions = expressions.rest
  • if clause.rest is nil: return test: 「子句只有判断式,没有结果」。返回 test——已经求过值的那个结果, 不是 clause.first(那是未求值的表达式)。 写成 return clause.first 的话,(cond ((= 4 4))) 会返回链表 (= 4 4)。
  • return eval_all(clause.rest, env): clause.rest 是「结果表达式的列表」,正好是 eval_all 要的形状。 env 是当前环境——cond 不开新帧, 所以 (cond (#t (define x 1))) 定义的 x 会留在外面。 ok 有一条用例正是这么验的。
  • 两个 return 都在 if is_scheme_true(test) 里面: 一旦某个子句命中就立刻返回,后面的子句连判断式都不再求值。 这是 cond 的短路语义。
  • expressions = expressions.rest 在最外层,只有没命中时才执行。

验证

逐步推演

ok 用例:

scm> (cond ((and #f 2) 'whats)
....       ((and 1 #t 2))
....       ((> 2 3) 'going)
....       (else 'on))
2
子句test 的求值is_scheme_true动作
((and #f 2) 'whats)(and #f 2) → #f 短路 → False否前进到下一子句
((and 1 #t 2))(and 1 #t 2) → 全真 → 最后一个 → 2是clause.rest is nil → 返回 test = 2
((> 2 3) 'going)从未被求值
(else 'on)从未被求值

打印 2。✓ 这一条同时依赖问题 12 的 and 实现正确 ——如果 and 返回的是 #t 而不是 2,这里就会打印 #t。

再看一组用副作用测「判断式求值了几次」的用例:

scm> (define x 0)
x
scm> (cond ((define x (+ x 1)) 'a)
....       ((define x (+ x 100)) 'b))
a
scm> x
1

第一个子句的判断式 (define x (+ x 1)) 只被求值一次: x 变成 1,do_define_form 返回符号 'x'。 is_scheme_true('x') 为真(只有 False 才是假)→ clause.rest 是 Link((quote a), nil),不是 nil → eval_all 求值它,返回符号 'a',打印 a。 第二个子句碰都没碰,所以 x 是 1 而不是 101。✓ 如果你在判断真假时又求值了一次判断式,x 会变成 2。

逐步推演

多结果表达式 + 环境不变的用例:

scm> (define x 0)
scm> (define y 0)
scm> (define z 0)
scm> (cond (#t
....        (define x (+ x 1))
....        (define y (+ y 1))
....        (define z (+ z 1)))
....       (else ...))
z
scm> (list x y z)
(1 1 1)
  1. 第一个子句判断式是 #t,test = True,命中。
  2. clause.rest 是三个 define 组成的列表,不是 nil。
  3. eval_all(那三个define, env): 前两个在循环里求值(副作用:x 变 1、y 变 1),返回值丢弃; 最后一个 (define z (+ z 1)) 在循环外求值,z 变 1,返回符号 'z'。
  4. do_cond_form 返回 'z',REPL 打印 z。✓
  5. env 全程是全局帧,所以三个 define 都写在全局帧上, (list x y z) 得 (1 1 1)。✓ else 子句里的三个减法从未执行。

最后确认「什么都没命中」:(cond (#f 1))—— 唯一的子句判断式为假,循环推进到 nil,退出,函数隐式返回 None。 REPL 对 None 不打印任何东西,ok 的期望输出正是一片空白。✓ 而 (cond ((= 1 1) nil)) 返回的是 nil,打印成 ()—— 两者显示不同,这就是 None 和 nil 必须区分的原因。

常见误区

误区一:return scheme_eval(clause.rest.first, env)。 单结果的子句全对,但 (cond ((= 4 4) 'here (+ 40 2))) 会返回 here 而不是 42。

误区二:无结果表达式时返回 True 而不是 test。 (cond (else)) 和 (cond ((= 4 4))) 都恰好对, 但 (cond (12)) 会返回 #t 而不是 12, (cond ((and 1 #t 2))) 会返回 #t 而不是 2。

误区三:在没有真判断式时显式 return nil 或 return False。 (cond (#f 1)) 会打印 () 或 #f,而期望是什么都不打印。

误区四:用 if clause.rest == nil。 nil 是空元组,Link.__eq__ 对非 Link 返回 False, 反过来元组和 Link 比较也是 False,所以 == nil 在 clause.rest 是 Link 时确实是 False、 是 nil 时是 True——碰巧能用。但 is nil 语义明确、更快, 而且整个代码库都用 is,别搞特殊。

15. 问题 14:enumerate(用 Scheme 写)

题目要什么

从这里开始换语言:不再是用 Python 写解释器,而是用你刚写好的解释器去跑 Scheme 代码。 如果前面某道题有 bug,这些题会以最莫名其妙的方式失败——所以先确认 1–13 全绿。

(enumerate s) 接收一个列表,返回一个「两元素列表」的列表, 第一个元素是下标(从 0 开始),第二个是原值。

scm> (enumerate '(3 4 5 6))
((0 3) (1 4) (2 5) (3 6))
scm> (enumerate '(c s 6 1 a))
((0 c) (1 s) (2 6) (3 1) (4 a))
scm> (enumerate '())
()

边界情况:空列表返回空列表。

怎么想到的

直觉写法是「递归处理 (cdr s),然后给结果的每个下标加一」:

; 能用,但是 O(n²)
(define (enumerate s)
  (if (null? s)
      nil
      (cons (list 0 (car s))
            (增量加一 (enumerate (cdr s))))))

这需要再写一个「把所有下标加一」的辅助过程,还要遍历一遍结果, 总复杂度变成平方级。更重要的是,写起来啰嗦。

换个思路:下标是从头往尾递增的,那就让它跟着递归一起往下走。 问题变成「从下标 index 开始给 s 编号」—— 这个问题是递归的:从 index 给 s 编号 = (index (car s)) 接上「从 index+1 给 (cdr s) 编号」。

但 enumerate 的签名只有一个参数,没地方放 index。 解决办法是定义一个带累加参数的内部辅助过程, 外层过程只负责用初始值 0 启动它。

关键一步

「函数签名不够用」是递归题里最常见的卡点。 标准解法:写一个参数更多的辅助函数,外层用初始值调用它。 这个模式在 Python 里也一样(def helper(s, index)), 只不过 Scheme 里可以用 define 把辅助过程写在函数体内部, 这样它不会污染全局环境——而且它能闭包捕获外层的名字(虽然这题用不上)。 注意:能这么写,靠的正是你在问题 4 里让 define 绑定到「当前环境」而不是全局帧。

代码

(define (enumerate s)
  ; BEGIN PROBLEM 14
  ;; Walk s while carrying the index of the current element.
  (define (enumerate-from s index)
    (if (null? s)
        nil
        (cons (list index (car s))
              (enumerate-from (cdr s) (+ index 1)))))
  (enumerate-from s 0)
  ; END PROBLEM 14
  )
  • (define (enumerate-from s index) ...): 内部辅助过程,参数里多了个 index。 它的形参 s 遮蔽了外层的 s,这没问题——每层递归用自己那一份。
  • (if (null? s) nil ...):base case。 空列表返回 nil,打印成 ()。 这同时处理了「输入本来就是空表」和「递归到底」两种情况。
  • (list index (car s)):造一个两元素列表。 (list a b) 等价于 (cons a (cons b nil)), 用 list 更直观。不能用 (cons index (car s))—— 那会造出点对 (0 . 3) 而不是列表 (0 3)。
  • (cons ... (enumerate-from (cdr s) (+ index 1))): 把当前的两元素列表接到「剩余部分的编号结果」前面。 下标加一随着列表缩短同步发生。
  • (enumerate-from s 0):函数体的最后一个表达式, 用初始下标 0 启动递归。它的值就是 enumerate 的返回值—— 这依赖你在问题 6 写对的 eval_all(多表达式函数体取最后一个的值)。

验证

逐步推演

展开 (enumerate '(3 4 5)):

(enumerate-from (3 4 5) 0)
  s 非空 → (cons (list 0 3) (enumerate-from (4 5) 1))
                              |
                              +→ s 非空 → (cons (list 1 4) (enumerate-from (5) 2))
                                                             |
                                                             +→ s 非空 → (cons (list 2 5) (enumerate-from () 3))
                                                                                            |
                                                                                            +→ (null? ()) 为真 → nil

逐层回代:

层回代表达式结果
4(最深)—()
3(cons (2 5) ())((2 5))
2(cons (1 4) ((2 5)))((1 4) (2 5))
1(cons (0 3) ((1 4) (2 5)))((0 3) (1 4) (2 5))

打印 ((0 3) (1 4) (2 5))。✓ 和 doctest 里的 (enumerate '(3 4 5 6)) → ((0 3) (1 4) (2 5) (3 6)) 完全同构。

再验空表:(enumerate '()) → (enumerate-from () 0) → (null? ()) 为真 → 返回 nil → 打印 ()。✓

顺便说一句:这段 Scheme 代码能跑起来,用到了你写的 do_define_form(两个分支都用了)、do_lambda_form、 do_if_form(本来就给好的)、eval_all、 make_child_frame、scheme_apply 的两个分支…… 基本上是对前 13 题的一次集成测试。

常见误区

误区一:用 cons 代替 list 造对子。 (cons 0 3) 打印成 (0 . 3)(点对), 和期望的 (0 3) 差一个 nil 结尾。 Scheme 的「两元素列表」是 (cons 0 (cons 3 nil))。

误区二:把 (enumerate-from s 0) 写在 define 前面。 Scheme 的函数体是顺序求值的,此时 enumerate-from 还没定义, 报 SchemeError: unknown identifier: enumerate-from。

误区三:递归调用写成 (enumerate (cdr s))(漏了 -from)。 下标会一直从 0 重新开始,得到 ((0 3) (0 4) (0 5))。

16. 问题 15:get 与 set(字典列表)

题目要什么

「字典列表(dictionary list)」是一个形如 ((key value) (key value) ...) 的 Scheme 列表, 每个 key 唯一。要实现两个过程:

  • (get dict key):返回 key 配对的值;找不到返回 #f。
  • (set dict key value):返回一个新的字典列表。 如果 key 已存在,长度不变、把对应的值换成 value; 如果不存在,在末尾追加一个 (key value)。
scm> (define dict-list '((a 1) (b 2) (c 3)))
dict-list
scm> (get dict-list 'b)
2
scm> (get dict-list 'e)
#f
scm> (set dict-list 'b 4)
((a 1) (b 4) (c 3))
scm> (set dict-list 'x 0)
((a 1) (b 2) (c 3) (x 0))

最要紧的一条隐性要求:set 不能改动原字典。 ok 有一条用例先 (set (set schememon 'tree 3) 'fibo 4), 然后检查 schememon 还是原样。

怎么想到的

先看 questions.scm 顶部给好的几个工具:

(define (caar x) (car (car x)))
(define (cadr x) (car (cdr x)))
(define (cadar x) (car (cdr (car x))))
(define (cdar x) (cdr (car x)))
(define (cddr x) (cdr (cdr x)))

这些名字看着像乱码,其实有规律:从右往左读 a(car)和 d(cdr)。 cadar = car ∘ cdr ∘ car——先取 car,再取 cdr,再取 car。

套到字典上:dict 是 ((a 1) (b 2) ...), (car dict) 是第一个键值对 (a 1):

表达式值(以 dict = ((a 1) (b 2)) 为例)含义
(car dict)(a 1)第一个键值对
(caar dict)a第一对的 key
(cadar dict)1第一对的 value
(cdr dict)((b 2))剩下的字典

get 于是就是一个三分支递归:空表返回 #f; 第一对的 key 匹配就返回它的 value;否则在 (cdr dict) 里继续找。

set 稍难,难在「返回新列表,不改旧的」。 Scheme 的列表是不可变的(这个项目里没有 set-car!), 所以「修改」只能靠重新构造: 把不需要改的部分原样 cons 回去,把要改的那个位置换成新的对子。

关键一步

set 的三个分支恰好对应三种情况,而且顺序不能乱:

  1. 走到底了还没找到 → 说明 key 不存在 → 返回 ((key value)),一个只含新对子的单元素列表。 回代时它会被接在原有元素后面,效果就是「追加到末尾」。
  2. 当前这对的 key 匹配 → 用新对子替换它, 后面的 (cdr dict) 原样保留(不用继续递归,因为 key 唯一)。
  3. 不匹配 → 保留当前这对,递归处理剩下的。

第 1 条是这题最巧的地方:「找不到就追加到末尾」和「递归的 base case」是同一件事, 因为递归恰好是从头走到尾的。不需要单独写一个「拼接到末尾」的过程。

代码

;; Return the value for a key in a dictionary list
(define (get dict key)
  ; BEGIN PROBLEM 15
  ;; Scan the pairs; caar is a pair's key and cadar is its value.
  (cond ((null? dict) #f)
        ((equal? (caar dict) key) (cadar dict))
        (else (get (cdr dict) key)))
  ; END PROBLEM 15
  )

;; Return a dictionary list with a (key value) pair
(define (set dict key val)
  ; BEGIN PROBLEM 15
  ;; Rebuild the list; replace the matching pair, or append at the end.
  (cond ((null? dict) (list (list key val)))
        ((equal? (caar dict) key) (cons (list key val) (cdr dict)))
        (else (cons (car dict) (set (cdr dict) key val))))
  ; END PROBLEM 15
  )
  • (cond ...) 而不是嵌套的 if:三个分支写成三行更清楚。 能这么写,靠的是你在问题 13 刚实现好的 do_cond_form—— Phase 4 的题目会反过来用到 Phase 1–3 的每一项成果。
  • (equal? (caar dict) key):用 equal? 而不是 eq?。 key 是符号时两者都行,但 equal? 对列表也做结构比较,更保险。
  • get 的 (cadar dict):取第一对的第二个元素。 不是 (cdar dict)——那会返回 (1),一个单元素列表,多了层括号。
  • set 第一分支 (list (list key val)):两层 list。 内层造出对子 (key val),外层把它包成「只有一个元素的字典列表」。 只写一层的话,回代时 cons 会把 key 和 val 摊平进外层列表。
  • set 第二分支 (cons (list key val) (cdr dict)): 换掉当前对子,后面原封不动。 这里没有递归——key 唯一,找到就结束。这也保证了「长度不变」。
  • set 第三分支 (cons (car dict) (set (cdr dict) key val)): 把当前对子(同一个对象,不是副本)接到递归结果前面。 共享结构是安全的,因为谁都不会去改它。

验证

逐步推演

ok 用例:schememon = ((squirrel 0) (fibo 1) (oski 2)), 先算 (set schememon 'tree 3):

(set ((squirrel 0) (fibo 1) (oski 2)) tree 3)
  dict 非空;(caar dict) = squirrel ≠ tree → 第三分支
  = (cons (squirrel 0) (set ((fibo 1) (oski 2)) tree 3))
                          (caar) = fibo ≠ tree → 第三分支
                        = (cons (fibo 1) (set ((oski 2)) tree 3))
                                           (caar) = oski ≠ tree → 第三分支
                                         = (cons (oski 2) (set () tree 3))
                                                            (null? dict) → 第一分支
                                                          = ((tree 3))

回代:

层结果
最深((tree 3))
3((oski 2) (tree 3))
2((fibo 1) (oski 2) (tree 3))
1((squirrel 0) (fibo 1) (oski 2) (tree 3))

新对子确实在末尾。✓ 再对这个结果 (set ... 'fibo 4):

  squirrel ≠ fibo → (cons (squirrel 0) (set ((fibo 1) (oski 2) (tree 3)) fibo 4))
                                        (caar) = fibo = fibo → 第二分支
                                      = (cons (fibo 4) ((oski 2) (tree 3)))
                                      = ((fibo 4) (oski 2) (tree 3))

回代得 ((squirrel 0) (fibo 4) (oski 2) (tree 3))。✓ 长度还是 4,只有 fibo 的值变了,位置也没变。

而原来的 schememon 呢?整个过程中只做了 cons(造新结点) 和 car/cdr(读),一次写操作都没有, 所以 schememon 仍然是 ((squirrel 0) (fibo 1) (oski 2))。✓

schememon ──→ [(squirrel 0)] ──→ [(fibo 1)] ──→ [(oski 2)] ──→ nil
                    ↑                                ↑
                    │(结构共享:新链表复用了同一个对子对象)
                    │                                │
with-tree ──→ [•] ──→ [(fibo 4)] ─────────────────→ [•] ──→ [(tree 3)] ──→ nil

上图说明:with-tree 的第一个和第三个元素与 schememon 是同一个对象, 只是被新的链表结点串了起来。这就是函数式数据结构的「持久化(persistence)」—— 旧版本不受影响,新版本又不用完整复制。

get 的验证简单些:(get with-tree 'tree) 依次比较 squirrel、fibo、oski,都不匹配,最后 (caar ((tree 3))) = tree 匹配, 返回 (cadar ((tree 3))) = 3。✓ (get schememon 'tree) 一路走到 (null? dict),返回 #f。✓

常见误区

误区一:set 找不到 key 时写成 (cons (list key val) nil) 之外的形式,比如 (list key val)。 结果会变成 ((squirrel 0) (fibo 1) (oski 2) tree 3)—— 最后多了两个游离元素而不是一个对子。

误区二:get 用 (cdar dict) 取值。 返回 (1) 而不是 1,多一层括号。 (cadar dict) 才对(先 car 拿到对子,再 cdr 跳过 key,再 car 取值)。

误区三:set 匹配成功后还继续递归 (cons (list key val) (set (cdr dict) key val))。 因为 key 唯一,后面找不到,会在末尾再追加一个 (key val), 列表长度多 1。ok 的用例检查了长度。

17. 问题 16:solution-code(把代码当数据改写)

题目要什么

Schémémon 这个卡牌游戏里,玩家可以做一道填空题来加倍伤害。 题目是一段带 _____(五个下划线)的 Scheme 代码,玩家给出填空内容。 (solution-code problem solution) 要把 problem 里每一个 _____ 替换成 solution,返回替换后的表达式。

scm> (define add-problem '(define (add x y) _____))
add-problem
scm> (define add-sol '(+ x y))
add-sol
scm> (solution-code add-problem add-sol)
(define (add x y) (+ x y))

关键点:_____ 可能藏在任意深度的嵌套里 ((* n _____) 就在两层括号内),而且可能出现多次。 ok 的用例还包括「一个 _____ 都没有」的情况(原样返回)。

怎么想到的

这是一道树递归题,但伪装成了列表题。

先想清楚 problem 的数据形状。 '(define (factorial n) (if (= n 0) 1 (* n _____))) 是一棵树: 每个括号是一个内部结点,每个符号/数字是一个叶子。 在 Scheme 里,这棵树用嵌套的 pair 表示。

要「把树里所有的 _____ 换掉」,就得访问每一个结点。 访问一棵用 pair 表示的树,有一个非常经典的写法:同时对 car 和 cdr 递归。

(cons (处理 (car t)) (处理 (cdr t)))

为什么这样能遍历整棵树?因为 (a b c) 就是 (cons a (cons b (cons c nil))): 对 car 递归 = 往子表达式内部钻; 对 cdr 递归 = 往同层的下一个元素走。 两个方向都走遍,就覆盖了整棵树。

关键一步

不要试图用「遍历列表元素」的思路((cdr s) 一路走到底, 对每个元素判断是不是列表、是的话再递归)。那样写也行,但分支更多、更容易漏。 car + cdr 双递归把「深度」和「广度」用同一行代码处理了, 因为在 pair 的世界里,这两者本来就没有区别——都只是「下一个 pair」而已。

接着定 base case。递归到什么时候停?当参数不再是 pair 的时候。 非 pair 的东西有三类:

  1. nil(列表末尾)→ 返回 nil;
  2. 符号 _____ → 返回 solution;
  3. 其它原子(数字、别的符号)→ 原样返回。

顺序上有个陷阱:pair? 的判断必须在 equal? 之前吗? 其实这里怎么排都行,因为 _____ 是符号、不是 pair,两个条件互斥。 但 null? 必须在 pair? 之前或与之无关—— nil 不是 pair,所以 (pair? nil) 为假, 它会落到后面的分支,被 else 原样返回 nil。 换句话说第一个分支 (null? problem) 其实可以省掉, 但写出来意图更明确。

代码

(define (solution-code problem solution)
  ; BEGIN PROBLEM 16
  ;; Rebuild the expression tree, swapping every _____ for the solution.
  (cond ((null? problem) nil)
        ((pair? problem) (cons (solution-code (car problem) solution)
                               (solution-code (cdr problem) solution)))
        ((equal? problem '_____) solution)
        (else problem))
  ; END PROBLEM 16
  )
  • ((null? problem) nil):空表原样返回。 它同时是「列表递归到底」的出口。
  • ((pair? problem) (cons ... ...)):递归结点。 两个递归调用都传同一个 solution—— solution 在整个替换过程中是不变的。 cons 把两边的结果重新组装成 pair, 所以返回的是一棵结构相同、叶子被替换过的新树。
  • ((equal? problem '_____) solution):命中空格。 注意 '_____ 前面的引号——不加引号的话, Scheme 会试图查找名为 _____ 的变量,报 unknown identifier。 _____ 在 CS 61A 的 Scheme 里是合法符号 (题面说符号可以含 !$%&*/:<=>?@^_~-+. 这些字符,下划线在列)。
  • (else problem):其它原子原样返回。 数字、define、if、n 这些符号都走这一支。
  • 整个过程不修改 problem,全靠 cons 造新结构。 这一点和问题 15 的 set 是同一个思路。

验证

逐步推演

取一个小一点的例子把递归完整展开:
(solution-code '(+ 1 _____) '(f x))。

'(+ 1 _____) 就是 (cons '+ (cons 1 (cons '_____ nil)))。

solution-code( (+ 1 _____) )
  pair? → cons( solution-code(+) , solution-code((1 _____)) )
           │                        │
           │ 非 pair、非 _____       │ pair? → cons( solution-code(1), solution-code((_____)) )
           │ → else → +             │                │                 │
           │                        │                │ → else → 1      │ pair? → cons( solution-code(_____), solution-code(()) )
           │                        │                │                 │              │                       │
           │                        │                │                 │              │ equal? _____ → (f x)  │ null? → ()

从最深处回代:

层组装结果
4(cons (f x) ())((f x))
3(cons 1 ((f x)))(1 (f x))
2(cons + (1 (f x)))(+ 1 (f x))

结果 (+ 1 (f x))。✓ 注意 (f x) 作为一个整体被塞进了 _____ 的位置, 没有被摊平——因为 solution 是被整个返回的, 它自己不参与递归。

再核对官方 doctest:(solution-code '(define (factorial n) (if (= n 0) 1 (* n _____))) '(factorial (- n 1)))。 递归会一路钻到 (* n _____) 里的 _____, 把它换成 (factorial (- n 1)),其余结点原样重建,得到
(define (factorial n) (if (= n 0) 1 (* n (factorial (- n 1)))))。✓

还有一条用例是没有空格的: (solution-code '(define (sum s) (if (null? s) 0 (+ (car s) (sum (cdr s))))) '(sum (cdr s))) → 输入里一个 _____ 都没有, 所有叶子都走 else 原样返回,重建出来的树和原树结构完全一样, 打印结果与输入一致。✓ 这条用例在测「不该改的别乱改」。

常见误区

误区一:写 (equal? problem _____),忘了引号。 报 SchemeError: unknown identifier: _____。

误区二:只对 car 递归,cdr 那边直接 (cdr problem) 原样接上。这样只会替换掉每个列表的第一个元素内部的空格, (* n _____) 里的空格(它在 cdr 方向)永远换不到。

误区三:用 (list ...) 代替 (cons ...) 重建。 (list a b) 造的是两元素列表,而 (cons a b) 是把 a 接到列表 b 前面。 用 list 会让每层递归都多包一层括号,结果面目全非。

误区四:担心 solution 里如果也有 _____ 会怎样。 不会有问题——solution 从不参与递归,被命中时整个返回, 所以它内部的任何东西都原样保留。

18. 问题 EC 1:let 特殊形式

题目要什么

(let ((名字 表达式) ...) 体...) 在一个新帧里把名字绑到值, 然后在这个帧里求值函数体。do_let_form 已经给好了, 你要写的是 make_let_frame(bindings, env): 返回一个 env 的子帧,其中每个绑定的符号被绑到对应表达式的值。

最关键的语义细节,题面用一个例子点了出来:

scm> (define x 5)
x
scm> (define y 'bye)
y
scm> (let ((x 42)
....       (y (* x 10)))  ; 这个 x 指的是全局的 5,不是 42
....   (list x y))
(42 50)
scm> (list x y)
(5 bye)

y 被绑成 50 而不是 420——说明所有绑定表达式都在外层环境里求值, 新帧建好之前谁也看不见谁。这是 let(而非 let*)的定义。

题面还点名了两个工具:validate_form(expr, min, max) 检查列表长度, validate_formals(formals) 检查是一串互不相同的符号。

怎么想到的

先把要做的事拆开:

  1. 遍历 bindings,每个元素形如 (名字 表达式);
  2. 对每个元素,校验它长度恰好是 2;
  3. 把名字收集成一个 Scheme 列表 names, 把表达式在 env 里求出的值收集成 Scheme 列表 vals;
  4. 校验 names 是合法形参表(不重名、都是符号);
  5. return env.make_child_frame(names, vals)——最后一行是给好的。

第 3 步有个技术难点:怎么按原顺序构造一个 Scheme 列表? Link(x, 已有列表) 只能往前面加,所以如果你边遍历边 cons, 得到的顺序是反的。题面的提示是「从右往左建」,也就是先把所有元素攒起来, 再倒着 cons 回去。

本仓库的实现走的正是这条路:先用两个 Python list 顺序收集, 再用 zip(reversed(...), reversed(...)) 从后往前串成链表。 这样做还有一个好处:求值顺序和链表构造顺序解耦了。 表达式必须从左到右求值(ok 有个用例 (let ((a (define z (+ z 1)))) z) 靠副作用测这个), 而链表必须从右到左构造。分成两步就不会打架。

关键一步

校验的位置很讲究。validate_form(binding, 2, 2) 要放在循环里、求值之前—— 这样 (let ((a 1 1)) a) 才会报错而不是悄悄忽略多出来的部分。 validate_formals(names) 则必须放在循环外、链表建好之后—— 它要检查的是「整组名字有没有重复」,得等所有名字都收集齐了才能判断。 (let ((a 2) (a 3)) (+ a a)) 就靠它报错。

还有一个隐藏要求:不能改 bindings。 ok 有一条用例在调用后检查 bindings 还是原样。 因为 let 表达式可能在循环或递归里被反复求值,改坏了就不可逆。

代码

def make_let_frame(bindings, env):
    """Create a child frame of Frame ENV that contains the definitions given in
    BINDINGS. ..."""
    if not scheme_listp(bindings):
        raise SchemeError('bad bindings list in let form')
    names = vals = nil
    # BEGIN OPTIONAL PROBLEM 1
    # Evaluate every binding expression left to right in the *outer* env,
    # collecting names and values so we can build the Scheme lists afterwards.
    collected_names, collected_vals = [], []
    while bindings is not nil:
        binding = bindings.first
        validate_form(binding, 2, 2)  # each binding is exactly (symbol expr)
        collected_names.append(binding.first)
        collected_vals.append(scheme_eval(binding.rest.first, env))
        bindings = bindings.rest
    # Build the linked lists from right to left so the order is preserved.
    for name, val in zip(reversed(collected_names), reversed(collected_vals)):
        names = Link(name, names)
        vals = Link(val, vals)
    validate_formals(names)  # names must be distinct symbols
    # END OPTIONAL PROBLEM 1
    return env.make_child_frame(names, vals)
  • collected_names, collected_vals = [], []: 用 Python 列表当中转站。append 是 O(1),顺序天然正确。
  • validate_form(binding, 2, 2):长度必须恰好 2。 (let ((y 2 3)) ...) 和 (let ((y)) ...) 都在这里被拦下。 放在 binding.first / binding.rest.first 之前, 这样非法结构不会导致 AttributeError。
  • scheme_eval(binding.rest.first, env): 在外层环境求值。这一个参数决定了 let 和 let* 的差别。 ok 的用例 (let ((a 1) (b a)) b) 期望 SchemeError—— 求值 a 时新帧还不存在,外层也没有 a,所以报 unknown identifier。✓
  • 循环里求值是顺序发生的,所以副作用按从左到右的顺序累积。
  • zip(reversed(...), reversed(...)):两个列表同步倒序遍历。 每轮 names = Link(name, names) 把当前名字接到已有链表前面, 倒着走一遍,最终顺序就正过来了。
  • validate_formals(names):放在链表建好之后、 make_child_frame 之前。它会检查每个元素是符号且互不重复。 (let ((a 1) (2 2)) a) 里的 2 不是符号,在这里被拦下。✓
  • names = vals = nil 这行是起始代码给的, 两个名字都指向 nil(同一个空元组)。因为后面是重新绑定不是修改,这没问题。

验证

逐步推演

ok 用例:全局 x = 3、y = 4,求值

(let ((x (+ y 2))
      (y (+ x 2)))
  (cons x (cons y nil)))
轮次binding在 G 里求值collected_namescollected_vals
1(x (+ y 2))(+ 4 2) = 6['x'][6]
2(y (+ x 2))(+ 3 2) = 5['x','y'][6, 5]

注意第 2 轮里的 x 用的是全局的 3,不是第 1 轮算出的 6—— 因为求值环境始终是 env(= G),新帧还没建。

然后倒序构造:

  1. 先处理 ('y', 5):names = Link('y', nil),vals = Link(5, nil)。
  2. 再处理 ('x', 6):names = Link('x', Link('y', nil)),vals = Link(6, Link(5, nil))。

顺序正确。validate_formals 通过(x、y 是不同的符号)。 G.make_child_frame((x y), (6 5)) 得到新帧 {x: 6, y: 5},parent = G。

do_let_form 接着 eval_all(((cons x (cons y nil))), 新帧): x → 6,y → 5 → (6 5)。✓ 退出 let 后全局的 x、y 仍是 3 和 4——新帧被丢弃了。

再验一组嵌套 let:

scm> (let ((x 5))
....    (let ((x 2)
....          (y x))
....        (+ y (* x 2))))
9

外层 let 建帧 f1:{x: 5},parent = G。 内层 let 的两个绑定表达式都在 f1 里求值: 2 → 2;x → f1 里的 5。 所以内层帧 f2 是 {x: 2, y: 5},parent = f1。 求值 (+ y (* x 2)) → 5 + 4 = 9。✓ 如果绑定表达式在新帧里求值(即 let* 语义), y 会绑到 2,结果是 6。

另一条用例 (let ((a (define z (+ z 1)))) z)(起始 z = 0)返回 1: 绑定表达式 (define z (+ z 1)) 在外层求值, 所以 z 是在全局帧上被改成了 1; 函数体里的 z 在新帧查不到,上溯到全局,得 1。✓ 这条同时确认了「绑定表达式在外层环境求值」和「新帧只装绑定的名字」。

常见误区

误区一:先建帧再逐个求值绑定表达式(写成 let*)。 (let ((a 1) (b a)) b) 会返回 1,而期望是 SchemeError。

误区二:边遍历边 names = Link(binding.first, names)。 顺序反了,(let ((x 1) (y 2)) x) 会把 x 绑成 2。 因为 make_child_frame 是按位置配对的,名字和值只要有一边反了就全错。 (如果两边都反了,反而碰巧对——这种「有时对有时错」的 bug 最难查。)

误区三:把 validate_formals 放在循环里对单个名字调用。 它接收的是整个形参列表,传单个符号会报类型错误; 而且重名检查本来就需要看到全部名字。

19. 问题 EC 2:尾调用优化与 trampolining

题目要什么

让下面这段代码不炸:

scm> (define (sum n total)
....   (if (zero? n)
....       total
....       (sum (- n 1) (+ n total))))
sum
scm> (sum 1001 0)
501501

为什么会炸?因为 (sum 1001 0) 要嵌套 1001 层 Scheme 调用, 而每一层 Scheme 调用在 Python 里对应好几层 scheme_eval / scheme_apply 帧。 Python 默认递归深度上限是 1000 左右,早就爆了:RecursionError。

可 sum 的递归调用是尾调用(tail call)—— 它是函数体里最后要做的事,调用返回之后当前这一层没有任何遗留工作。 既然如此,当前这一层的帧完全没必要留着。 标准 Scheme 要求实现必须做尾调用优化,让这种递归在常数空间里跑。

要做两件事:

  1. 补完 optimize_tail_calls,它返回一个「被优化过的 scheme_eval」;
  2. 找出解释器里所有处于尾位置的 scheme_eval 调用,给它们传 tail=True。

怎么想到的

Python 不做尾调用优化,所以不能指望语言帮忙。 思路是把「递归」改写成「循环」——但不是手改每个函数, 而是用一个通用机制:thunk + trampoline。

1 Thunk(延迟计算):与其立刻算出一个值,不如返回一个「还没算的计算」。 在这个项目里,Unevaluated(expr, env) 就是一个 thunk: 它说「这个表达式还没求值,环境是这个,你自己去算」。
2 Trampoline(蹦床):拿到一个 thunk 就把它展开一步, 如果结果还是 thunk 就继续展开,直到拿到真正的值。 这个「反复展开」是个 while 循环,不增加 Python 栈深度。
3 关键在于「谁弹、谁跳」:处在尾位置的求值不真的求值, 只返回一个 Unevaluated;这样调用它的那层 Python 帧可以立刻返回、被回收。 最外层的那个 while 循环负责接住返回的 thunk,继续下一步。

为什么这样能省空间?想象 (sum 3 0): 不优化时是「eval(sum 3 0) 里面套着 eval(sum 2 3) 里面套着 eval(sum 1 5)…」, 帧一层层叠。优化后变成「while 循环第 1 轮算出一个 thunk『求值 (sum 2 3)』, 第 2 轮算出 thunk『求值 (sum 1 5)』…」, 每一轮开始前,上一轮的所有帧都已经返回了。深度恒定。

关键一步

optimized_eval 有两个角色,靠 tail 参数区分:

  • tail=True 且表达式非原子 → 当「跳板」: 不求值,直接返回 Unevaluated(expr, env),把控制权交回去。
  • tail=False(默认)→ 当「蹦床」: 进入 while 循环,反复调用未优化版的 scheme_eval, 把返回的 thunk 一个个展开,直到拿到真值。

为什么原子表达式(符号、自求值的数)要排除在外? 因为求值它们不会引发任何递归——直接算掉更省事, 包装成 thunk 反而多一轮循环。

代码

def optimize_tail_calls(unoptimized_scheme_eval):
    """Return a properly tail recursive version of an eval function."""
    def optimized_eval(expr, env, tail=False):
        """Evaluate Scheme expression EXPR in Frame ENV. If TAIL,
        return an Unevaluated containing an expression for further evaluation.
        """
        if tail and not scheme_symbolp(expr) and not self_evaluating(expr):
            return Unevaluated(expr, env)

        result = Unevaluated(expr, env)
        # BEGIN OPTIONAL PROBLEM 2
        # Trampoline: keep unwrapping thunks until we reach an actual value.
        # Each iteration performs exactly one step of evaluation, so a chain
        # of tail calls uses constant space in Python.
        while isinstance(result, Unevaluated):
            result = unoptimized_scheme_eval(result.expr, result.env)
        return result
        # END OPTIONAL PROBLEM 2
    return optimized_eval

# ...文件末尾:
scheme_eval = optimize_tail_calls(scheme_eval)
  • optimize_tail_calls 是个高阶函数:接收原始的 scheme_eval, 返回一个同名同签名的替代品。最后一行的 scheme_eval = optimize_tail_calls(scheme_eval) 把全局名字换掉, 于是所有对 scheme_eval 的调用都自动走优化版—— 这正是装饰器模式,不用改任何调用点。
  • unoptimized_scheme_eval 被闭包捕获。 注意:原版 scheme_eval 内部的递归调用走的是全局名字 scheme_eval, 也就是优化版。所以两者是互相调用的: 优化版的 while 循环调原版走一步,原版在尾位置又调优化版拿到 thunk 返回。
  • if tail and not scheme_symbolp(expr) and not self_evaluating(expr): 这一行是给好的。三个条件:调用方声明这是尾位置、表达式不是符号、不是自求值。 满足就打包成 thunk 返回。
  • result = Unevaluated(expr, env): 用一个 thunk 当循环的初值,这样 while 的条件第一次就成立, 循环体统一处理第一步和后续所有步。比写 do-while 或者重复一次代码干净。
  • while isinstance(result, Unevaluated): result = unoptimized_scheme_eval(...): 蹦床本体。每轮只做「一步」求值。 一步之后如果又碰到尾调用,原版会(通过优化版)返回新的 Unevaluated,循环继续。
  • return result:跳出循环时 result 一定是真值。

哪些调用是尾调用

光写 optimize_tail_calls 没用——如果没人传 tail=True, 第一个分支永远不触发,优化等于没做。 所以还要在解释器各处标出尾位置。本仓库标了这些(前面各题的代码里都能看到那个 True):

位置代码为什么是尾位置
eval_all 最后一个表达式scheme_eval(expressions.first, env, True)它的值直接就是 eval_all 的返回值,算完没有后续工作
do_if_form 的两个分支scheme_eval(expressions.rest.first, env, True)选中的那个分支的值就是 if 的值
do_and_form 最后一个操作数scheme_eval(expressions.first, env, True)没短路时它的值就是整个 and 的值
do_or_form 最后一个操作数同上同上

反过来,哪些不是尾位置?do_if_form 里求值判断式的那个 scheme_eval(expressions.first, env) 就不是—— 算完还要拿它去判断真假,当前帧还有活干。 and/or 循环里的那些也不是,它们的值要参与短路判断。 scheme_eval 里求值算子和算子数的那两处更不是—— 它们的结果还要交给 scheme_apply。

还有一处配套设施 complete_apply(已给好):

def complete_apply(procedure, args, env):
    """Apply procedure to args in env; ensure the result is not an Unevaluated."""
    validate_procedure(procedure)
    val = scheme_apply(procedure, args, env)
    if isinstance(val, Unevaluated):
        return scheme_eval(val.expr, val.env)
    else:
        return val

它给「从 Python 侧调用 Scheme 过程」的场合(比如内建的 map、apply)兜底: scheme_apply 现在可能返回一个 thunk,这些调用方不懂怎么处理, complete_apply 替它们展开。

验证

逐步推演

追踪 (sum 3 0),函数体是 (if (zero? n) total (sum (- n 1) (+ n total)))。

  1. REPL 调 scheme_eval((sum 3 0), G),即优化版,tail=False。 进入蹦床,result = Unevaluated((sum 3 0), G)。
  2. 第 1 轮:调原版 scheme_eval((sum 3 0), G) → 调用表达式 → scheme_apply(sum过程, (3 0), G) → 新帧 f1 {n: 3, total: 0} → eval_all(体, f1) → 体只有一个表达式,走 scheme_eval(if表达式, f1, True)—— 但优化版看到 tail=True 且 if 表达式非原子, 立刻返回 Unevaluated(if表达式, f1)。 这个 thunk 一路原样返回:eval_all 返回它,scheme_apply 返回它, 原版 scheme_eval 返回它。f1 相关的所有 Python 帧此刻全部弹出。 result = Unevaluated(if表达式, f1)。
  3. 第 2 轮:调原版求值 (if (zero? n) total (sum (- n 1) (+ n total))) 于 f1。 if 是特殊形式 → do_if_form → 判断式 (zero? n) 用 tail=False 求值 → 得 False(n 是 3)→ 走 else 分支:scheme_eval((sum (- n 1) (+ n total)), f1, True) → 又返回一个 thunk:Unevaluated((sum (- n 1) (+ n total)), f1)。
  4. 第 3 轮:求值 (sum 2 3)(实参在 f1 里算出 2 和 3)→ 新帧 f2 {n: 2, total: 3} → 返回 Unevaluated(if表达式, f2)。 此时 f1 已经没人引用了,可以被垃圾回收。
  5. 如此往复:第 4 轮返回 Unevaluated((sum 1 5), f2), 第 6 轮返回 Unevaluated((sum 0 6), f3), 第 7 轮建出 f4 {n: 0, total: 6} 并返回 Unevaluated(if表达式, f4)。
  6. 第 8 轮:do_if_form 求值 (zero? n) 得 True, 走 then 分支:scheme_eval(total, f4, True)—— total 是符号,第一个 if 的条件不成立(scheme_symbolp 为真), 所以不打包成 thunk,直接进蹦床求值,返回 6。
  7. 外层 while 看到 result 不再是 Unevaluated,退出,返回 6。✓

整个过程中,Python 的栈深度始终维持在几层(最外层蹦床 + 一次 eval/apply/eval_all), 和 n 无关。所以 (sum 1001 0) 能算出 501501 而不是 RecursionError。

ok 的 EC2 测试用八种写法把同一个 sum 反复考了一遍, 每一种在考一个不同的尾位置:

用例写法在考哪个尾位置
(if (zero? n) total (sum ...))do_if_form 的 else 分支
(if #f 42 (sum ...)) 嵌在 if 里嵌套的 if 分支也要能层层传递 thunk
(cond ((zero? n) total) ((zero? 0) (sum ...)) ...)do_cond_form 里的 eval_all
互相调用的 sum / add相互递归(mutual recursion)也能被优化
(let ((n-1 (- n 1))) (sum n-1 ...))do_let_form 里的 eval_all(依赖 EC1)
(or (and (zero? n) total) (add ...))do_and_form / do_or_form 的最后一个操作数
函数体里先 define 再 or多表达式函数体:eval_all 只把最后一个标成尾位置
(begin (define ...) (or ...))do_begin_form → eval_all,同上

这些用例合在一起说明:尾位置不是「函数体最后一行」, 而是「求值它之后当前调用就没事可做了」的任何位置—— 它可以穿过 if、cond、and、or、let、begin 一层层往里传。

常见误区

误区一:只写 optimize_tail_calls,忘了取消注释 scheme_eval = optimize_tail_calls(scheme_eval)。 所有测试仍然用未优化版,EC2 全挂,而且看不出为什么。

误区二:把 tail=True 加到 do_if_form 求值判断式的那一处。 判断式会变成一个 Unevaluated 对象, 而 is_scheme_true(Unevaluated实例) 恒为真(它不是 False), 于是所有 if 都走 then 分支。这个 bug 会让几乎所有测试同时失败。

误区三:在 scheme_eval 求值算子或算子数时传 True。 procedure 会变成一个 Unevaluated, validate_procedure 立刻报 SchemeError。

误区四:把 while 循环写成 if。 只展开一层 thunk,深一点的尾递归会把 Unevaluated 对象直接返回给 REPL, 打印出 <scheme_eval_apply.Unevaluated object at 0x...> 这种东西。

20. 整份作业回顾

把 18 道题的清单再念一遍没有意义。真正值得带走的是下面几件事。

eval 和 apply 是一对互相咬合的齿轮

整个解释器的骨架只有两个函数:

scheme_eval(expr, env)
   │  expr 是符号        → env.lookup(expr)                    ← 问题 1
   │  expr 自求值        → expr
   │  expr 是特殊形式    → SPECIAL_FORMS[first](rest, env)     ← 问题 4/5/7/10/11/12/13/EC1
   └  expr 是调用表达式  → procedure = scheme_eval(算子)        ← 问题 3
                          args      = map_link(scheme_eval, 算子数)
                          scheme_apply(procedure, args, env)
                                        │
scheme_apply(procedure, args, env) ←────┘
   │  BuiltinProcedure   → py_func(*args)                      ← 问题 2
   │  LambdaProcedure    → procedure.env.make_child_frame(...)  ← 问题 8/9
   │                       eval_all(body, 新帧) ──┐
   └  MuProcedure        → env.make_child_frame() ← 问题 11
                           eval_all(body, 新帧) ──┤
                                                  │
                    eval_all → scheme_eval  ──────┘             ← 问题 6

写完之后你会发现,「一门编程语言」这个听上去很庞大的东西, 核心不过是这两个函数加上一个环境类。 剩下的一切——内建过程库、读取器、REPL——都是围绕它们的配套设施。

四条贯穿全项目的主线

题目核心手法迁移到哪里
1、8帧 = 字典 + parent 指针;查找沿链上溯任何语言的作用域实现;理解 Python 的 LEGB 规则
2、3两个世界的数据表示互相转换(Scheme 链表 ↔ Python 列表)写任何「胶水层」「FFI」「序列化」时的思路
4、5、12、13特殊形式 = 「不该被求值的子表达式」宏、惰性求值、DSL 设计;理解为什么 and 短路而函数调用不能
6、9、13、EC1复用同一个 eval_all 处理「一串表达式取最后一个」识别重复模式并抽成一个函数——最基本也最容易忽略的工程能力
7、9、11作用域规则 = 新帧 parent 的选择闭包、装饰器、回调里的 this/self 绑定
10、16把新问题化归成已解决的问题(重组数据再复用旧函数)递归设计的通用套路
14、15、16不可变数据 + 重新构造;car/cdr 双递归遍历树函数式数据结构、持久化数据结构、编译器里的 AST 变换
EC2thunk + trampoline:把递归改写成循环协程、生成器、异步编程的底层原理

特殊形式速查

形式哪些子表达式被求值返回什么开新帧吗
(define x e)只有 e符号 x否,绑在当前帧
(define (f a) b...)一个都不求值符号 f否
(quote e)一个都不求值e 本身否
(lambda (a) b...)一个都不求值LambdaProcedure(记住定义处环境)否
(mu (a) b...)一个都不求值MuProcedure(不记环境)否
(begin e...)全部,按顺序最后一个的值;空则 undefined否
(if p a b)p 和被选中的那一个被选中分支的值否
(and e...)从左到右,直到遇假第一个假值,或最后一个的值;空则 #t否
(or e...)从左到右,直到遇真第一个真值,或最后一个的值;空则 #f否
(cond (p r...)...)判断式直到命中;命中子句的全部结果最后一个结果的值;无结果则返回判断式的值;全不命中则 undefined否
(let ((n e)...) b...)全部 e(在外层环境)+ 全部 b(在新帧)最后一个 b 的值是
核心结论

这个项目回答了整门课的第一个母题——「一个表达式被求值时到底发生了什么」—— 用的方式是让你把答案写成可执行的代码。 以后再看 Python 的环境图、闭包、作用域、短路求值, 你脑子里浮现的不再是黑板上的方框,而是 Frame.lookup 里那句 return self.parent.lookup(symbol)、 scheme_apply 里那句 procedure.env.make_child_frame(...)。 这些概念从「要背的规则」变成了「我实现过的机制」。

动手练习

下面几题的答案都能从上面的代码和推演里推出来,先自己想再看。

1. 如果把问题 9 的 procedure.env.make_child_frame 改成 env.make_child_frame, (define (compose f g) (lambda (x) (f (g x)))) 之后 ((compose (lambda (x) (* 2 x)) (lambda (x) (+ x 1))) 5) 会发生什么?

会报 SchemeError: unknown identifier: f(或 g)。 内层 (lambda (x) (f (g x))) 的自由变量 f 和 g 只存在于 compose 的调用帧里。改成动态作用域后, 调用这个 lambda 时新帧的 parent 是调用处(全局帧), 而全局帧里没有 f、g——它们随着 compose 返回就「看不见」了。

2. (cond ((and 1 #t 2))) 和 (cond ((and 1 #t 2) 'x)) 分别返回什么?为什么?

前者返回 2,后者返回 x。 (and 1 #t 2) 的值是最后一个操作数 2(都是真值,不短路)。 前者子句没有结果表达式(clause.rest is nil), 按规则返回判断式的值,也就是 2; 后者有结果表达式,走 eval_all,返回 'x 求值后的符号 x。

3. (if #f 1) 和 (begin nil) 在 REPL 里的输出有什么不同?为什么?

(if #f 1) 什么都不打印,(begin nil) 打印 ()。

do_if_form 在判断式为假且没有第三个操作数时,Python 函数走到底、隐式返回 None; REPL 对 None(Scheme 的 undefined)选择不显示任何东西。 而 (begin nil) 里 eval_all 求值 nil, nil 是自求值的,返回空表本身,repl_str(nil) 给出字符串 "()"。

要点:None(undefined)和 nil(空表)是两个不同的值。 问题 6(空函数体返回 None)和问题 13(无真判断式返回 None)都依赖这个区分—— 这也是为什么那两处不能写 return nil。 顺带一提,(begin) 本身是非法的:do_begin_form 里的 validate_form(expressions, 1) 会报 Error: too few operands in form。

4. 用 mu 能写出一个「返回调用者局部变量 x」的过程吗?用 lambda 能吗?

mu 能:(define h (mu () x)), 然后在任何有 x 的帧里调用 (h) 就会拿到那个 x。 ok 的用例 (define (high fn x) (fn)) 加 (high h 2) → 2 正是如此。 lambda 不能:它的 env 是定义处的环境, 如果定义处没有 x,无论谁调用都会报 unknown identifier。

5. EC2 完成后,(define (loop) (loop)) 然后 (loop) 会怎样?

会无限循环但不崩溃——蹦床的 while 循环一直转,每轮返回一个新 thunk, Python 栈深度恒定,内存也不涨(旧帧被回收)。 没做 EC2 之前,同样的代码会在一千层左右抛 RecursionError。 这个对比最直观地说明了尾调用优化干了什么。

验证状态

本仓库在 proj/scheme/ 下运行 python3 ok --local:

---------------------------------------------------------------------
Scheme tests in tests.scm

116 passed; 0 failed
-- OK! --

---------------------------------------------------------------------
Test summary
    235 test cases passed! No cases failed.

235 个测试用例全部通过,无失败项,覆盖问题 1–16 以及两道 Extra Challenge。 上文引用的每一段 Python 和 Scheme 代码都逐字取自 proj/scheme/scheme_classes.py、scheme_eval_apply.py、 scheme_forms.py、questions.scm 这四个文件。