宏,中缀表达式,自定义语法与可编程的编程语言

· · 科技·工程

本文同步发表于我的个人博客。

光剑后日谈 #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-syntaxsyntax-rules 来定义简单的宏,它的工作方式就像“查找与替换”的模式匹配。

#lang racket

(define-syntax when
  (syntax-rules ()
    [(_ condition body ...)
     (if condition
         (begin body ...)
         (void))]))

让我们来拆解这段神奇的代码:

  1. (define-syntax when ...):我们声明,when 不是一个函数或变量,而是一个新的语法
  2. (syntax-rules () ...):这是一个简单宏的声明,() 表示这个宏里没有特殊的关键字(暂不深究)。
  3. [(_ condition body ...)]:这是匹配模式
    • _ (下划线) 是一个占位符,它能够匹配任意单个 S-expression 并忽略它。在这里,它会匹配宏调用中的第一个 S-expression,也就是宏的名字 when 本身。
    • condition 是一个模式变量,它会匹配 when 后面的第一个表达式。
    • body ... 是最巧妙的部分。... (省略号) 表示它会匹配接下来零个或多个表达式,并将它们收集到一个序列里。
  4. (if condition (begin body ...) (void)):这是输出模板
    • 它描述了目标代码的样子。conditionbody 是上面的模式匹配部分捕获的模式变量。在一个真正的宏调用中,它们会被替换成模式中的实际内容。

所以,当我们写下 (when (< user-level 5) (display "...") (log-warning "...")) 时,宏展开器会:

编译器真正看到的,永远是转换后的标准 Racket 代码。我们通过宏,成功地为语言添加了一个新的控制结构。

这就是 define-syntaxsyntax-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 代码。

这赋予了我们极大的自由:

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 的宏系统邀请我们从一个语言的“使用者”,转变为一个语言的“设计者”。它给予了我们工具,去打造最适合我们问题的语言。这是一种深刻而强大的编程思想。