符号与连接
( thoughts-on :math )

Lisp 宏:代码即数据的终极表达

|数学 & 形式

同像性:Lisp 的根本洞察

1958 年,John McCarthy 在设计 Lisp 时做出了一个看似简单却极其深远的决定:程序的语法结构直接用列表来表示。一个 Lisp函数调用——比如 (+ 1 2)——既是一段可执行的程序,也是一个包含三个元素的列表。代码和数据之间没有语法层面的鸿沟。

这种特性被称为同像性(homoiconicity),源自希腊语 homo(相同)和 icon(表示)。其含义直截了当:程序的内部表示与程序员编写时使用的表示是同一个东西。你看到的代码就是解释器或编译器看到的结构——一棵嵌套的列表树,即 S-Expression(Symbolic Expression)。

这并非一个微小的语法选择。在大多数语言中,源代码首先要经过词法分析和语法解析,转化为抽象语法树(AST),然后才能被编译器处理。AST 是编译器内部的数据结构,程序员通常无法直接触及。而在 Lisp 中,源代码就是 AST。程序员与编译器之间的屏障消失了。

; 一段普通代码
(if (> x 0)
    (print "positive")
    (print "non-positive"))

; 它同时也是一个嵌套列表
; '(if (quote (> x 0)) (quote (print "positive")) (quote (print "non-positive")))

这个等价性是一切魔法的起点。当你可以像操作列表一样操作代码,你就获得了生成代码、变换代码、创造新语言的能力。这正是宏的基础。

S-Expression:统一的抽象

在深入宏之前,值得仔细审视 S-Expression 这一数据结构,因为它承载了 Lisp 的全部力量。

S-Expression 的定义极其简洁——只有两种基本形式:

  1. 原子(atom):数字、字符串、符号等不可再分的值
  2. 列表(list):由 S-Expression 组成的有序序列,用括号括起
42                    ; 原子:数字
"hello"               ; 原子:字符串
x                     ; 原子:符号
(+ 1 2)               ; 列表:包含三个原子
(+ (* 3 4) (- 10 2))  ; 嵌套列表

注意一个关键事实:(+ 1 2) 作为一个列表存在时,它只是一组符号。只有当 Lisp 的求值器(eval)处理它时,它才被"解释"为"将 1 和 2 相加"这个操作。求值规则同样简洁:

  • 原子求值为它自身的值(数字就是数字,符号查当前环境)
  • 列表求值时,第一个元素是操作符,其余是参数

求值是一种行为,不是一种属性。 同一个列表可以求值,也可以不被求值——这取决于上下文。正是这个"可以不被求值"的可能性,为宏打开了大门。

引用与求值:控制代码的生与死

Lisp 提供引用(quote)机制,让你能够阻止求值,将代码当作纯粹的数据来处理。

lisp
; 求值:返回 3
(+ 1 2)
 
; 不求值:返回列表 (+ 1 2)
(quote (+ 1 2))
; 简写形式
'(+ 1 2)

quote 是整个宏系统的基石。它让你能够"暂停"求值器,拿到代码本身——一个可以被检查、拆解、重组的列表。

但仅有 quote 还不够。宏需要做的是生成新的代码,这意味着你需要在"引用的代码模板"中嵌入"需要求值的表达式"。为此,Lisp 提供了准引用(quasiquote)机制:

lisp
; quasiquote(反引号):整体引用,但允许局部求值
; unquote(逗号):在引用内部标记"这里要求值"
; unquote-splicing(逗号-_at):在引用内部标记"这里要展开并拼接"
 
