括号不是审美,是代价与红利
你翻开这本书的第一秒就在想这件事了:为什么全是括号?这一章给一个具体的回答——括号是一张票的价格,而票买到的东西,是「你的程序可以像处理数据一样处理自己的代码」。
在 Java 里,你能不能写一个方法,它接收一段 Java 代码,
把里面所有的 + 换成 *,然后运行改过的版本?
再问一句:在 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 提案), 全都没能流行起来。因为一旦加上,人们就发现自己付了括号的钱却没拿到票。
《解释》与《求值》:那两本书从零造一台解释器, 会让你对「源码 → 记号 → 语法树 → 值」这条流水线有肌肉记忆。 这一章可以看成它们的一个极端结论: 如果语法树用的就是语言自己的数据类型,那第三步和第四步之间就开了一道门, 而宏就是从那道门走进去的。
反过来说,如果你读过《求值》,这一章对你来说只是「原来 Clojure 把这道门做成了正门」。
开头那道题的答案是 C:在 Java 里,
语法树是编译器内部的东西,你的程序拿不到。
第二问:在 Clojure 里是 三个——read-string、
map(配一个 = 判断)、eval。
A 「字符串替换就行」——试试就知道:
字符串里的 +、注释里的 +、
++ 和 += 里的 +,全都会被你误伤。
「代码是文本」这个视角一定会在某处漏。
B 「引入解析器库」——这确实能做(JavaParser、Spoon 都存在), 而且这个选项几乎是对的。它和 C 的差别只有一个: 你需要一整套额外的类型体系去描述代码,而这套体系不是你写业务时用的那套。 Clojure 省掉的正是这一层。
D 「反射」——反射能看结构、能调用,但看不到方法体里的表达式, 更不能改。反射的粒度停在「类和成员」,进不到「语句和表达式」。
你进来时:括号是 Lisp 的历史包袱,忍一忍或者绕开。
你出去时:括号是一个明码标价的交易—— 牺牲一点书写习惯,换来「程序能处理自己的代码」。 划不划算取决于你用不用得上,而第 19、20 章会告诉你, 用得上的场合比你想的少,但每次都很关键。
卷 I 的三条结论原封不动:值不变、共用不复制、身份是一根线。 这一章只重建了一条路径:语法层面的疑虑。
这一章的一句话
括号的价格是书写习惯,买到的是「代码和数据是同一批类型」—— 于是处理数据的全部工具,立刻可以用来处理代码。
卷 I 到这里结束:你已经拿到了这本书的全部主线。
卷 II 开始是手上的活——四种括号各自什么脾气、为什么可以不建类、
解构怎么一行顶十行。第 5 章从一个小问题进去:
conj 往向量里加元素是加在末尾,往列表里加却是加在开头。
这不是不一致,是同一条规则在两种结构上的结果。