元循环求值器(上):用 Racket 写 Racket
这是整本书的最高峰。我们要做第 1 章题 10 承诺过的那件事:用 Racket,写一个能跑 Racket 的解释器。它支持变量、函数、闭包、递归、条件、let —— 不是玩具,是真能跑 (fact 5) 得到 120、能跑高阶函数的东西。而它的核心,只有两个互相调用的函数:eval 和 apply。你会亲眼看着它们咬住彼此的尾巴,把整棵语法树吃完。前二十章的每一个概念 —— 环境、闭包、求值顺序、特殊形式 —— 会在这一百行里全部汇合。
为什么这件事这么重要
「用一门语言写它自己的解释器」——这个动作有个名字,叫元循环求值器(metacircular evaluator)。它是 SICP 第四章的高潮,也是很多人编程生涯里的一个分水岭时刻。
为什么是分水岭?因为在你写出它之前,「编程语言」对你是一个黑盒 —— 一套你必须遵守、但不知其所以然的规则。写出它之后,语言变成了一个你亲手拆装过的机器。你会开始把所有语言 —— Kotlin、JS、Rust —— 都看成「某台 eval 机器上的一组配置」,而不是「必须背下来的戒律」。
因为前面二十章,已经把所有零件都造好了。写解释器需要的每一样东西,你都已经有了:
- 不用写词法/语法分析器 ——
read已经把代码变成表了(第 3、4 章) - 不用设计 AST 类型 —— 表就是 AST(第 4 章)
- 闭包只是一个「参数 + 函数体 + 环境」的结构体(第 5、6 章)
- 环境就是一串带父指针的帧(第 5 章)
- if 要特殊处理,因为它不能按普通调用来(第 7 章)
- 用
match/case对着代码的形状分派(第 11 章)
这一章不是学新东西,是把二十章的零件拧到一起。这就是为什么它在 Racket 里只要一百行,而在 Java 里要几千行 —— Java 里这些零件一个都不是现成的。
整个求值器,就是 eval 和 apply
在写代码之前,先把那张架构图刻进脑子。整台机器只有两个函数:
eval:把一个表达式,在一个环境里,变成一个值。
它看表达式的形状:是数字?返回它自己。是符号?查环境。是 if?特殊处理。是函数调用?——算出函数和参数,然后交给 apply。
apply:把一个函数,用在一组值上,得到一个值。
是原语(比如 +)?直接算。是闭包?——新建一个帧绑定参数,然后回头调 eval 去算函数体。
看那两句划线的:eval 调用 apply,apply 又调用 eval。它们互相咬着尾巴。这就是「元循环」里那个「循环」。
动手:一块一块搭
下面这份代码,你可以一段段粘进 DrRacket 跑。我在本机 Racket v9.2 上完整跑过它。
零件 1:环境(第 5 章那张图,变成代码)
#lang racket
; 一个环境帧:一张「名字→值」的表 + 指向父帧的指针
(struct frame (vars parent))
(define (make-env parent) (frame (make-hash) parent))
(define (env-lookup env name) ; 查变量:顺着链往上找
(cond
[(not env) (error 'eval "未绑定的标识符: ~a" name)]
[(hash-has-key? (frame-vars env) name)
(hash-ref (frame-vars env) name)]
[else (env-lookup (frame-parent env) name)])) ; ← 找不到就往父帧找
(define (env-define! env name val)
(hash-set! (frame-vars env) name val))
这就是第 5 章那个环境链,一字不差地变成了代码。env-lookup 的 else 分支「往父帧找」,就是你在环境链 demo 里看到的那根向上的箭头。
零件 2:闭包(第 6 章那个「代码 + 出生环境」)
; 闭包 = 参数 + 函数体 + 「我出生时的环境」 (struct closure (params body env))
三个字段。第 5 章我说「闭包就是一个三字段结构体」,这里它真的是。
零件 3:全局环境,装几个原语
(define global (make-env #f)) ; 顶层,没有父帧
; 把宿主 Racket 的一些函数,借给我们的小语言用
(for ([pair (list (cons '+ +) (cons '- -) (cons '* *) (cons '/ /)
(cons '= =) (cons '< <) (cons '> >)
(cons 'cons cons) (cons 'car car) (cons 'cdr cdr)
(cons 'list list) (cons 'null? null?))])
(env-define! global (car pair) (cdr pair)))
这些「原语」是我们语言的地基 —— 我们不自己实现加法,直接借宿主 Racket 的 +。(一个真正从零开始的解释器会连加法都自己实现到位运算,但那是另一个故事。)
零件 4:eval —— 机器的一半
(define (my-eval expr env)
(cond
; —— 自求值:数字、布尔,就是它们自己(第 7 章步进器里的「值」)
[(number? expr) expr]
[(boolean? expr) expr]
; —— 符号:查环境(第 5 章)
[(symbol? expr) (env-lookup env expr)]
; —— 复合表达式:看第一个词是不是特殊形式
[(pair? expr)
(case (car expr)
; quote:原样返回,不求值(第 4 章)
[(quote) (cadr expr)]
; if:特殊形式 —— 只求值一个分支(第 7 章!)
[(if) (if (my-eval (cadr expr) env)
(my-eval (caddr expr) env)
(my-eval (cadddr expr) env))]
; define:给名字绑个值
[(define)
(if (pair? (cadr expr))
; (define (f x) body) 是糖(第 6 章)
(env-define! env (car (cadr expr))
(closure (cdr (cadr expr)) (cddr expr) env))
(env-define! env (cadr expr) (my-eval (caddr expr) env)))]
; lambda:造一个闭包 —— 把当前环境装进去(第 5、6 章)
[(lambda) (closure (cadr expr) (cddr expr) env)]
; let:新建一帧,绑定,在新帧里求值体
[(let)
(define new-env (make-env env))
(for ([b (cadr expr)])
(env-define! new-env (car b) (my-eval (cadr b) env)))
(for/last ([e (cddr expr)]) (my-eval e new-env))]
; —— 都不是?那就是函数调用:
[else
(my-apply (my-eval (car expr) env) ; 算出函数
(map (lambda (a) (my-eval a env)) (cdr expr)))])] ; 算出所有参数
[else (error 'eval "不认识的表达式: ~a" expr)]))
这个 my-eval 里,前二十章的概念全在这儿了:
- 那个
[(if) ...]:它手动只求值一个分支。这就是第 7 章「if 为什么必须是特殊形式」的物质证据 —— 你看它没有被丢进最后那个「算出所有参数」的else里。如果丢进去了,两个分支就都会被求值,myIf的悲剧重演。 - 那个
[(lambda) (closure ... env)]:它把当前的env装进闭包。第 5 章的「捕获出生环境」,就是这一行。 - 那个
else分支的map:它先把所有参数求值,再调 apply。这就是应用序(第 7 章) —— 白纸黑字。
你不是在学新东西。你是在看那些概念,一个个变成可执行的代码。
零件 5:apply —— 机器的另一半
(define (my-apply f args)
(cond
; 原语:直接借宿主的力
[(procedure? f) (apply f args)]
; 闭包:第 5 章那个「调用」动作
[(closure? f)
(define new-env (make-env (closure-env f))) ; ← 新帧的父 = 闭包的出生环境!
(for ([p (closure-params f)] [a args]) ; (不是调用者的环境 —— 词法作用域)
(env-define! new-env p a)) ; 绑定参数
(for/last ([e (closure-body f)]) ; 在新帧里求值函数体
(my-eval e new-env))] ; ↑ apply 又调回了 eval!
[else (error 'apply "不是一个函数: ~a" f)]))
① (make-env (closure-env f)) —— 新帧的父环境是「闭包出生的地方」,不是「谁调用它」。这一行,就是第 5 章那个「x 是 10 不是 20」的词法作用域,以及 1975 年 Scheme 那次历史性转向。整个词法作用域,就浓缩在这一个括号里。如果你把它错写成把调用者的环境当父帧,你就亲手实现了动态作用域 —— 那个 Lisp 用了十七年才修正的 bug。
② 最后那个 (my-eval e new-env) —— apply 调回了 eval。这就是「循环」闭合的地方。eval 遇到调用 → 叫 apply;apply 要算函数体 → 叫 eval;eval 又可能遇到调用 → 再叫 apply……两个函数手拉手,把整棵语法树从上到下走完。
让它跑起来
(define (run expr) (my-eval expr global))
(run '(+ 1 (* 2 3))) ; ⇒ 7
(run '(if (> 3 2) 'yes 'no)) ; ⇒ 'yes
(run '(define (square x) (* x x)))
(run '(square 7)) ; ⇒ 49
(run '(define (fact n)
(if (= n 0) 1 (* n (fact (- n 1))))))
(run '(fact 5)) ; ⇒ 120 ← 递归!
(run '(define (adder n) (lambda (x) (+ x n))))
(run '((adder 10) 5)) ; ⇒ 15 ← 闭包!
(run '(let ([a 3] [b 4]) (+ (* a a) (* b b)))) ; ⇒ 25
(run '(define (map1 f lst)
(if (null? lst) '()
(cons (f (car lst)) (map1 f (cdr lst))))))
(run '(map1 (lambda (x) (* x x)) (list 1 2 3 4))) ; ⇒ '(1 4 9 16) ← 高阶!
它全都跑通了。递归、闭包、高阶函数、let —— 一个真正的编程语言的核心,一百行。而这一百行里,没有一行是这一章新学的 —— 全是前二十章零件的组装。
看着 eval 和 apply 互相咬尾巴
光看代码不够。下面这台机器,就是上面那个 my-eval / my-apply 的忠实翻版(我用 JS 重写了一遍,好让它一边跑一边把自己的调用轨迹吐出来 —— 但它的逻辑和上面的 Racket 一一对应)。选一个例子,一步步走,盯着 eval 和 apply 交替出现:
特别看那个 fact 的例子:你会看到 eval 遇到 (fact 4) → apply 展开函数体 → 又 eval 到 (fact 3) → 又 apply……那条一层层深下去又一层层浮上来的轨迹,就是递归的全貌。而 if 的例子会让你看到:没被选中的那个分支,轨迹里一行都没有 —— 特殊形式的威力,肉眼可见。
「元循环」这个名字的深意
你可能已经注意到了:我们的 my-eval 里,if 是用宿主 Racket 的 if 实现的,闭包的环境是用宿主的结构体,加法是借的宿主的 +。
这正是「元循环」的含义 —— 我们用一门语言的特性,去解释那门语言的同样的特性。我们没有「从头」定义 if,我们是借了底层的 if 来实现上层的 if。
这看起来像循环论证,但它不是骗局 —— 它是一个深刻的事实:你只需要一个已经会求值的底座,就能在它上面解释出任意语言。底座的 if 只有一个,但你可以在它上面实现一千种不同语义的 if(惰性的、带 continuation 的、能回溯的)。下一章我们就要这么干 —— 修改这台机器,让它长出宿主语言没有的特性。
(想「彻底不作弊」?那你得一路下降到图灵机或 λ 演算 —— 第 6 章那个 Church 编码就是「连数字都不借」的极端版本。但在实践中,「借一个底座」是所有解释器都在做的事,包括 CPython、V8、JVM。它们全都是元循环求值器的工业级后代。)
回流
这一章的回流不是某段能抄走的代码。是一个永久性的视角转变。
① 你现在能看穿任何语言的实现了。CPython 的核心是一个巨大的 ceval.c,里面一个巨大的 switch —— 那就是 my-eval 的那个 case,只是用 C 写的、有几千个分支。V8、JVM、Ruby、PHP —— 每一个解释器的心脏,都是一个「看表达式形状,分派处理」的循环。你读过一个一百行的,你就认得出所有的。
② 你能推断一个语言特性「贵不贵」了。下次你想知道「Kotlin 的这个特性性能如何」,你可以在脑子里问:它在 eval 循环里对应哪个分支?每次执行它,机器要做多少事?为什么反射慢(要在运行时查一堆表)、为什么 when 分派快(编译成跳转表)、为什么闭包有分配开销(要建一个 closure 结构体)——你现在有了推断这些的底层模型。
③ 你写解析器 / DSL / 配置语言时,不再害怕了。无数真实工作里的任务 —— 一个表达式求值器(给用户写规则)、一个模板引擎、一个查询语言、一个 feature flag 的条件 DSL —— 本质都是一个小小的 eval。你现在知道它的骨架:read → 一个看形状分派的 eval → 环境。这不再是「编译原理专家」才敢碰的东西。
④ 最深的一条:你不再把编程语言当成「规则」,而是「设计」。每一条语法规则背后,都是某个人在他的 eval 里选择了某个分支的某种写法。语言不是从天上掉下来的戒律,是别人做的设计决定——而现在,你也会做这个决定了。
下一章
我们造出了一台会求值的机器。但目前它只能解释 Racket 已经有的东西。
下一章,我们做一件真正激动人心的事:修改这台机器,让我们的小语言长出宿主 Racket 没有的特性。加一个 let?小菜。加一个自己的宏系统?可以。加一个 amb(能回溯的非确定性选择,直接用上第 14 章的续延)?——你会发现,给一门你自己造的语言加特性,比你想象的容易得多。
因为这一次,eval 是你的了。