视频封面

视频元信息

  • 课程:MIT 6.001 Structure and Interpretation of Computer Programs(1986 传奇原版)
  • 讲次:第 14 讲 · 元循环求值器(下)
  • 主题:用元循环求值器探索语言设计——可变参数、动态绑定 vs 词法绑定、按名调用(延迟参数)

内容摘要(TL;DR)

本讲以元循环求值器为实验平台,演示如何给语言添加新特性:可变参数过程(用点对语法 x . y 表示)、动态绑定(自由变量在调用者环境求值,导致模块化危机)、以及按名调用/延迟参数(delay/thunk/force 机制)。核心结论是:词法绑定 + 一等过程是保证模块化的正确设计。


一、元循环求值器:语言设计的实验平台 [00:45]

元循环求值器(metacircular interpreter)之所以叫"元循环",是因为它用自身所解释的语言来定义自己,被解释的语言包含了解释器自身。

核心思想:解释器是用同一种语言写成的,语言里能表达的东西,解释器里也能表达。

为什么它是探索语言问题的理想媒介:

  • 想加新特性?改一下就行,非常简单
  • 玩一阵子不喜欢?扔掉重来
  • 想比较不同绑定策略的差异?改几行即可
  • 想和别人交流语言设计想法?把解释器用网络邮件发过去,对方在自己机器上跑一跑,说"这不行",再回信"试试这个版本"

关键点:这是设计专用语言的入门技术——用最小的代价试验语言设计决策。

理解:元循环求值器的价值不在于性能,而在于它是一个"可执行的规范"——语言的定义本身就是一段可运行、可修改的代码。


二、加特性的两种灾难:Creeping Featureism 与 Feet Being Creatureism [02:49]

在添加特性之前,先要警惕两种把系统搞垮的方式:

1. Creeping Featureism(特性蔓延)

  • 计算机科学家有个笑话:bug 会随时转化成为 feature
  • George 加了他喜欢的特性,Harry 觉得系统变味了也加一个,Jim 再加一个……
  • 结果:说明书长达 500 页,没人能看懂
  • 有时是同一个人加了所有特性,造出极其复杂的东西

2. Feet Being Creatureism(脚被造物主义 / 特性被"脚"吃掉)

  • 你有一个带花哨显示器和鼠标的盒子
  • 所有复杂性和花哨 IO 都堆进去
  • 结果:计算机语言本身变成一个可怜的小东西,几乎不能工作
  • 因为窗口系统不断抢占资源,鼠标进程一有动静就醒来又睡去
  • 手肘不小心碰到鼠标,电脑就冒烟

两种毁灭系统的路径:特性太多(语言臃肿)或特性太少(被外部复杂性吞噬)。编辑器这类工具特性多尚可接受,但计算机语言里塞太多东西是灾难。

思考:这是 1986 年的观点,但对今天的编程语言设计依然成立——语言核心应保持小而正交,扩展通过库和宏完成。


三、第一个特性:可变参数过程(Indefinite Numbers of Arguments)[04:52]

3.1 动机

真实的 Lisp 里,+ 和 * 可以接受任意数量的参数:

(+ (* a x x) (* b x))

加法接受 3 个或 2 个参数,乘法接受 2 个或 3 个参数。所有参数以相同方式处理,这是很有价值的特性。

但此前展示的 Lisp 系统参数数量是固定的——在 binder 里把参数和形参一一配对。

3.2 两个子问题

  1. 语法规范:如何表示"不知道有多少个"的额外参数
  2. 语义解释:一旦有了记法,如何解释它

3.3 语法规范:点对记法

(lambda (x . y) ...)
  • x 是必需的一个参数
  • y 是所有剩余参数组成的列表

为什么这个记法合理? 因为它正是 Lisp 里表示序对(cons)的语法:

  • (cons 1 2) 打印出来是 (1 . 2)
  • x . y 对应一个序对,car 是 x,cdr 是 y

匹配过程演示:

  • 形参 (x y z) 匹配实参列表 (1 2 3):x←1,y←2,z←3
  • 形参 (x . y) 匹配实参列表 (1 2 3):x←1,y←(2 3)(剩余部分整体作为列表)

3.4 全部参数都要的情况

如果不想单独取出一个或两个参数,而是所有参数都作为列表:

(lambda x ...)

此时 x 绑定到所有参数的列表。例如 (lambda x x) 就是 list 过程——接受所有参数并返回它们的列表。

语法必须无歧义。要能明确区分三种情况:固定数量的具名形参、具名形参+剩余列表、全部参数作为列表。否则解释器的整段逻辑会因为语法歧义而被"切断"。(传统 Algol 类语言在谓词部分嵌套 if 时就有这类问题。)

3.5 语义实现:一行代码的修改

修改元循环求值器里的 pair-up(配对)过程:

  • 变量列表为空 + 值列表为空 → 返回空列表
  • 变量列表为空 + 值列表非空 → 参数太多(错误)
  • 变量列表非空 + 值列表为空 → 参数太少(错误)
  • 变量列表是一个符号(而非序对)→ 特殊情况:把这个符号绑定到所有值的列表
  • 否则 → 正常递归配对

