第 2 部分 · 基础:用 Racket 写点什么
括号不是负担——Racket 的结构之美
Racket 满屏的括号不是语法噪音,而是一种把程序形态削减到极致的设计——同一套括号既是代码,也是数据,这就是同像性(homoiconicity)。
你第一次看到 Racket 代码,大概率会被满屏的括号劝退。(define (f x) (+ x 1)),为什么连定义一个“加一”函数都要包这么多层括号?
大多数语言的语法靠几十条规则堆起来:if 长这样、for 长那样、函数调用又是一副模样。Racket 反其道而行:它把程序的形态削减到只剩一种结构,却因此拿到了别的语言做不到的能力。这篇文章从最小的构件——一个能装两样东西的盒子——一步步走到那个能力跟前。
从一个能装两样东西的盒子开始
想象你要搭一个世界,希望里面的结构能随时增长、随时拆开,能串成链、能在链里继续嵌链,最后拼出树状甚至网状的形态。
这种需求更像乐高积木,而不是数组。数组是固定、紧凑、靠下标随机访问的容器;积木是小块之间互相拼接,结构由“怎么连”决定。于是 Racket 给出了最基础的构件:一个能容纳两个槽位的盒子。
这个盒子叫点对(pair),用 cons 造出来:
(cons 1 2)
;; '(1 . 2) 中间的句点说明它是一个点对,而不是列表
它不强调“能装多少东西”,而强调“能接着另一个结构继续组合”。正因为它太简单,才能拼成链表、树、语法结构,最后拼成程序本身。
列表不是发明,而是出现
把盒子一个接一个连起来,结尾用 null 收住,就成了一条链:
(cons 1 (cons 2 (cons 3 null)))
;; '(1 2 3)
这种结构天生适合递归,可以无限延伸,完全由“怎么连”定义,结尾用一个 null 收住。Racket 并没有先设计一条“列表规则”塞给你,而是当你把盒子一个接一个连起来时,列表这个概念自己冒了出来。
语法从结构里长出来
当链条里放的不是数字,而是“动作”和“参数”,它突然就变成了代码:
(+ 1 2)
;; 3
从结构的角度看,(+ 1 2) 就是一条链:+ 在最前,后面跟着 1、2,结尾是 null。换句话说,表达式只是一条特殊的列表。
于是嵌套调用,不过是嵌套的链条;条件分支,是分了叉的链条;整段程序,是一棵由链条长成的树。你不需要记“语法规则”,因为语法本身就是结构,它和列表是同一种东西。
代码也是数据:同像性的真正含义
这是 Racket 最神奇的地方。既然程序本质上是链条,而链条又是普通的数据结构,那么一段代码既可以被执行,也可以像列表一样被拿出来观察、分析、修改。代码和数据同形,这件事有个正式的名字——同像性(homoiconicity)。
给一段表达式加上 '(quote),它就不执行了,只把结构原样返回:
'(+ 1 2)
;; '(+ 1 2)
从这一刻起,你可以对它为所欲为。
想看它的第一项和剩下的项,用 car 和 cdr,和操作普通列表一模一样——因为它就是列表:
(car '(+ 1 2)) ;; '+ 操作符本身
(cdr '(+ 1 2)) ;; '(1 2) 参数们
想从一段字符串里读出代码,用 read 就够了,不需要写解析器:
(read (open-input-string "(+ 10 20)"))
;; '(+ 10 20)
在别的语言里解析 JSON 你得请一个库出场;Racket 解析自己,一个 read 搞定。想改这段代码,比如把里面所有数字翻倍,也只是普通的列表遍历:
(define (double-numbers expr)
(map (lambda (x)
(if (number? x) (* 2 x) x))
expr))
(double-numbers '(+ 1 2 3))
;; '(+ 2 4 6)
你以为自己在处理列表,实际上做的是代码重写。想反过来生成新代码,把一堆数字拼成一个加法表达式:
(define (make-sum numbers)
(cons '+ numbers))
(make-sum '(1 2 3 4))
;; '(+ 1 2 3 4)
最后,把生成的代码喂给 eval 真的运行一遍:
(eval '(+ 1 2 3) (make-base-namespace))
;; 6 make-base-namespace 提供求值所需的顶层环境
于是一段代码可以走完完整的一生:
结构 → 数据 → 变换 → 新代码 → 执行
在别的语言里,这套操作叫“元编程”,要请出编译器插件、AST、整套工具链。在 Racket 里,代码即数据不是一句口号,而是一个可以直接动手的事实。
拍平一段嵌套的加法
把前面的能力合起来看一个稍复杂的例子:把 (+ 1 2 (+ 3 4)) 自动改写成 (+ 1 2 3 4),把嵌套的子加法拍平进外层。几行就够:
(define (flatten-add expr)
(apply append
(map (lambda (x)
(if (and (list? x) (eq? '+ (car x)))
(cdr x) ; 子加法:抽出内部的 3 4
(list x))) ; 其它元素:原样包成单元素列表
expr)))
(flatten-add '(+ 1 2 (+ 3 4)))
;; '(+ 1 2 3 4)
没有解析器,没有 AST 访问者,因为表达式本身就是结构树。你处理的“列表”,就是别人眼里的“程序”。
结构之上生长语言
当修改结构变得轻而易举,“定义新的语法”就不再是黑魔法。Racket 的 when 看起来像一条新语法:
(when condition body ...)
但它本质上只是把结构改写成了你熟悉的样子:
(if condition
(begin body ...)
(void))
没有字符串替换,没有宏展开器插件,全程都在操作结构。于是领域特定语言(DSL)可以轻松实现,语言可以自己“长出”新的部分,语法不再是规则的堆砌,而是结构的转换。Racket 的宏系统之所以出名,不是因为宏本身厉害,真正的原因是:程序本身的形态非常适合被加工。
结构决定表达能力
回到最初的问题:为什么 Racket 的括号不是负担?
从“一个能装两样东西的盒子”开始,一切都自然展开:点对连成链条,链条被解释成列表,列表结构被当成表达式,表达式既能运行也能当数据,程序自己加工结构,于是能生成新的语言。整个 Racket 最终只剩下一条核心信念:
结构决定表达能力,而最简单的结构带来最大的自由。
它看似括号密密麻麻,语法却薄如纸片:没有特殊语法糖,没有隐式规则,表达式和数据结构合二为一,所有语言特性都能在语言内部实现。看透这一点,你会同意:Racket 不只是一门语言,而是一种构建语言的方式。
搭好了容器,下一篇 数据——原子与复合 看这套括号里到底能装些什么。