宏,中缀表达式,自定义语法与可编程的编程语言
本文同步发表于我的个人博客。
光剑后日谈 #2.
在 Scheme/Racket 的世界里,我们常说“一切代码皆为 S-expression”。一个函数调用是 (函数名 参数1 参数2),一个列表是 (元素1 元素2 元素3),它们都遵循着括号包裹、前缀表示的统一形式。
(这里有句话不知道为什么会导致无效请求。所以改了一下。去掉且仅仅去掉了一个加号。)
但细心的你可能会问,'(a b c) 或者 #'(1 stx) 呢?开头的那个 ' 或 #' 字符,怎么看也不像是列表的一部分。它们是如何融入这套 S-expression 体系的?
答案很简单:它们是一种语法糖。我们都知道 'exp 等价于 (quote exp),#'exp 等价于 (syntax exp)。这个单引号 ' 仿佛是一个别名,在 Racket 读取我们的代码时,就悄无声息地将它转换成了标准的形式。这种在“读取阶段”就生效的转换规则,被称为读取器宏(Reader Macro)。
读取器宏背后,是整个 Lisp 家族语言引以为傲的宏系统。宏是一种强大的元编程工具,它本质上是一个在编译期(或称为“展开时”)执行的函数。这个特殊的函数,它的输入是代码(以语法对象的形式),输出也是代码。它允许我们对代码进行任意复杂的、图灵完备的变换,从而彻底扩展语言本身的语法。
那么,亲手编写一个宏,究竟是怎样的体验呢?
宏之初体验:编写你的第一个宏 when
让我们从一个简单的需求开始。在编程中,我们经常遇到一个场景:“当某个条件成立时,执行一系列操作”。用 Racket 的 if 可以这样写:
(if (< user-level 5)
(begin
(display "权限不足!")
(log-warning "Attempted access by low-level user."))
#f)
每次都写 (begin ...) 有点繁琐(更何况 Racket 强制 if 有两个分支,那个不需要的分支混淆了语义)。如果我们能创造一个新语法 when,让代码变成下面这样,岂不是更清晰?
(when (< user-level 5)
(display "权限不足!")
(log-warning "Attempted access by low-level user."))
这个 when 无法用普通函数实现,因为函数会立即计算所有参数的值,而我们希望只有在条件为真时才执行后面的操作。这正是宏的用武之地。
在 Racket 中,我们可以用 define-syntax 和 syntax-rules 来定义简单的宏,它的工作方式就像“查找与替换”的模式匹配。
#lang racket
(define-syntax when
(syntax-rules ()
[(_ condition body ...)
(if condition
(begin body ...)
(void))]))
让我们来拆解这段神奇的代码:
(define-syntax when ...):我们声明,when不是一个函数或变量,而是一个新的语法。(syntax-rules () ...):这是一个简单宏的声明,()表示这个宏里没有特殊的关键字(暂不深究)。[(_ condition body ...)]:这是匹配模式。_(下划线) 是一个占位符,它能够匹配任意单个 S-expression 并忽略它。在这里,它会匹配宏调用中的第一个 S-expression,也就是宏的名字when本身。condition是一个模式变量,它会匹配when后面的第一个表达式。body ...是最巧妙的部分。...(省略号) 表示它会匹配接下来零个或多个表达式,并将它们收集到一个序列里。
(if condition (begin body ...) (void)):这是输出模板。- 它描述了目标代码的样子。
condition和body是上面的模式匹配部分捕获的模式变量。在一个真正的宏调用中,它们会被替换成模式中的实际内容。
- 它描述了目标代码的样子。
所以,当我们写下 (when (< user-level 5) (display "...") (log-warning "...")) 时,宏展开器会:
- 匹配到
condition是(< user-level 5)。 - 匹配到
body ...是(display "...")和(log-warning "...")两个表达式。 - 然后将它们填充到模板中,最终代码被转换为:
(if (< user-level 5) (begin (display "...") (log-warning "...")) (void))。
编译器真正看到的,永远是转换后的标准 Racket 代码。我们通过宏,成功地为语言添加了一个新的控制结构。
这就是 define-syntax 和 syntax-rules。它们提供了简单易用的构造新语法的能力。具体的用法是这样的:
(define-syntax name
(syntax-rules <special-words>
<clause1>
<clause2>
...))
syntax-rules 的每一个 clause(子句)形如 (pattern template)。用过模式匹配的朋友应该很熟,pattern 部分会匹配宏调用的模式,如果匹配成功就会对其中的模式变量进行绑定,然后将 template 部分中的模式变量替换为它们的值作为宏的输出。syntax-rules 展开时会从上到下依次尝试匹配每条子句,如果没有任何子句能够匹配就报错。syntax-rules 支持 (else ...) 这样的子句,else 能够匹配任何东西。
special-words 列表则指定一些特殊关键字,它们会被精确匹配,而不是作为模式变量名匹配并绑定任意值。你只需要把精确匹配关键字加到列表里就可以了,比如 (syntax-rules (keyword1 keyword2 ...) ...)。
对于只有一个子句的 syntax-rules,可以用 define-syntax-rule 这个语法糖进一步简化。
突然发现 Racket 官网上的文档非常好。那我就省去说明的口舌了,大家都去读文档吧。借助 genAI 会很轻松。
宏之意义:一门可编程的编程语言
通过 when 这个小例子,我们看到了宏如何让我们创造新的语法。但这只是冰山一角。这种“用代码生成代码”的能力,正是 Racket 被誉为“可编程的编程语言(Programmable Programming Language)” 的核心。
在上一篇文章 https://litjohn.github.io/posts/start-to-build-a-compiler/ 中,我们构建了一个源到源的编译器。其实,宏系统本身就是一个内嵌在语言里的微型编译器。它接受你用扩展语法写成的代码,然后把它转换成标准的 Racket 代码。
这赋予了我们极大的自由:
- 构建领域特定语言(DSL):当你在解决某个特定领域的问题时(如图形、数据库查询、并发控制),你可以利用宏创造一套专为此领域设计的语法,让代码读起来更像是对问题的直接描述。
- 抽象样板代码:将复杂的、重复的代码模式封装成一个简洁的宏。
- 创造新的语言范式:你甚至可以在 Racket 中用宏实现面向对象、逻辑编程等不同的编程范式。
Racket 著名的 #lang 机制,允许我们把整个文件切换成另一种“语言”,其背后也是宏系统的强大支撑。
宏之进阶:迈向更复杂的变换
syntax-rules 对于简单的模式匹配非常方便,但如果我们的语法转换需要更复杂的逻辑呢?比如,我们想在 Racket 中直接写中缀表达式:
(bin-exp 1 + (2 + 3 + 4) * 5 + 6)
我们希望 Racket 能正确理解运算符的优先级,将它转换为标准的前缀表达式 (+ (+ 1 (* (+ 2 3 4) 5)) 6)。
这个任务无法通过简单的模式匹配完成。我们需要一个真正的解析算法:遍历表达式,找到优先级最低的运算符(在这里是最后的 +),将其作为根节点,然后递归地处理左右两边的子表达式。这是一个计算过程。
为此,Racket 提供了更强大的宏工具 syntax-case。与 syntax-rules 不同,syntax-case 允许你在宏展开阶段执行任意的 Racket 代码来分析和构建语法。你可以编写辅助函数,在编译期对输入的语法对象进行遍历、判断和重组,最终生成目标代码。
实现 bin-exp 的过程,本质上就是在 Racket 的宏系统里,为中缀表达式这个“迷你语言”编写一个微型解析器。这完美地展示了宏的图灵完备计算能力。
当然这个转换的算法大概不用我说。各位身为 OIer 应该都会做这个入门题。
#lang racket
(begin-for-syntax
(define (find-last-add exp-vec l r)
(let loop ([i (sub1 r)])
(if (< i l)
#f
(if (eq? (vector-ref exp-vec i) '+)
i
(loop (sub1 i)))))))
(begin-for-syntax
(define (find-last-mul exp-vec l r)
(let loop ([i (sub1 r)])
(if (< i l)
#f
(if (eq? (vector-ref exp-vec i) '*)
i
(loop (sub1 i)))))))
(begin-for-syntax (define (transform exp-vec l r)
(if (= (add1 l) r)
(let ([tmp (vector-ref exp-vec l)])
(if (not (pair? tmp))
tmp
(let ([v (list->vector tmp)])
(transform v 0 (vector-length v)))))
(let ([add-pos (find-last-add exp-vec l r)])
(if add-pos
`(+ ,(transform exp-vec l add-pos) ,(transform exp-vec (add1 add-pos) r))
(let ([mul-pos (find-last-mul exp-vec l r)])
(if mul-pos
`(* ,(transform exp-vec l mul-pos) ,(transform exp-vec (add1 mul-pos) r))
(error "Invalid input!"))))))))
(define-syntax bin-exp
(lambda (stx)
(syntax-case stx ()
[(_ . exp)
(let ([exp-vec (list->vector (syntax->datum #'exp))])
(datum->syntax
#'exp
(transform
exp-vec
0
(vector-length exp-vec))))])))
注意上面的几个辅助函数都被 begin-for-syntax 包裹了。这是必要的,如果去掉会编译错误。因为宏和函数的存在时期不一样,函数存在于运行时,但那时宏早已不存在了。宏无法引用更晚才被定义的函数,所以需要使用这个语法。它就像 C++ 的 constexpr 能够标记一个函数在编译期求值一样,标记一个函数是给宏使用的辅助函数,存在于宏展开期。
以及注意我们寻找运算符是从右到左的。这是实现左结合性的必要操作。对于加法和乘法,左结合或右结合都无所谓,但是一旦引入减法,这就成为了一个必须要注意的细节。
一个有趣的问题:如果一个被标记为 begin-for-syntax 的函数需要用到一个宏呢?而如果这个函数和宏互相引用呢?
结论
我们从一个不起眼的单引号 ' 出发,通过亲手实现一个简单的 when 宏,窥见了 Racket 宏系统的基本工作原理。进而,我们理解了宏如何赋予我们创造 DSL、甚至重塑语言的能力,这是“可编程的编程语言”这一称号的底气所在。最后,我们看到了像中缀表达式解析这样的复杂任务,展示了宏系统深不可测的潜力。
Lisp/Scheme/Racket 的宏系统邀请我们从一个语言的“使用者”,转变为一个语言的“设计者”。它给予了我们工具,去打造最适合我们问题的语言。这是一种深刻而强大的编程思想。