卷 I · 读CH 04深度 04/25

quote:当代码变成数据

上一章我说括号是「你看得见的语法树」。这一章要把那句话的后果兑现:既然代码是一棵你能看见的树,那你能不能动手改它答案是能,而且工具你早就会了 —— carcdrmap,你操作任何列表用的那些函数。开关只有一个字符:'。按下它,代码就变成了数据。这一章是这本书的引信。

quote同像性evalquasiquote代码生成

一个字符的差别

在 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)) 是一段代码。而你正在用 carcdrlength —— 你操作购物清单的那些函数 —— 拆解它。

就是一个列表。第一个元素是符号 +,第二个是数字 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))

你刚刚写了一个程序,它修改了另一个程序,然后运行了它。

用的工具是:conscdr总共两个函数,都是你学列表的第一天就会的。

这件事有多不寻常,得对着 Kotlin 才看得清

你想在 Kotlin 里干同样的事 —— 把 1 + 2 * 3 里的 + 换成 *,然后跑它。

你需要:

  1. 一个解析器,把 "1 + 2 * 3" 变成树(因为源码是字符串,不是树)
  2. 一套 AST 类型BinaryExprIntLiteralOperator 枚举……
  3. 一个 visitor 来遍历和改写它
  4. 一个代码生成器,把改完的树变回代码(或者字节码)
  5. 一个编译器 + 类加载器,把生成的代码真的跑起来

这就是 KSP、Babel plugin、注解处理器的全部工作量来源。它们要维护一整套「代码的影子数据结构」,因为在这些语言里,代码在运行时根本不存在 —— 它编译完就蒸发了,只剩下字节码。

◆ 同像性(homoiconicity)

「程序的代码,用这门语言自己的基本数据结构来表示。」

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 是一个你可以传来传去、放进列表、比较、存起来的值

这正是「代码是数据」的物质基础:一段代码 = 一个由符号和字面量组成的树。没有符号,代码就只能是字符串。

✎ 掌故 · 1958 年的一个意外

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 不是宏

你现在手里有 quoteeval,可能已经开始兴奋了。先冷静一下。

⚠ 不要在生产代码里用 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 DSLbuildString、Compose、Ktor 路由)「用代码构造代码结构」只能构造数据,不能构造语法iffor 你还是造不出来
反射 + 动态代理运行时改行为慢、无类型检查、混淆器的噩梦。这是运行时的 eval
模板引擎 / 代码生成脚本直接生成源码文本产出的是字符串,语法错误要等编译才发现

看出共同点了吗:它们都在处理「代码的某种替身」 —— 影子类型、AST 节点、字符串模板。而每一种替身,都要付一次「翻译」的税。

下次你写 KSP 处理器写到烦躁的时候,你会知道那股烦躁的确切来源了。这不能让你的 KSP 变好写,但它能让你在设计的时候做出更好的选择 —— 比如:能用普通函数解决的,就别上代码生成。因为在你的语言里,代码生成的价格比在 Lisp 里贵一个数量级。

卷 I 到此结束

四章下来,你手里有了三样东西:

  • read —— 文本变成树(第 3 章那台机器)
  • 树就是数据 —— 可以用普通函数操作(本章)
  • eval —— 数据可以变回代码跑掉(本章,且我警告了你别乱用)

read → eval → print。这就是 REPL 那四个字母的来历,也是这本书的书名。

卷 I 讲完了 read从下一章开始,我们钻进 eval 里面 —— 那台机器的心脏。

第一站:环境。也就是「闭包到底捕获了什么」这个问题的真正答案。