易错点:这里要区分"符号"和"序对"。(x . y) 在列表结构中,car 是符号 x,cdr 是符号 y(不是空列表)。当形参列表位置遇到一个裸符号时,说明是可变参数情况。

推导:(lambda x x) 的列表结构是 (lambda x x),bound variable list 部分是符号 x。配对时发现是符号,就把整个实参列表绑定给 x。


四、第二个特性:动态绑定(Dynamic Binding)[约 15:00 起]

4.1 引入动机:一个"应该能工作"的抽象

考虑求和过程:

(define (sum term a next b)
  (if (> a b)
      0
      (+ (term a)
         (sum term (next a) next b))))

用它定义"幂之和"和"幂之积":

(define (sum-powers a b n)
  (sum (lambda (x) (expt x n)) a 1+ b))

(define (product-powers a b n)
  (product (lambda (x) (expt x n)) a 1+ b))

问题:两处 (lambda (x) (expt x n)) 几乎相同,为什么不抽出来?

(define (nth-power x) (expt x n))

但是:在环境模型下,n 在 nth-power 里是自由变量,它的含义由定义环境决定。nth-power 定义在全局环境,全局没有 n,所以 n 是未绑定变量。

然而从直觉上,我们希望 n 取的是调用处的 n。

4.2 动态绑定的定义

动态绑定:自由变量的值在调用者(caller)的环境中解释,而不是在定义环境中解释。系统沿着调用链向上搜索。

  • nth-power 被 sum 调用,sum 被 sum-powers 调用,sum-powers 绑定了 n → n 通过调用链获得值
  • 这正是许多早期 Lisp 和 APL 的做法

4.3 实现方式(两处修改)

  1. lambda 子句:不需要捕获定义环境,过程不再需要 (closure expr env) 结构,只保留表达式本身
  2. apply 子句:应用过程时,把调用者的环境传进去,用它来扩展绑定形参,而不是用过程捕获的环境

理解:动态绑定实现起来更简单(少捕获一个环境),这正是早期 Lisp 顺手这么做的原因——"obvious accidental implementation"。

4.4 动态绑定的致命问题:模块化危机

场景:两个人合作开发大系统,希望各自的内部名字互不干扰。我写的代码不应因为别人用了我的内部名字而失效。

反例:sum 的作者把内部变量 next 改名为 end(规格说明完全没变),结果:

  • sum 本身还能工作
  • 但 sum-powers 和 product-powers 全部失效

原因:nth-power 里的 n 不再经由定义环境(定义处 n 明确指向 sum-powers 的 n),而是沿调用链查找,结果找到了 sum 内部改名的 end(或其它同名变量),语义被劫持。

动态绑定破坏了 lambda 的变量捕获(quantifier)性质:lambda 本应保证"被绑定的名字不重要,可以一致替换任意不冲突的新名字而语义不变"。动态绑定下,改一个内部变量名就改变了整个表达式的含义——lambda 不再是良定义的概念。

思考:这是"引用透明性"和"模块化"之间的经典冲突。动态绑定看似方便,但代价是失去局部推理能力。


五、正确的解法:返回过程(一等过程)[约 33:00 起]

在词法(静态)绑定系统里如何获得同样的能力?

答案:写一个过程生成器(procedure generator)

(define (p-gen n)
  (lambda (x) (expt x n)))

(define (sum-powers a b n)
  (sum (p-gen n) a 1+ b))

(define (product-powers a b n)
  (product (p-gen n) a 1+ b))

关键:p-gen 接受 n,返回一个把 n 词法捕获在内部的求幂过程。现在:

  • 抽象被封装起来
  • 不会因为改名而被破坏
  • (p-gen n) 相当于一个"参数化的名字"——一个依赖词法可见的 n 的长名字

David 的提问:这个解法是否依赖于语言能"把过程作为返回值"?

回答:是的。返回过程值、拥有第一类过程(first-class procedures)是做好模块化编程的本质要求。

对比:其它模块化机制(包系统、模块系统等)只能覆盖局部问题;而"返回过程作为值"覆盖所有这些问题,是最简单、模块化效果最好的机制。

拓展:这正是 Scheme 中 let 是 lambda 语法糖、以及闭包(closure)成为现代语言核心的根本原因。JavaScript、Python、Ruby 的闭包都继承这一思想。


六、第三个特性:按名调用 / 延迟参数(Call by Name / Delayed Arguments)[约 40:00 起]

6.1 动机:把特殊形式变成普通过程

昨天学 stream 时,参数延迟需要调用者和被调用者双方约定——被调用者必须记得 force。这种"握手协议"很糟糕。

理想:把延迟决策作为明确标注的设计决策,只由一方负责。

历史背景:Algol 60 有两种调用方式——按名(by name)和按值(by value)。按名调用即参数被延迟,只有访问时才求值。

6.2 目标示例

想定义 unless(if 的反面):

