卷 I · 值不动CH 04深度 4/24

括号不是审美,是代价与红利

你翻开这本书的第一秒就在想这件事了:为什么全是括号?这一章给一个具体的回答——括号是一张票的价格,而票买到的东西,是「你的程序可以像处理数据一样处理自己的代码」。

代码即数据读取器前缀表示法

▷ 先猜一下

在 Java 里,你能不能写一个方法,它接收一段 Java 代码, 把里面所有的 + 换成 *,然后运行改过的版本?

A 能,用字符串替换就行 B 能,但要引入一个解析器库,再想办法编译运行 C 不能,Java 的语法树是编译器内部的东西,程序拿不到 D 能,反射就是干这个的

再问一句:在 Clojure 里做同一件事,需要几个函数?

先接受一件小事:函数名写在括号里面

在动机之前,先把语法这一关过了。它只有一条规则:

你写的            Clojure 写的
────────────────────────────────────
add(1, 2)         (add 1 2)
1 + 2             (+ 1 2)
obj.foo(x)        (foo obj x)
if (a) b else c   (if a b c)

就这一件事:把左括号往左边挪一格,把逗号删掉add(1, 2) 变成 (add 1 2)。仅此而已。

这么写有个立刻兑现的好处:+ 不再是特殊的语法,它就是个普通函数, 于是它可以接受任意多个参数,也可以被当成值传来传去。

(+ 1 2 3 4 5)        ;; => 15
(apply + [1 2 3])    ;; => 6      把 + 当成一个值传给 apply
(reduce + 0 [1 2 3]) ;; => 6

在 Java 里 + 是语法,你没法把它传给一个方法; 你得写 Integer::sum 这种替身。这是一个小便利, 但它预告了这一章真正的主题:Clojure 里「特殊的东西」特别少

括号真正买到的是什么

现在说代价与红利。

你写 Java 时,源码是文本。编译器把它解析成语法树, 而那棵树是编译器的私产——它用的是编译器内部的类, 你的程序碰不到,也没法构造。你想改一段代码再跑, 要么玩字符串替换(脆弱得可笑),要么请一整个编译器进来。

Clojure 的源码读进来之后就是普通数据。 不是「类似数据的语法树对象」,是你程序里天天用的那些列表、向量、符号、关键字。

(read-string "(+ 1 2)")
;; => (+ 1 2)

(type (read-string "(+ 1 2)"))
;; => clojure.lang.PersistentList        ← 就是个列表

(first (read-string "(+ 1 2)"))
;; => +
(type (first (read-string "(+ 1 2)")))
;; => clojure.lang.Symbol                ← 就是个符号

(count (read-string "(+ 1 2)"))
;; => 3                                  ← 就是个长度 3 的列表

所以「把所有 + 换成 *」这件事,用的是普通的集合函数:

