卷 I · 读CH 03深度 03/25

前缀记法:括号不是噪音,是你看得见的语法树

每个第一次见到 Lisp 的人都会说同一句话:「括号太多了。」这句抱怨完全正当 —— 但它抱怨错了对象。那些括号不是 Lisp 加上去的负担,是别的语言藏起来的东西。你的 Kotlin 编译器每天都在把你的代码变成一棵树,只不过它不给你看。Lisp 的做法是:直接让你写那棵树。这一章我要说服你:这笔交易,你赚了。

前缀记法S-表达式语法树运算符优先级

先承认:括号确实烦人

不给你灌鸡汤。刚开始写的时候,括号确实会把你搞疯。你会数错、会漏掉、会在删一行代码的时候连带删掉半个表达式。这个阶段是真实存在的,大概持续一到两周。

所以先把解药给你,省得你在接下来的二十二章里一直难受:

▶ 括号的三条解药(现在就用上)

① 不要数括号,看缩进。DrRacket 会自动缩进。当你缩进对了,括号必然是对的 —— 因为缩进就是编辑器根据括号算出来的。括号错了,缩进会立刻变得很丑,你一眼就能看见。老 Lisp 程序员从来不数括号,他们看形状。

② 不要手动敲右括号。DrRacket 会自动配对。更进一步:学会用 结构化编辑 —— 在 DrRacket 里,光标在括号上时按 Ctrl+左/右 可以整块地移动、Alt+( 可以把一个表达式包进一层括号。你操作的单位不再是字符,是子树。Emacs 里这套东西叫 paredit,VS Code 里叫 Calva/Parinfer。一旦用上,你会觉得在别的语言里编辑代码像在用石器。

③ 尾括号全部堆在一起,永远不换行。...)))),不要写成一行一个。这是全 Lisp 世界的铁律 —— 因为括号不携带视觉信息,缩进才携带。给括号单独一行,等于给噪音单独一行。

好,抱怨结束。现在说为什么这笔交易是划算的。

你的语言里有一张表,你其实背不下来

看这个 C 表达式:

a & b == c

它是 (a & b) == c 还是 a & (b == c)

答案是 a & (b == c)。因为在 C 里,== 的优先级高于 &。这个设计被公认为 C 语言的历史错误之一(Dennis Ritchie 本人后来承认这是个 bug,但当时已经有太多代码依赖它了,改不动)。它导致的真实 bug 数以万计,直到今天编译器还在为它发警告。

再来一个,这次是你天天写的:

// Kotlin
val x = a ?: b + c        // 是 a ?: (b + c) 还是 (a ?: b) + c ?
val y = !flag && other    // 是 (!flag) && other 还是 !(flag && other) ?
val z = 1 + 2 shl 3       // 中缀函数和 + 谁优先?

你可能答得出来(分别是 a ?: (b+c)(!flag) && other(1+2) shl 3)。但你答得出来,是因为你查过、背过、或者被坑过。

这就是中缀记法的隐藏成本:一张十几层的优先级表。Kotlin 的官方文法里,运算符优先级分了十几个层级。C 和 Java 也差不多。这张表没有任何逻辑可言 —— 它纯粹是历史约定,你只能背。而且每换一门语言,表就变一次(Python 的 ** 是右结合的,JS 的 ** 也是,但 -2 ** 2 在 JS 里直接是语法错误 —— 因为设计者知道这里太容易错,干脆禁了)。

而在 Racket 里:

(bitwise-and a (equal? b c))    ; 就是这个意思
(bitwise-and (bitwise-and a b) c)  ; 或者这个意思
; 没有第三种可能。没有歧义。没有表要背。
◆ 交易的第一条

你多打了几个括号,换掉了一整张优先级表。

而且换掉的不只是「背表」这件事 —— 是「这行代码到底怎么结合的」这个问题本身消失了。你不再需要在 code review 时盯着一个表达式思考三秒钟。结构是显式的,所以结构不需要被推断。

括号真的更多吗?我们数一数

直觉上 Lisp 括号多。但这个直觉有一半是错的 —— 因为你忘了算你自己语言里的括号。

// Kotlin
max(min(a, b), sqrt(c + d))
; Racket
(max (min a b) (sqrt (+ c d)))

数一下:Kotlin 6 个括号,Racket 8 个。多了两个 —— 因为 + 也要括号。就这样。

真正的区别不是数量,是位置:Kotlin 把括号写在函数名后面max(),Racket 写在前面(max)。一个字符的位移,换来的东西大得离谱 —— 我们马上就说。

那种「Lisp 括号铺天盖地」的印象,主要来自两个地方:一是深度嵌套的代码(但那种代码在任何语言里都难读,Lisp 只是诚实地把难读暴露出来了);二是你还没学会看缩进

关键的那件事:一切都是同一个形状

现在看这几行 Kotlin,注意它们的形状有多不一样:

1 + 2                        // 中缀
foo(1, 2)                    // 前缀 + 逗号
if (c) a else b              // 关键字 + 括号 + 关键字
for (x in xs) { ... }        // 又一套语法
val x = 1                    // 赋值语法
obj.method(arg)              // 点号语法
list[0]                      // 方括号语法
{ x -> x + 1 }               // lambda 语法
when (x) { 1 -> "a" }        // 又双一套语法