(unless (= 1 0) 2 (/ 1 0))

期望语义:等价于

(cond ((not (= 1 0)) 2)
      (else (/ 1 0)))

问题:普通过程会先求值所有参数,于是 (/ 1 0) 会报错。我们希望 c(consequence)和 a(alternative)自动延迟。

6.3 语法声明

(define (unless p (name c) (name a))
  (if (not p) c a))

name 声明告诉解释器:这两个参数按名传递。

警告:这是打补丁(kludge)。这个声明语法可能与未来的类型声明、优化声明冲突。不要把它当成通用声明机制。每加一个特性都要问:它会不会和别的东西冲突?会不会导致一堆复杂的补丁?

6.4 实现:对求值器基本策略的改动

原策略:求值组合时先求值 operator 得到过程、再求值所有 operands 得到参数、然后 apply。

新策略:必须先检查过程的声明,才知道哪些 operands 要延迟。

关键机制:thunk(延迟对象)

回忆 delay 的实现:(delay expr) 等价于 (lambda () expr)——一个无参过程。

为什么延迟参数必须捕获环境? 因为延迟表达式里有变量,其含义必须来自书写处的上下文。所以要构造一个包含表达式 + 环境的 thunk。

force 的实现:调用 thunk(无参调用),得到的正是"以定义环境为基础、加一个空帧"的环境——即 (lambda () expr) 的调用效果。

6.5 修改点

  1. eval 的组合子句:

    • 求值 operator 得到过程(需要先 force,确保拿到真正的过程)
    • 把当前环境传给 apply,以便延迟某些 operands 时把环境附加上去
  2. apply 的复合过程子句:

    • 用 v-names 剥掉声明,得到变量名
    • 处理声明,决定哪些实参求值、哪些封装成 delay
    • 原语过程(如 +)必须拿到真正的操作数,所以这里要 force
  3. 两种求值列表:

    • evlis:求值 operands,之后会 force
    • g-m-list:遍历表达式列表,遇到普通表达式就 eval,遇到按名参数就构造 thunk(表达式 + 当前环境)作为参数
  4. cond 子句:条件判断处必须 force——必须知道谓词是真还是假。人们常忘记这一处。

6.6 thunk 与 force

  • thunk:包含表达式、环境和类型标签的数据结构。("thunk" 一词来自 Algol,据说是"往栈上压东西的声音",讲师自嘲不是 Algolian,不清楚)
  • force:递归地解包 thunk,直到不再是 thunk 为止

易错点:条件判断、原语过程、数据选择器(car/cdr)等需要真正看数据的地方必须 force;而数据构造器(cons)不需要。

6.7 原语过程能否按名调用?[问答]

提问:为什么避免在原语过程里用按名调用?

回答:某些情况下按名调用原语是合理的:

  • 数据构造(如 cons、数组构造):没必要求值参数,只需保存 promise,等真正访问时才求值。把两个 promise cons 起来和把两个值 cons 起来一样容易。
  • 这正是 lambda 演算中 Church 定义的 car/cdr/cons 能成立的原因:cons/car/cdr 只是搬运数据、不干活("just shuffling data, just routing fuel")。

但必须看数据的操作不能延迟:

  • + 必须看数字的位才能运算(除非是 Church 数那种"奇怪"的表示)
  • 谓词(判断是不是 pair、是不是 symbol)必须看数据
  • 数据选择器、有副作用的操作,在"最懒"的解释器里也不需要 force

设计原则:数据构造器、数据选择器、副作用操作不需要 force;谓词和算术运算必须 force。


七、本讲核心方法论 [总结]

  1. 元循环求值器是语言设计的实验台:改几行就能试验一种设计,不喜欢就扔掉
  2. 加特性要警惕两种灾难:特性蔓延(语言臃肿)、被外部复杂性吞噬(语言萎缩)
  3. 语法必须无歧义:可变参数用点对记法,与已有记法可区分
  4. 动态绑定破坏模块化:lambda 失去变量捕获性质,改名会改变语义
  5. 正确解法是返回过程:一等过程 + 词法绑定 = 最佳模块化机制
  6. 延迟参数需要 thunk(表达式+环境):force 在需要看数据的地方调用
  7. 每个设计决策都要问:它和别的东西如何交互?哪些组合是一致的合法语言,哪些只是补丁堆积?

思考:本讲的深层主题是"设计决策的相互作用"。讲师反复强调"这是你必须自己玩的游戏"——只有亲手试验各种求值器变体,才能看清所有可能的设计决策及其含义。


关键标签 / 关键词

SICP 元循环求值器 metacircular interpreter 可变参数 点对记法 dot notation 动态绑定 dynamic binding 词法绑定 lexical binding 模块化危机 modularity crisis 一等过程 first-class procedures 闭包 closure 按名调用 call by name 延迟参数 delayed arguments thunk force delay Algol 60 特性蔓延 creeping featureism 环境模型 lambda 演算 Church 编码


生成时间:2026-10-11 14:29:25 生成平台:VideoNote www.xygy.top