(defn swap-op [form]
  (if (seq? form)
    (map swap-op form)                    ;; 递归处理每个子形式
    (if (= form '+) '* form)))            ;; 就是个 = 判断

(swap-op (read-string "(+ 1 (+ 2 3))"))
;; => (* 1 (* 2 3))

(eval (swap-op (read-string "(+ 1 (+ 2 3))")))
;; => 5

没有解析器库,没有访问者模式,没有 instanceof BinaryExpressionNode。 就是 map=if——你处理任何一坨嵌套数据时用的那些。

下面这台 demo 是真的读取器。改成任意 Clojure 代码, 它会告诉你读出来的每一层是什么类型,然后当场改掉一个节点再跑:

◆ 可以带走的判断

括号的代价:你要多打几个括号,眼睛要适应。
括号买到的:代码和数据是同一批类型,所以处理数据的全部工具立刻可以用来处理代码。 这就是第 19 章那些宏能存在的唯一原因。

为什么必须是括号

你可能会问:JSON 也是数据啊,为什么不用 JSON 写代码?

因为「代码是数据」要成立,需要一个更强的条件: 这门语言的全部语法,都能用这门语言的少数几种数据结构表示出来。

Java 有几十种语法结构:类声明、方法声明、for、while、switch、 三元表达式、lambda、注解、泛型边界……每一种都有专门的语法, 于是也需要专门的语法树节点类型。你想写个程序改 Java 代码, 就得认识这几十种节点。

Clojure 只有:列表、向量、字典、集合,加上符号、关键字、数字、字符串。 就这些。if 是列表,defn 是列表,函数调用是列表—— 全都是同一种东西,处理起来只有一套代码。

这门语言里「特殊语法」的数量
──────────────────────────────────────────
Java          几十种(每种都要单独学、单独处理)
Clojure       一个:(f a b) —— 括号里第一个是操作,后面是参数
              加上十来个「特殊形式」(def / fn / if / let …)

括号的用处,就是把这唯一的一种结构标出来。 前缀写法(操作在最前面)则保证了每个括号的第一个位置永远是「要做什么」, 不用考虑优先级——所以 Clojure 没有运算符优先级表, 因为它没有中缀运算符。(+ 1 (* 2 3)) 的结构一眼看死,不可能有歧义。

顺手学会读它

这本书后面全是这种代码,花两分钟把这几个记号认了,后面就不卡了:

;; 这是注释

(f a b)        ;; 调用 f,参数 a b
[1 2 3]        ;; 向量(≈ ArrayList,但是值)
{:a 1 :b 2}    ;; 字典(≈ HashMap,但是值)。:a 是键
#{1 2 3}       ;; 集合
'(1 2 3)       ;; 引号:不要求值,把它当数据

:keyword       ;; 关键字。像枚举常量,同时也是「取这个键」的函数
"string"       ;; 字符串
nil            ;; null
\a             ;; 字符

(def x 1)              ;; 定义
(defn f [a b] (+ a b)) ;; 定义函数,[a b] 是参数表
(let [x 1] (+ x 2))    ;; 局部绑定
#(* % %)               ;; 匿名函数简写,% 是参数
@x                     ;; deref:取出 atom 此刻的值

有一个记号值得单独说:关键字既是常量,也是函数

(:name {:name "Ada" :age 36})     ;; => "Ada"
;; 读作:拿 :name 这把钥匙,去这个 map 里开一下

(map :name [{:name "Ada"} {:name "Alan"}])
;; => ("Ada" "Alan")               ← 直接当函数用,不用写 lambda

这个小设计在第 6 章会大放异彩:因为取字段这么便宜, 「不建类,直接用 map」才变得可行。

▸ 在现实里

你其实已经受益于「代码即数据」很多年了,只是在别的语言里它以受限的形式出现:

  • SQL 的查询计划是数据,所以数据库能改写你的查询(谓词下推、连接重排)。 如果 SQL 只是一串文本,优化器就无从下手。
  • Kotlin 的编译器插件 / 注解处理器能生成代码, 但它们跑在编译器里,用的是编译器的 AST API, 而且你得学一整套单独的东西(KSP、IR)。红利是一样的,票价高得多。
  • 构建脚本:Gradle 的 Groovy/Kotlin DSL 之所以强, 是因为构建配置就是代码。而 Maven 的 XML 之所以憋屈, 是因为配置只是数据,没法执行——这是同一枚硬币的两面。
✗ 这个直觉是错的

「括号是为了让编译器好写。Lisp 是六十年代的语言, 那时候解析器技术不发达,所以只能用这么原始的语法。」

因果反了。写解析器在今天是本科作业, Lisp 保留这个语法不是因为解析难,是因为它要保住「代码 = 数据」这个性质。 一旦引入中缀运算符和多种语法结构,代码就不再是那几种数据结构了, 宏也就无从谈起。

证据:从 Lisp 之后,有大量语言尝试给它加上「正常语法」 (比如 Dylan,比如 Lisp 自己的 M-expression 提案), 全都没能流行起来。因为一旦加上,人们就发现自己付了括号的钱却没拿到票。

◇ 另存一版

开头那道题的答案是 C:在 Java 里, 语法树是编译器内部的东西,你的程序拿不到。
第二问:在 Clojure 里是 三个——read-stringmap(配一个 = 判断)、eval

A 「字符串替换就行」——试试就知道: 字符串里的 +、注释里的 ++++= 里的 +,全都会被你误伤。 「代码是文本」这个视角一定会在某处漏。

B 「引入解析器库」——这确实能做(JavaParser、Spoon 都存在), 而且这个选项几乎是对的。它和 C 的差别只有一个: 你需要一整套额外的类型体系去描述代码,而这套体系不是你写业务时用的那套。 Clojure 省掉的正是这一层。

D 「反射」——反射能看结构、能调用,但看不到方法体里的表达式, 更不能改。反射的粒度停在「类和成员」,进不到「语句和表达式」。

你进来时:括号是 Lisp 的历史包袱,忍一忍或者绕开。

你出去时:括号是一个明码标价的交易—— 牺牲一点书写习惯,换来「程序能处理自己的代码」。 划不划算取决于你用不用得上,而第 19、20 章会告诉你, 用得上的场合比你想的少,但每次都很关键。

卷 I 的三条结论原封不动:值不变、共用不复制、身份是一根线。 这一章只重建了一条路径:语法层面的疑虑

这一章的一句话

括号的价格是书写习惯,买到的是「代码和数据是同一批类型」—— 于是处理数据的全部工具,立刻可以用来处理代码。

卷 I 到这里结束:你已经拿到了这本书的全部主线。 卷 II 开始是手上的活——四种括号各自什么脾气、为什么可以不建类、 解构怎么一行顶十行。第 5 章从一个小问题进去: conj 往向量里加元素是加在末尾,往列表里加却是加在开头。 这不是不一致,是同一条规则在两种结构上的结果。