元循环求值器(下):让你的语言长出新特性
上一章你造出了一台会求值的机器。这一章我们做那件真正让人上瘾的事:给这门你自己的语言,加上宿主 Racket 都没有的特性。加短路的 and、加 let*、加 cond —— 每一个,都只是往那个 case 里多写一个分支。然后是压轴的:给它加一个 while 循环,而 Racket 本身没有 while。当你亲手做完这件事,一个东西会永久改变 —— 你会彻底明白,「语言特性」不是天赐的,是某个人在他的 eval 里,写下的一个分支。
加特性 = 往 case 里加一个分支
回忆上一章 my-eval 的结构:一个大 case,根据表达式的第一个词分派。「给语言加一个特殊形式」,就是往这个 case 里加一个 [(名字) ...]。就这么简单 —— 而这个简单本身,就是这一章要让你感受的东西。
加 and(短路)
; 加进 my-eval 的 case 里:
[(and)
(let loop ([es (cdr expr)])
(cond
[(null? es) #t] ; (and) ⇒ #t
[(null? (cdr es)) (my-eval (car es) env)] ; 最后一个:它的值就是结果
[(my-eval (car es) env) (loop (cdr es))] ; 真 → 继续看下一个
[else #f]))] ; 假 → 短路,后面不看了
验证它真的短路:
(run '(and #t #f (error "永不执行"))) ⇒ #f ; error 没有被求值 —— 短路成功
第 7 章我说:「and 能短路,是因为它是特殊形式,而你拿不到这个特权。」
现在你拿到了。看那个 [(and) ...] 分支 —— 它没有被丢进「先 map 求值所有参数」的 else 里。它自己决定求值的顺序和时机,选择在遇到假值时停下。
「短路」不是魔法,就是「在这个分支里,我选择不去 eval 剩下的参数」。你在自己的语言里,亲手给了 and 这个特权。
加 let*(顺序绑定)
普通 let 里,各个绑定看不见彼此。let* 让后面的绑定能用到前面的:
[(let*) (define ne (make-env env)) (for ([b (cadr expr)]) (env-define! ne (car b) (my-eval (cadr b) ne))) ; ← 注意:在 ne 里求值! (for/last ([e (cddr expr)]) (my-eval e ne))]
和 let 唯一的区别:求值绑定的值时,用的是新环境 ne(而不是外层 env)。所以后一个绑定能看见前一个刚放进 ne 的东西。一个字符的差别,一个特性。
(run '(let* ([a 2] [b (* a 3)]) (+ a b))) ⇒ 8 ; b 用到了 a —— let* 生效
加 cond
[(cond)
(let loop ([clauses (cdr expr)])
(cond
[(null? clauses) (void)]
[(eq? (car (car clauses)) 'else) (my-eval (cadr (car clauses)) env)]
[(my-eval (car (car clauses)) env) (my-eval (cadr (car clauses)) env)]
[else (loop (cdr clauses))]))]
你也可以不把 cond 做成特殊形式,而是用宏把它展开成嵌套的 if(第 18 章那样)。这是一个真实的语言设计决策:哪些东西做成核心特殊形式,哪些做成宏(建立在核心之上)?Racket 的答案是:核心尽量小(十来个特殊形式),其余全是宏。你的答案可以不同 —— 这就是「语言设计」这四个字的实际含义。
压轴:给它一个 Racket 都没有的 while
Racket 语言没有 while 循环。它用递归(第 8 章)。但我们的小语言,可以有:
[(while)
(let loop ()
(when (my-eval (cadr expr) env) ; 求值条件
(for ([e (cddr expr)]) ; 真 → 执行循环体
(my-eval e env))
(loop)))] ; 再来一遍
配上 set!(也是几行的事),我们的小语言现在能写命令式的循环了:
(run '(define counter 0))
(run '(define sum 0))
(run '(while (< counter 5)
(set! sum (+ sum counter))
(set! counter (+ counter 1))))
(run 'sum)
⇒ 10 ; 0+1+2+3+4 —— 一个 Racket 没有的循环,在你的语言里跑起来了
你的语言,现在拥有了它的宿主没有的东西。
这不是小事。它意味着:你不再是「语言的用户」了 —— 你是「语言的设计者」。「有没有 while」这种以前对你来说是天条的东西,现在是你 case 里的一个分支,你想加就加,想怎么定义就怎么定义。
你甚至可以定义一个奇怪的 while —— 比如「至少执行一次」的(do-while)、或者「条件为假才继续」的(until)、或者「循环体能通过一个 break 续延跳出」的(用上第 14 章的 call/cc)。语义完全由你写的那个分支决定。规则不再约束你,你在制定规则。
更进一步:给它加宏
如果你想更疯狂 —— 你可以给你的小语言加一个宏系统。思路(不展开完整代码,但方向你已经完全能理解了):
- 在环境里,除了「变量 → 值」,再存一张「宏名 → 变换函数」的表
my-eval遇到一个复合表达式时,先查它的第一个词是不是一个宏- 如果是:先调用那个变换函数把代码变形(展开),再 eval 变形后的结果
- 加一个
define-macro特殊形式,让用户往那张表里注册变换函数
看清楚这个结构:宏 = 「在 eval 之前,先跑一个『代码→代码』的函数」。这正是第 17 章「展开期在求值期之前」的实现。你现在不仅理解了那句话,你还知道怎么把它做出来。
你在第 4 章学了 eval(把数据当代码跑)。你在第 17–20 章学了宏(展开期的代码变换)。现在,在你自己的 eval 里,你能把它们全部实现一遍。
这就是元循环的终极威力:一旦你有了一台可以修改的 eval,语言的每一个特性 —— 求值策略、作用域规则、宏、甚至续延 —— 都变成了「你可以在这台机器里做的一个设计选择」。你不再是站在语言外面仰望它,你站在了它的里面,手里拿着改它的工具。
一个诚实的边界:解释器 vs 编译器
别让这一章的兴奋冲昏头。我们造的这台机器,每次运行都要重新遍历一遍语法树,反复查环境哈希表。它慢。比原生 Racket 慢几个数量级。
真正的语言实现会做更多的事来换性能:
- 编译到字节码(CPython、JVM):把树一次性翻译成扁平的指令,之后不用反复遍历树
- JIT(V8、HotSpot、Racket 自己的 Chez 后端):把热点代码进一步编译成机器码
- 变量的词法地址:把「查哈希表找变量」优化成「取第 2 帧第 3 个槽」(编译期就算好位置)
但这些都是优化,不改变本质。字节码解释器的核心,还是一个「看指令、分派处理」的循环 —— 和你的 my-eval 是同一个骨架,只是输入从「树」变成了「扁平指令」。你理解了这台一百行的慢机器,就理解了那些快机器的灵魂。SICP 后面几章、以及《Crafting Interpreters》这本书,走的正是「从树遍历解释器,升级到字节码虚拟机」这条路 —— 第 25 章会给你指路。
回流
「造一门语言」听起来遥远,但「造一个小语言」在真实工程里到处都是,而你现在有能力做了:
| 真实场景 | 它其实是一个小 eval |
|---|---|
| 规则引擎 / feature flag 条件 | 让运营写 (and (> age 18) (in region "NZ")),你 eval 它 |
| 公式 / 计算字段(像 Excel、Notion 公式) | 一个表达式求值器 + 一个变量环境 |
| 查询 DSL(搜索过滤器、GraphQL resolver) | 把查询解析成树,遍历求值 |
| 模板引擎 | {{if}} {{each}} —— 就是特殊形式 |
| 游戏 / 对话脚本、行为树 | 一个解释器,环境里装游戏状态 |
| 配置语言的动态部分 | 让配置能写简单逻辑,而不是硬编码 |
三条实践建议:
① 当你需要「让用户写一点逻辑」时,你现在知道正确的结构了。不要用 eval(宿主的,危险)、不要用一堆 if-else 硬凑、更不要发明一个正则怪物去解析。正确的骨架是:定义你的小语言(有哪些特殊形式?)→ 解析成树 → 写一个看形状分派的 eval → 用一个环境装变量。这个骨架你这两章已经完整走过一遍了。
② 你现在能安全地做这件事。用户写的 DSL 跑在你的 eval 里 —— 你精确控制它能调用什么(就那几个你放进环境的原语)、不能干什么。它不能读文件、不能发网络请求、不能死循环炸掉你的服务器(你可以在 eval 里加步数限制)。这是相对「用宿主 eval」的巨大安全优势 —— 也是为什么真实世界的规则引擎都自己写 eval,而不是用 eval()。
③ 最重要的:你面对「语言」时的心态永久变了。Kotlin 为什么这样设计?TypeScript 的类型为什么那样工作?——这些不再是「必须接受的事实」,而是「某个团队在他们的 eval / 类型检查器里做的设计选择」。你可以不同意,你可以想象别的做法,你甚至可以自己做一个。你从语言的信徒,变成了语言的同行。
下一章
我们手工造了一台 eval,还给它加了特性。但每次都要自己写 read、自己管环境、自己搭 REPL,挺累的。
下一章,Racket 亮出它区别于所有其他 Lisp 的终极武器:#lang。它让「造一门语言」不再是「写一个解释器」,而是「写一个模块」—— 你造的语言,能像库一样被 require,能复用 Racket 的读取器、宏系统、工具链、甚至 DrRacket 的 IDE 支持。四十行,一门带完整工具链的新语言。
那才是第 2 章那个悬念 —— #lang 这一行 —— 真正的答案。