简介卫生宏

宏(macro)是Lisp语言进行元编程的手段,

它分为两种,读取宏(read macro)和编译宏(compilation macro)。

可以用来编写读取期和编译期运行的代码。


宏,事实上进行了代码的转换。

每一个宏都与一个转换器(transformer)相关联。

代码转换后,会替换到原来的位置。

这个过程,称为宏展开(expansion)。


区分读取期,编译期和运行期,是很重要的。

这是3个独立的阶段,

尤其在运行期,不存在任何未展开的宏。


我们使用“a -> b”来表示,表达式a展开为b。

而使用“a => b”表示,表达式a的值为b。


例子:

'a -> (quote a),其中“'”是一个读取宏。

读取宏可以将任意表达式转换为S表达式。


很多常见的表达式调用,其实是编译宏。

(and test1 test2) -> (if test1 test2 #f)

编译宏可以将一个S表达式转换为另一个S表达式。


我们通常说的宏,指的是编译宏。

Scheme语言暂时不支持自定义的读取宏。


定义一个宏

Scheme语言中使用define-syntax来定义宏,

定义一个宏,有时也称为绑定一个关键字(keyword binding)。

宏展开的结果,最终都转换成了一些Scheme语言内置的表达式。


例如:宏and是这样的定义的,

(define-syntax and

    (syntax-rules ()

        [(and) #t]

        [(and test) test]

        [(and test1 test2 ...)

            (if test1

                (and test2 ...)

                #f)]))


其中,(syntax-rules ...)的值是一个转换器。

define-syntax将关键字and和这个转换器相关联。


Scheme语言中有很多表达式可以返回转换器,

而syntax-rules是最常用的一个。

它内置了一个模板语言(pattern language)


syntax-rules后面紧跟的一个括号,里面可以设置辅助关键字。

至于辅助关键字,可以参考Scheme语言手册了解使用情况。


后续的每一个表达式,都具有“[模式,模板]”这样的结构。

对于[(and) #t]来说,

模式 = (and)

模板 = #t


模式和模板中的“...”是syntax-rules模板语言的一部分。

具体用法,也可以参考Scheme语言手册。


如果宏调用匹配了某个模式,就会按照相应模板展开。

下面3个宏调用,展开结果如下:

(and) -> #t

(and x) -> x

(and x y) -> (if x (and y) #f) -> (if x y #f)


宏是可以递归展开的,一直到结果表达式中不再含有宏为止。


模式中的第一个元素,因为肯定是宏的名字,

所以也可以替换成通配符“_”,

[(_) #t]

[(_ test) test]


卫生宏

Scheme是第一个支持卫生宏(hygienic macro)的Lisp方言。

也是第一个支持卫生宏的编程语言。


“卫生”这个词表示,宏展开后,不会污染原来的词法作用域。

我们还是举例来说明吧,最后,我们再总结规律。


例1:宏展开后,原表达式处于新的词法环境中。

(let-syntax [(insert-binding (syntax-rules ()

    [(_ x) (let [(a 1)]

        (+ x a))]))]

    

    (let [(a 2)]

        (insert-binding (+ a 3))))

=> 6


其中,let-syntax用来绑定局部关键字。

就像let可用来绑定局部变量一样。


在let-syntax表达式内部,我们定义了宏insert-binding。

它绑定到syntax-rules求值后得到的转换器上。


根据定义,我们知道(insert-binding x) -> (let [(a 1)] (+ x a))

原表达式x,处于含有新的绑定a => 1的词法环境中。


如果原表达式x中含有a,就出现问题了。

我们的例子就是这种情况。


(let [(a 2)]

    (insert-binding (+ a 3)))

->

(let [(a 2)]

    (let [(a 1)]

        (+ (+ a 3) a)))

=> 5


结果出错了。

从(+ a 3)所在的原始词法环境来看,

(let [(a 2)]

    (insert-binding (+ a 3)))

(+ a 3)中a的值,应该是2才对。

宏展开污染了原始的词法环境。


这是不“卫生”的。

Scheme通过给绑定的值改名字来实现卫生宏。


宏展开(insert-binding x) -> (let [(a 1)] (+ x a))

改成了(insert-binding x) -> (let [(:g0001 1)] (+ x :g0001))

其中,:g0001是语言实现生成的唯一名字,不会与任何已有的名字冲突。


(let [(a 2)]

    (insert-binding (+ a 3)))

->

(let [(a 2)]

    (let [(:g0001 1)]

        (+ (+ a 3) :g0001)))

=> 6


这样就得到了正确的结果。


例2:宏展开后,引入了不在原来词法作用域中的标识符。

(let [(a 1)]

    (let-syntax [(insert-free (syntax-rules ()

        [(_ x) (+ x a)]))]

        

        (let [(a 2)]

            (insert-free (+ a 3)))))

=> 6


同样根据定义,我们知道(insert-free x) -> (+ x a)


所以,

(let [(a 2)]

    (insert-free (+ a 3)))

->

(let [(a 2)]

    (+ (+ a 3) a))

=> 7


结果又出错了。

哪里出现问题了?


宏定义的模式/模板[(_ x) (+ x a)]))]中的a,应该是第一行的绑定,(let [(a 1)]

而展开式(let [(a 2)] (+ (+ a 3) a))覆盖了外层对a的绑定。


因此,宏展开式的行为,将取决于展开后的环境,

展开到不同的环境中,行为是不同的。

失去了宏调用的“引用透明性”。


Scheme是怎么解决的呢?

语言规范指出,宏展开式中的自由标识符,处于宏定义时的词法作用域中。

即,宏展开式(+ x a)中,a具有宏定义环境中的值a => 1,(insert-free x) -> (+ x 1)


(let [(a 2)]

    (insert-free (+ a 3)))

->

(let [(a 2)]

    (+ (+ a 3) 1))

=> 6


规律总结

我们遇到了一个问题。

我们知道,“+”在Scheme中表示加法函数,

它和a地位相同,也是一个变量,只不过它的值是一个函数。


那么,以上两个例子中,变量+的值分别来自哪个词法作用域呢?


例1中,

(let [(a 2)]

    (insert-binding (+ a 3)))

->

(let [(a 2)]

    (let [(:g0001 1)]

        (+ (+ a 3) :g0001)))


经过分析,我们知道了,

(+ (+ a 3) :g0001)))中第一个+来自宏定义处的词法作用域,

第二个+来自宏替换处的词法作用域。


例2中,

(let [(a 2)]

    (insert-free (+ a 3)))

->

(let [(a 2)]

    (+ (+ a 3) 1))


同样经过分析,我们知道了,

(+ (+ a 3) 1))中第一个+来自宏定义处的词法作用域,

第二个+来自宏替换处的词法作用域。


因此,

我们找到了一个规律,这也是卫生宏的目的所在。即,

宏展开式中的所有标识符,仍处于其来源处的词法作用域中。


我们试着分析一下这两个例子。

例1展开式,

(let [(a 2)]

    (let [(:g0001 1)]

        (+ (+ a 3) :g0001)))

粗体来源于宏定义处,普通字体来源于宏替换处。

展开后,它们仍然处于各自来源处的词法作用域中。


例2展开式,

(let [(a 2)]

    (+ (+ a 3) 1))

上述规则同样满足。


宏展开式中的标识符,虽然来源不同,但互不污染。

这就达到卫生宏的目的了。


结语

在Lisp编程中,宏展开后造成了非预期的污染,是经常出现问题的地方。

Common Lisp目前并不支持卫生宏,

但是可以实现自己的宏定义,用自己的宏来定义宏,达到简洁可控的目的。


当然,卫生宏也造成了表达能力的损失,

在特殊情况下,可以使用syntax-case以及datum->syntax来弥补。