(let ((x 10)
      (items '(a b c)))
  `(the answer is ,x            ; ,x → 求值 x,得到 10
    and the items are ,@items)) ; ,@items → 展开 (a b c) 并拼接

反引号 ` 创建一个代码模板,逗号 , 在模板中打开一个"洞",让特定位置被求值后的结果填入,而逗号-@ 则会将一个列表"展平"拼接进模板。这三件套——反引号、逗号、逗号-@——构成了宏的模板语言。

宏与函数的本质区别

初学者常有疑问:宏能做的事,函数不是也能做吗?答案是否定的,差异深藏在求值时机中。

函数接收的是已经求值的参数

lisp
(defun double (x)
  (* x 2))
 
(double (+ 1 2))
; 求值过程:
; 1. 先求值 (+ 1 2) → 3
; 2. 将 3 传给 double
; 3. 执行 (* 3 2) → 6

接收的是未求值的代码本身(原始的 S-Expression),返回一段新的代码,然后再对这段新代码求值:

lisp
(defmacro double (x)
  `(* ,x 2))
 
(double (+ 1 2))
; 求值过程:
; 1. 拿到未求值的参数:x = (+ 1 2) 这个列表
; 2. 展开宏:(* (+ 1 2) 2)
; 3. 对展开后的代码求值 → 6

在这个简单的例子中,结果相同。但差异在更复杂的场景中变得至关重要——特别是当你需要控制是否求值、何时求值、求值几次的时候。

考虑条件执行:

lisp
; 如果用函数实现 when——这是错误的
(defun faulty-when (condition body)
  (if condition body))
 
; 问题:condition 和 body 在传入函数之前就已经被求值了!
; (faulty-when (> 1 2) (launch-missiles))
; → 先求值 (> 1 2) → nil
; → 先求值 (launch-missiles) → 导弹已经发射了
; → 然后才执行 if → 但为时已晚

函数无法延迟求值。而宏可以,因为宏操作的是代码,不是

lisp
(defmacro when (condition &body body)
  `(if ,condition
       (progn ,@body)))
 
; (when (> 1 2) (launch-missiles))
; 展开为:
; (if (> 1 2)
;     (progn (launch-missiles)))
; → (> 1 2) 为 nil
; → (launch-missiles) 不会被执行
; → 世界和平

这就是宏存在的根本理由:当你需要改变求值规则本身时,函数无能为力,只有宏能做到。

从简单到复杂:宏的五个层次

宏的能力可以按复杂度分为五个递进的层次。

第一层:控制流构造

最基础的宏用于创造语言中缺失的控制结构。除了上面的 when,再看一个 while 循环:

lisp
(defmacro while (condition &body body)
  `(do ()
       ((not ,condition))
     ,@body))
 
; 使用
(let ((i 0))
  (while (< i 10)
    (print i)
    (setf i (+ i 1))))

在 Lisp 中,do 是基本循环原语。while 不需要作为语言内置关键字存在——通过宏,程序员自己就能添加它。语言的能力边界不是由设计者决定的,而是由使用者决定的。

第二层:资源管理模式

许多编程任务遵循"获取资源→使用→释放"的模式。宏可以将这种模式封装得滴水不漏:

lisp
(defmacro with-file ((var path direction) &body body)
  `(let ((,var (open ,path :direction ,direction)))
     (unwind-protect
          (progn ,@body)
       (when ,var (close ,var)))))
 
; 使用
(with-file (f "/tmp/data.txt" :input)
  (read-line f))
; f 在 body 结束后自动关闭,即使发生错误

unwind-protect 确保清理代码无论是否发生异常都会执行。宏让使用者无需关心这个细节——它被编译进了展开后的代码。

第三层:指代宏(Anaphoric Macros)

指代宏是一种精巧的技巧:在宏展开的代码中隐式绑定一个变量,让后续代码可以直接引用它。

lisp
(defmacro aif (condition then &optional else)
  `(let ((it ,condition))
     (if it ,then ,else)))
 
; 使用
(aif (find-user id)
    (format t "Found: ~a" it)    ; "it" 绑定到 find-user 的结果
  (format t "Not found"))
 
; 对比不用 anaphoric macro 的写法
(let ((user (find-user id)))
  (if user
      (format t "Found: ~a" user)
    (format t "Not found")))

aif 将条件表达式的结果绑定到 it,省去了一个显式的 let。这种宏在实用主义者眼中是优雅的工具,在语言纯洁主义者眼中是危险的入侵——它引入了一个没有在源代码中声明的变量。这个争议本身就揭示了宏的微妙之处。

第四层:领域特定语言

当宏积累了足够的表达能力,你就可以用它来构建完整的领域特定语言(DSL)。下面是一个极简的单元测试框架:

lisp
(defmacro check (expr)
  `(report-result ,expr ',expr))
 
(defmacro test-suite (&body cases)
  `(progn
     ,@(loop for c in cases
             collect `(check ,c))))
 
(defun report-result (result expr)
  (format t "~a ... ~:[FAIL~;PASS~]~%" expr result)
  result)
 
; 使用——一个微型测试 DSL
(test-suite
  (= (+ 1 2) 3)
  (= (* 2 3) 6)
  (< 0 (length "hello")))

注意 test-suite 宏做了什么:它接收一系列断言表达式,将每个都包装成 check 调用,并且用 '(quote)把原始表达式保留下来作为输出信息的一部分。宏同时处理了代码的执行和代码的表示——这是只有"代码即数据"才能做到的事。

第五层:代码遍历与编译期计算

最高阶的宏会遍历和变换整棵代码树,在编译期完成复杂的计算:

lisp
(defmacro define-enum (&rest members)
  "从符号列表生成枚举类型及相关函数"
  (let ((counter -1))
    `(progn
       ,@(loop for m in members
               collect (let ((val (incf counter)))
                         `(defparameter ,(intern (format nil "*~a*" m)) ,val)))
       (defun enum-name (val)
         (case val
           ,@(loop for m in members
                   for i from 0
                   collect `(,i ',m))
           (t 'unknown))))))
 
(define-enum red green blue)
; 展开为:
; (defparameter *red* 0)
; (defparameter *green* 1)
; (defparameter *blue* 2)
; (defun enum-name (val) (case val (0 'red) (1 'green) (2 'blue) (t 'unknown)))
 
(enum-name 1) ; → green

这个宏在编译时从一组符号生成了一整套常量定义和查找函数。这就是元编程的本质:写一个程序来生成另一个程序

其他语言的"元编程":一场不公平的比较

Lisp 宏如此强大,那么其他语言的元编程机制又如何呢?这个比较能帮助我们理解宏的独特性。

C 预处理器:文本替换

c
#define MAX(a, b) ((a) > (b) ? (a) : (b))

C 的"宏"只是预处理阶段的文本替换。它对 C 的语法结构一无所知——没有类型信息,没有作用域,没有 AST 概念。经典的陷阱:

c
MAX(x++, y--)  // x 和 y 会被自增/自减两次

C++ 模板:图灵完备的类型计算

C++ 模板是编译期的一个独立的图灵完备语言,但它与 C++ 本身是两套不同的语法体系:

cpp
template<int N>
struct Factorial {
    static const int value = N * Factorial<N - 1>::value;
};

模板在类型层面进行计算,语法晦涩,错误信息出了名地难以解读。

Rust 宏:模式匹配的代码生成

rust
macro_rules! when {
    ($cond:expr { $($body:stmt);* }) => {
        if $cond { $($body);* }
    };
}

Rust 的 macro_rules! 基于模式匹配,比 C 预处理器高级得多,但它仍然是基于语法类别的匹配(exprstmtident),而非对 S-Expression 的自由操作。你无法像 Lisp 那样写一个遍历任意代码树并做全局变换的宏。

Python 装饰器:函数变换

python
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
 
@log_calls
def greet(name):
    return f"Hello, {name}"

装饰器本质上是高阶函数——接收一个函数,返回一个新函数。它很优雅,但它操作的是运行时的值(函数对象),而不是编译期的代码结构。装饰器无法创建新的控制结构,无法改变语法,无法做语法层面的变换。

Ruby 块与元编程

Ruby 可能是最接近 Lisp 元编程灵活性的主流语言——method_missingdefine_methodclass_eval 都允许运行时修改类和方法。但它的元编程仍然运行在对象和方法的层面,而非语法树的层面。

核心差异总结

机制操作对象操作时机能否创造新控制结构
C 预处理器文本预处理
C++ 模板类型编译期
Rust macro_rules!语法类别编译期有限
Python 装饰器函数对象运行时
Ruby 元编程类/方法运行时有限
Lisp 宏AST(S-Expression)宏展开期

Lisp 宏的独特之处在于:它操作的既是编译器能理解的结构化数据,又是程序员能直接读写的代码。这种双重身份是同像性赐予的礼物。

卫生宏的辩论

宏的强大力量也带来了一个经典问题:变量捕获(variable capture)。

lisp
(defmacro swap (a b)
  `(let ((tmp ,a))
     (setf ,a ,b)
     (setf ,b tmp)))
 
; 看似没问题,但如果这样用呢?
(let ((tmp 10))
  (swap tmp 20))
; 展开为:
; (let ((tmp 10))
;   (let ((tmp tmp))     ; 哪个 tmp 是哪个?
;     (setf tmp 20)
; (setf tmp tmp)))       ; 混乱

宏内部使用的 tmp 和用户代码中的 tmp 发生了冲突。这就是不卫生的宏——它引入的绑定可能会意外捕获用户代码中的同名变量。

Scheme 的 syntax-rulessyntax-case 系统提出了卫生宏(hygienic macros)的解决方案:宏展开器自动重命名宏内部引入的变量,确保它们不会与用户代码冲突。

Common Lisp 采取了不同的路线——非卫生宏,但提供了 gensym 函数来手动生成保证唯一的符号:

lisp
(defmacro swap (a b)
  (let ((tmp-var (gensym "SWAP-")))
    `(let ((,tmp-var ,a))
       (setf ,a ,b)
       (setf ,b ,tmp-var))))
 
(let ((tmp 10))
  (swap tmp 20))  ; 现在安全了

(gensym "SWAP-") 生成一个从未被使用过的唯一符号(如 SWAP-42),从根本上避免了命名冲突。

这两种哲学背后是不同的价值取向:

  • 卫生宏派认为宏应该像语言内置构造一样"干净",使用者不需要知道它是宏
  • 非卫生宏派认为程序员应该拥有完全的控制权,包括故意利用变量捕获的能力

这不是一个有标准答案的辩论。它反映了编程语言设计中一个永恒的张力:安全性与灵活性的权衡

自指与形式系统:一个更深的视角

将 Lisp 宏放在形式系统的背景下审视,会发现一个令人着迷的平行。

1931 年,Kurt Gödel 证明了他的不完备定理。其核心技巧是Gödel 编码——将形式系统中的每个公式映射为一个自然数,使得"关于公式的陈述"本身也能被编码为公式。换言之,形式系统获得了谈论自身的能力。

Lisp 的同像性实现了类似的功能。S-Expression 既是程序(被求值器执行的指令),又是数据(可以被其他程序处理的列表)。这意味着 Lisp 程序可以谈论和处理自身——就像 Gödel 的形式系统一样。

code $\longleftrightarrow$ data(同像性) $\implies$ self-reference

代码与数据互为表里,由此诞生自指。

在这个视角下,宏是 Lisp 的自省机制(introspection)——不是运行时的反射(像 Java 的 Class.getMethod()),而是编译期的、结构性的自省。宏读取程序的语法结构,变换它,生成新的程序。这是一种元层面的计算

McCarthy 在 1960 年的论文中实际上证明了更深刻的事:用仅仅几个基本操作符(atomeqcarcdrconscond)和一个自引用的 label,就能定义出一个通用的计算模型。Lisp 的 eval 函数——用 Lisp 自身编写的 Lisp 解释器——是这个自指特性的终极体现。

Alan Kay 曾说,Lisp 是"Maxwell's equations of software"——软件的麦克斯韦方程组。这不是夸张。就像麦克斯韦方程用几个简洁的等式统一了电学和磁学,Lisp 用 S-Expression 统一了代码和数据、语法和语义、元语言和对象语言。

代码即数据:从理论到 Cantos

本文开头提到的同像性,其影响远不止于编译器内部的数据结构。当"代码即数据"成为一种思维方式,它会改变你看待软件系统的角度。

传统软件将功能分为"引擎"和"数据"——引擎处理数据,数据被引擎处理。这是一种不对称的关系。Lisp 的洞见是:如果代码和数据是同一种东西,那么系统的行为就可以被当作数据来存储、传输、修改和生成。

这个原则在设计 Cantos.cn 时得到了回响:

  • S-Expression 导航(nav :math) 既是用户在 REPL 中输入的"命令",也是一个结构化的表达式,可以被解析、路由、扩展
  • REPL 作为一等公民:交互式求值器不是一个附加功能,而是网站的交互核心——它体现了"程序是活的、可被实时修改的"这一 Lisp 精神
  • 元系统的设计哲学:整个网站呈现为一个"动态运行的系统",而非静态的内容容器

这些设计选择的共同点是:打破"消费者"和"创造者"之间的界限。在大多数网站上,访客只能消费内容。在 Cantos,访客可以通过 REPL 与系统的运行时交互,表达计算,探索逻辑。这也许不能改变世界,但它忠实于一个信念——理解一个系统的最好方式,不是阅读关于它的描述,而是与它的运行时对话。

Lisp 宏教会我们的是:当工具的边界可以被使用者自由改写时,创造力的天花板就不再由工具决定,而是由想象力决定。

← Back to 数学 & 形式

( copyright-notice )

本文为原创内容,采用 CC BY-NC-SA 4.0 协议发布。转载请注明出处并附上原文链接。

End of Cantos