Racket 编程入门

第 2 部分 · 基础:用 Racket 写点什么

符号表达式——Lisp 元编程的根基

Lisp 用一套括号写两样东西——程序,和程序操作的数据;这种“代码即数据”的语法叫符号表达式,是 Lisp 元编程的根基。

你在 Java 里写过 throw 抛异常,也在 Python 里写过 raise。换了个关键字,语义没变——你不会因此觉得哪种语言“更会抛异常”。多数时候,语法只是一层薄皮,包着底下的语义;换张皮,里子照旧。

于是有一个流行的信念:语法不重要,语义才重要。但偶尔,形式会咬合进实质。唐诗和宋词韵味不同,靠的不是题材,是格律;微博和博客承载的东西不同,靠的不是文字,是形态。形式是实质的寄托——要装下不同的实质,就得配上不同的形。

Lisp 之所以是一门不寻常的语言,靠的正是它不寻常的形:符号表达式(S-表达式,S-expression)。那么这套满屏括号的形,到底承载了什么别的语言装不下的实质?

一种括号,两样东西

先看一行 Racket 代码:

(+ 1 2)
;; 3

括号里第一个符号 + 是要执行的操作,后面是它的参数,求值结果是 3。这没什么稀奇,任何语言都能把两个数加起来。

稀奇的是这一行:

'(+ 1 2)
;; '(+ 1 2)

前面多了个 '。结果从 3 变成了 '(+ 1 2) 本身——一个长度为 3 的列表,三个元素分别是符号 +、数字 1、数字 2。同一串字符,加不加 ',Racket 看到的完全是两样东西:不加是“算给我看”,加了是“原样给我”。

这个 ' 后面跟着的列表,就是一个符号表达式。它既能当数据——一个普通的列表,能被 length、first、rest 翻来覆去地操作;也能当代码——交给求值器,它就真的跑起来。

(define expr '(+ 1 2))

(length expr) ;; 3 当数据:查长度
(first expr) ;; '+ 当数据:取第一个元素
(eval expr (make-base-namespace)) ;; 3 当代码:求值

同一段括号,是数据还是代码,只看你拿它做什么。数据和代码共用一张皮——这就是符号表达式最朴素的样子。

racket-sexp-code-as-data

quote:让代码停下来

为什么加了 ' 就不跑了?因为 Lisp 的求值规则里,列表天生是要“被执行”的:看到 (+ 1 2),解释器默认把第一个元素当操作、其余当参数去调用它。这是列表作为代码的默认身份。

quote 干的事就是踩一脚刹车:别求值,把它当数据原样交出来。一旦加了 ',那个列表就不再是“一次待执行的调用”,而是“一个静静躺在那里的对象”——刹车踩下去,代码就凝固成了数据。

这一脚刹车是整个 Lisp 元编程的地基。没有 quote,括号永远在跑、在算,你根本没法把“一段代码”拿在手里端详。有了 quote,你才能把代码像揉面团一样捏来捏去。

同像性:代码就是数据

“数据和代码共用一张皮”这件事,有个专门的名字:同像性(homoiconicity)。homo 是“同”,icon 是“像”——代码和数据长得一模一样。

大多数语言做不到这一点。在 Python 里,1 + 2 是源代码里的几个字符,程序跑起来后它变成字节码、变成 AST 节点;你想写一段 Python 去直接操作 1 + 2,得借助字符串解析、借助 ast 模块绕一大圈。代码是一种东西,操作代码的工具是另一种东西,两者隔着厚厚的翻译层。

Lisp 没有这层翻译。(+ 1 2) 这段代码,quote 一下就是 '(+ 1 2) 这个列表;这个列表的元素能取出来、能拼接、能替换;改完再交给 eval,它又变回代码跑起来。表示程序的数据结构,和程序本身,是同一个东西。

这才是符号表达式真正承载的实质:它让“写一段生成代码的代码”不再需要外挂的解析器或模板引擎——程序操作程序,用的就是程序本身的语言。这也正是 Lisp 当年成为人工智能研究首选语言的原因:AI 研究者要写的,恰恰是会生成、推理、改写程序的程序。元编程的字面意思,就是 programming about programming。

不是例外,是求值规则不同

讲到这里容易冒出一个疑问:那字面量呢?42、"hello" 看起来不像括号列表,它们算符号表达式吗?if、lambda 这些特殊形式呢?还有宏?

这里要澄清一个常见的混淆。符号表达式是个语法概念:要么是一个原子(数字、字符串、布尔值、符号),要么是原子组成的列表。从语法上看,42 是原子,(if a b c) 是列表,它们都是符号表达式——“一切皆为符号表达式”在语法层面成立,没有例外。

真正的区别不在“是不是符号表达式”,而在求值规则:

  • 42、"hello" 这类字面量是自求值的,求值结果就是它们自己。
  • (if a b c) 是特殊形式(special form),它不会把所有参数都先求一遍,而是先求条件,再按结果挑一个分支执行。规则特殊,但它依然是个列表。
  • (my-macro x) 是宏调用,它在程序跑起来之前就被展开成另一段代码。

形式上,它们都是括号或原子组成的符号表达式;不同的只是求值的时机和规则。把语法身份和求值行为分开看,“一切皆为符号表达式”才站得住。

宏:在编译期改写代码

quote 和 eval 给了你“运行时把代码当数据”的能力。但 Lisp 还有一手更狠的:在程序运行之前就改写代码——这就是宏。

Racket 里定义一个宏长这样:

(define-syntax swap!
 (syntax-rules ()
 [(_ a b)
 (let ([tmp a])
 (set! a b)
 (set! b tmp))]))

(define x 1)
(define y 2)
(swap! x y)
;; 此后 x 为 2,y 为 1

(swap! x y) 看起来像函数调用,但它不是。程序跑起来之前,编译器就把它原地替换成了下面那段 let。没有临时变量的语法噪音,读起来像语言自带的功能——而这是你自己加进去的。

这正是那句“不够用,就自己长”的真正含义:Lisp 不给你预留语法扩展位,它把语言的句法本身交到你手里。 而这一切之所以可能,根基还是符号表达式——因为代码就是列表,所以“写一个改写列表的函数”和“写一个改写代码的宏”,本质上是同一件事。

宏的完整写法、模式匹配的规则,是后面一整章的事。

符号表达式不是 Lisp 的某个语法选择,而是 Lisp 的本体:当代码与数据同形,语言就从“被人使用”变成了“能被改写”。