卷 VI · 造语CH 21深度 21/25

元循环求值器(上):用 Racket 写 Racket

这是整本书的最高峰。我们要做第 1 章题 10 承诺过的那件事:用 Racket,写一个能跑 Racket 的解释器。它支持变量、函数、闭包、递归、条件、let —— 不是玩具,是真能跑 (fact 5) 得到 120、能跑高阶函数的东西。而它的核心,只有两个互相调用的函数:evalapply你会亲眼看着它们咬住彼此的尾巴,把整棵语法树吃完。前二十章的每一个概念 —— 环境、闭包、求值顺序、特殊形式 —— 会在这一百行里全部汇合

元循环求值器evalapplySICP解释器

为什么这件事这么重要

「用一门语言写它自己的解释器」——这个动作有个名字,叫元循环求值器(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 与 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-lookupelse 分支「往父帧找」,就是你在环境链 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。它们全都是元循环求值器的工业级后代。

回流

⟲ 回流 · 你刚刚获得的,是一副「X 光眼镜」

这一章的回流不是某段能抄走的代码。是一个永久性的视角转变。

① 你现在能看穿任何语言的实现了。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 是你的了。