quote:当代码变成数据
上一章我说括号是「你看得见的语法树」。这一章要把那句话的后果兑现:既然代码是一棵你能看见的树,那你能不能动手改它?答案是能,而且工具你早就会了 —— car、cdr、map,你操作任何列表用的那些函数。开关只有一个字符:'。按下它,代码就变成了数据。这一章是这本书的引信。
一个字符的差别
在 REPL 里敲这两行:
> (+ 1 2) 3 > '(+ 1 2) '(+ 1 2)
第一行:Racket 求值了这个表达式,得到 3。
第二行:前面多了一个撇号,Racket 没有求值,而是把这个表达式原样交还给你。
问题来了:它交还给你的,到底是个什么东西?
不是字符串。不是「代码的文本」。它是一个货真价实的、三个元素的列表。不信你拆开看:
> (define code '(+ 1 (* 2 3))) > (length code) 3 > (car code) '+ ; ← 第一个元素是一个符号 > (symbol? (car code)) #t ; ← 确认:它是 symbol 类型 > (cdr code) '(1 (* 2 3)) ; ← 剩下的两个元素 > (third code) '(* 2 3) ; ← 第三个元素本身又是一个列表
停一下,确认你看到了什么。
你刚才写的 (+ 1 (* 2 3)) 是一段代码。而你正在用 car、cdr、length —— 你操作购物清单的那些函数 —— 拆解它。
它就是一个列表。第一个元素是符号 +,第二个是数字 1,第三个是另一个列表。没有 AstNode,没有 BinaryExpression,没有 KSFunctionDeclaration。就是表。
那就改改看
既然它是数据,那就能改。我们把加法换成乘法:
(define code '(+ 1 (* 2 3))) (define patched (cons '* (cdr code))) ; 把头换成 *,身子不动 patched ; ⇒ '(* 1 (* 2 3))
然后 —— 把这个我们刚拼出来的数据,当成代码跑掉:
> (eval code (make-base-namespace)) 7 ; (+ 1 (* 2 3)) > (eval patched (make-base-namespace)) 6 ; (* 1 (* 2 3))
你刚刚写了一个程序,它修改了另一个程序,然后运行了它。
用的工具是:cons 和 cdr。总共两个函数,都是你学列表的第一天就会的。
这件事有多不寻常,得对着 Kotlin 才看得清
你想在 Kotlin 里干同样的事 —— 把 1 + 2 * 3 里的 + 换成 *,然后跑它。
你需要:
- 一个解析器,把
"1 + 2 * 3"变成树(因为源码是字符串,不是树) - 一套 AST 类型:
BinaryExpr、IntLiteral、Operator枚举…… - 一个 visitor 来遍历和改写它
- 一个代码生成器,把改完的树变回代码(或者字节码)
- 一个编译器 + 类加载器,把生成的代码真的跑起来
这就是 KSP、Babel plugin、注解处理器的全部工作量来源。它们要维护一整套「代码的影子数据结构」,因为在这些语言里,代码在运行时根本不存在 —— 它编译完就蒸发了,只剩下字节码。
「程序的代码,用这门语言自己的基本数据结构来表示。」
Lisp 的基本数据结构是表,而 Lisp 的代码就是表。所以:
- 操作代码 = 操作表 = 你已经会的事
- 生成代码 = 造一个表 = 你已经会的事
- 你不需要「代码的 API」,因为代码没有额外的表示
同像性不是「多了一个特性」,是少了一堵墙。这是它最难被人 appreciate 的原因 —— 你没法演示一个「不存在的东西」有多好用。你只能等一个人被 KSP 折磨过之后,再告诉他:那堵墙本来可以不在。
quote 到底 quote 了什么
把规则说清楚,因为它比看起来简单:
| 你写 | Racket 做什么 | 得到 |
|---|---|---|
(+ 1 2) | 求值:把 + 查出来,把参数算出来,调用它 | 3 |
'(+ 1 2) | 不求值,把这个表原样给你 | '(+ 1 2)(一个表) |
x | 求值:查变量 x 的值 | 它的值 |
'x | 不求值,给你符号 x 本身 | 'x(一个 symbol) |
'(1 2 3) | 不求值 —— 但这里本来也没什么好求的 | '(1 2 3) |
(list 1 2 3) | 求值:调用 list 函数 | '(1 2 3) —— 同一个东西! |
最后两行值得停一下:'(1 2 3) 和 (list 1 2 3) 得到的是完全相同的值。两条路,一个目的地。一条是「原样拿来」,一条是「现场造一个」。
推论(很重要):既然 '(+ 1 2) 是一个表,那我也可以用 list 现场造一个一模一样的:
> (list '+ 1 2) '(+ 1 2) > (eval (list '+ 1 2) (make-base-namespace)) 3
你刚刚用 list 函数「生成」了一段代码。这就是代码生成的全部技术含量。
符号(symbol):一种你的语言里没有的类型
上面反复出现的 '+、'x 是什么?它们是 符号(symbol),Lisp 里的一个基本类型。
符号不是字符串,虽然它有个名字:
> (symbol? 'foo) #t > (string? 'foo) #f > (symbol->string 'foo) "foo" > (eq? 'foo 'foo) #t ; ← 符号是「驻留」的:同名符号是同一个对象,比较是 O(1)
符号是「名字」这个概念的一等公民化。在 Kotlin 里,变量名 userId 只存在于源码里,编译后就没了。在 Lisp 里,'userId 是一个你可以传来传去、放进列表、比较、存起来的值。
这正是「代码是数据」的物质基础:一段代码 = 一个由符号和字面量组成的树。没有符号,代码就只能是字符串。
John McCarthy 发明 Lisp 时,本来打算给它设计一套「正常的」中缀语法,叫 M-表达式(M = Meta)。而括号那套东西 —— S-表达式(S = Symbolic)—— 只是内部用来表示数据的格式,是个临时方案。
结果他的学生 Steve Russell 发现:既然 eval 已经写出来了,而 S-表达式又能表示代码,那干脆直接用 S-表达式写程序不就行了?他手工把 McCarthy 论文里的 eval 翻译成了机器码 —— Lisp 的第一个解释器就这么诞生了,而 M-表达式再也没人提起。
Lisp 最著名的特性,是一个没来得及被实现的设计的副产品。McCarthy 后来说,他当时完全没意识到这件事有多重要。
quasiquote:代码模板
' 有个问题:它把一切都冻住了。可我经常想「大部分冻住,但这里留个洞,填进一个算出来的值」。
这就是 quasiquote(反引号 `)和 unquote(逗号 ,):
(define name 'x) (define val 42) `(define ,name ,val) ; ⇒ '(define x 42) ;↑ ↑ ↑ ;反引号:冻住 逗号:这里解冻,把变量的值填进来
还有 ,@(unquote-splicing),它把一个列表摊平进去:
`(list ,@(list 1 2 3) 4) ; ⇒ '(list 1 2 3 4) ; ↑ 摊开,而不是嵌套 ; 对比一下不摊开的: `(list ,(list 1 2 3) 4) ; ⇒ '(list (1 2 3) 4)
quasiquote 就是字符串模板,只不过它的产物是树而不是字符串。
// Kotlin val greeting = "Hello, $name! You are $age." // ↑ 大部分是字面量 ↑ $ 是洞
; Racket `(greet ,name ,age) ; ↑ 反引号 = 大部分字面 ↑ 逗号 = 洞
区别只有一个,但它是决定性的:Kotlin 的模板产出字符串,你还得再解析一遍才能操作它;Racket 的模板产出树,可以直接跑。
把它们串起来:一个真的代码生成器
现在写点有用的。假设你要生成十个变量定义(真实场景:生成绑定、生成序列化代码、生成测试用例):
(define (make-defines n)
(for/list ([i (in-range n)])
`(define ,(string->symbol (format "v~a" i)) ,i)))
(make-defines 4)
; ⇒ '((define v0 0) (define v1 1) (define v2 2) (define v3 3))
四行。没有 KotlinPoet,没有 FunSpec.builder(),没有 addStatement()。就是一个普通的 for/list,产出一个普通的列表 —— 而那个列表恰好是可执行的代码。
再来个更狠的。用 map 改写代码:把一段代码里所有的数字都乘以 10。
(map (lambda (x) (if (number? x) (* x 10) x))
'(+ 1 2 3))
; ⇒ '(+ 10 20 30)
你刚才用 map 做了一次代码变换。这在 Babel 里是一个 plugin,要写 visitor、要处理 node type、要小心不要破坏 scope。在这里它是一行 map。
一个必须马上打的预防针:eval 不是宏
你现在手里有 quote 和 eval,可能已经开始兴奋了。先冷静一下。
eval 是运行时把数据当代码跑。它的问题和 JS 的 eval、Java 的反射 + 动态类加载一模一样:
- 慢 —— 每次都要重新展开、编译
- 没有编译期检查 —— 拼错了要到运行时才炸
- 安全风险 —— 如果那段数据来自用户输入,你就有了一个远程代码执行漏洞
- 需要显式的 namespace(注意上面我一直在写
(make-base-namespace))—— Racket 故意让它用起来别扭,这是一个设计上的劝退
「代码即数据」的正确用法不是 eval,是宏。
宏做的是同样的事 —— 把代码当数据操作 —— 但它发生在编译期(准确说是「展开期」,第 17 章会精确定义)。生成的代码会被正常编译,有完整的检查,零运行时开销。
一句话:eval 是把这个能力用在运行时(几乎总是错的);宏是把它用在展开期(这才是它该待的地方)。
那我们为什么还要学 eval?两个理由:
① 它是理解「代码即数据」最直接的演示 —— 你必须先亲眼看见「一个表可以被跑掉」,宏才不会显得像魔法。
② 第 21 章你要自己写一个 eval。到那时候,你会知道它内部到底发生了什么 —— 而不是把它当成一个黑盒的危险玩具。
回流
你的语言没有同像性。但它一定有一堆东西在模拟它 —— 认出这些替代品,你就能看清它们各自在哪里妥协了。
| 你的语言里的 | 它在模拟什么 | 代价 |
|---|---|---|
| 注解 + KSP / APT | 编译期代码生成 | 要学一整套 KSClassDeclaration 影子类型;生成的是字符串,靠 KotlinPoet 拼 |
| Babel plugin / ESLint 规则 | 代码变换 | 要在别人定义的 AST 节点类型上爬;t.binaryExpression(...) 一个节点一个节点砌 |
Kotlin 的 builder DSL(buildString、Compose、Ktor 路由) | 「用代码构造代码结构」 | 只能构造数据,不能构造语法。if 和 for 你还是造不出来 |
| 反射 + 动态代理 | 运行时改行为 | 慢、无类型检查、混淆器的噩梦。这是运行时的 eval |
| 模板引擎 / 代码生成脚本 | 直接生成源码文本 | 产出的是字符串,语法错误要等编译才发现 |
看出共同点了吗:它们都在处理「代码的某种替身」 —— 影子类型、AST 节点、字符串模板。而每一种替身,都要付一次「翻译」的税。
下次你写 KSP 处理器写到烦躁的时候,你会知道那股烦躁的确切来源了。这不能让你的 KSP 变好写,但它能让你在设计的时候做出更好的选择 —— 比如:能用普通函数解决的,就别上代码生成。因为在你的语言里,代码生成的价格比在 Lisp 里贵一个数量级。
卷 I 到此结束
四章下来,你手里有了三样东西:
read—— 文本变成树(第 3 章那台机器)- 树就是数据 —— 可以用普通函数操作(本章)
eval—— 数据可以变回代码跑掉(本章,且我警告了你别乱用)
read → eval → print。这就是 REPL 那四个字母的来历,也是这本书的书名。
卷 I 讲完了 read。从下一章开始,我们钻进 eval 里面 —— 那台机器的心脏。
第一站:环境。也就是「闭包到底捕获了什么」这个问题的真正答案。