九种东西,九种形状。每一种都要单独学、单独记,编译器里也要单独写一段解析代码。

同样的九件事,在 Racket 里:

(+ 1 2)
(foo 1 2)
(if c a b)
(for ([x xs]) ...)
(define x 1)
(send obj method arg)
(list-ref lst 0)
(lambda (x) (+ x 1))
(match x [1 "a"])

九种东西,一种形状:(操作 参数 参数 …)

这不是「统一得很优雅」这种审美问题。这是一个有巨大后果的技术决定:

◆ 交易的第二条(这一条才是重点)

因为所有代码都是同一个形状,所以 —— 一个能处理这个形状的程序,就能处理所有代码。

你想写一个工具,把项目里所有的 (if c a b) 改写成 (unless (not c) b a)你不需要写解析器。你需要的只是一个能处理列表的函数 —— 而你已经会写了。

你想给语言加一个新的控制结构?你不需要改编译器。你只需要写一个函数,输入一个列表,输出另一个列表。

这就是宏。而它的全部前提,就是「一切都是同一个形状」这件事。如果 Kotlin 想要真正的宏,它必须先回答:我的宏要怎么表示 when 表达式、for 循环、lambda、字符串模板…… 每一种语法都要一个 AST 节点类型。而 Racket 的答案是:都是表。没有第二种类型。

亲眼看一眼那棵树

说了这么多,不如直接看。下面这台机器做的事,就是 Racket 拿到你的代码后做的第一件事 —— 它叫 read把文本读成一棵树。注意,它不求值,它只是读。

随便改左边的代码,包括故意漏一个括号。

盯着那棵树看一会儿,然后把这件事想清楚:

当你在 Kotlin 里写 max(min(a,b), c),编译器在内部也建了一棵一模一样的树。它有一个 CallExpression 节点,两个子节点,其中一个又是 CallExpression…… 这棵树一直都在。你只是从来没被允许碰它。

Lisp 唯一做的事,就是把这棵树的写法和读法统一了:你写下的括号结构,字面上就是那棵树。没有翻译,没有损失,没有中间层。

顺便说几个语法细节

方括号和圆括号是一回事

你会看到 Racket 代码里混用 ()[]

(let ([x 1]
      [y 2])
  (+ x y))

(cond
  [(> x 0) "正"]
  [(= x 0) "零"]
  [else    "负"])

它们在语义上完全等价 —— 读取器把 [( 处理(你可以在上面那个 demo 里试)。用 [] 纯粹是视觉惯例绑定和分支用方括号,函数调用用圆括号,这样一眼就能分清「这是在绑定变量」还是「这是在调用函数」。

这是 Racket 相对其他 Lisp 的一个小小的人体工程学改进,也是一个信号:这门语言的设计者是真的在意可读性的。

注释

; 单行注释(一个分号是惯例,两个 ;; 用于整行注释,三个 ;;; 用于章节)

#| 块注释
   可以跨行 |#

#;(这个表达式被整个注释掉了)   ; ← 注意!这是「注释掉下一个表达式」
                              ;   它是结构化的,不是文本的

#; 值得单独看一眼:它注释掉的不是「这一行」,是「下一个完整的 S-表达式」,不管那个表达式有多长、跨多少行。这是一个只有在「代码是树」的世界里才可能存在的注释方式 —— 它是结构化的。你的语言里没有对应物。

回流

⟲ 回流 · 去看看你自己语言的那棵树

这一章的核心是「代码是一棵树」。你的语言里那棵树也在,去看一眼,这是最直接的回报。

JavaScript / TypeScript:打开 AST Explorer(astexplorer.net),左边粘你的代码,右边就是 Babel 建出来的树。写 ESLint 规则、写 codemod、写 Babel plugin,全都是在这棵树上爬。你会立刻明白为什么 a + b 变成了 BinaryExpression{operator:"+", left:…, right:…} —— 那正是 (+ a b),只是穿了一件很贵的西装。

Kotlin:IntelliJ 里有 PSI Viewer(需要打开内部模式)。Kotlin 编译器插件、KSP,都是在 PSI / FIR 树上工作。

然后你会亲身体会到第 1 章题 8 的那个问题:为什么在 JS 里改一段代码要先 t.binaryExpression("+", t.identifier("a"), t.numericLiteral(1)) 这样一个节点一个节点地砌?因为你在用数据模拟代码。而在 Racket 里,你直接写 `(+ a 1) —— 因为代码本来就是数据。

下一章就讲这件事,它有个名字:同像性。

这一章的一句话

括号是你为「代码即数据」付的税,而这个税率低得离谱。

你付出的是:两周的不适应,和每行多打两个字符。

你买到的是:一张优先级表的消失、一个统一的程序表示、以及 —— 接下来五章要慢慢展开的 —— 把语言设计权拿到自己手里的能力。

下一章,我们去按下那个开关。它只有一个字符:'