macro of scheme

intro

大家对宏(特指C/C++的宏)应该不陌生, 甚至被这个东西坑过无数次, 究其原因, C/C++的宏本质上只是文本替换而已, 没有任何语法层面的信息, 因而也就做不到类型检查语法检查这些事情; 而scheme的宏是真正工作在语法树上的, 甚至可以对其进行修改!

define-syntax

在R5RS以前, scheme通过define-macro来定义宏, 形式如下:

1
2
3
4
5
6
7
8
(define-macro (..)
; usually
`(..
,(eval-to-single-var)
,@(eval-to-list)
)
; so use it as **template**!
)

行为上其实和C/C++的宏是差不多的, 在transform阶段简单的做了token的替换, 看一个简单的例子:

1
2
3
4
5
6
7
8
9
(define-macro
myor
(lambda (a b)
`(let ([tmp ,a])
(if tmp #t ,b)
)
)
)
(let ([tmp #t]) (myor #f tmp)) ; oops, here gives #f, not expected

这个坑很明显, 和C/C++一样, 宏内部出现的名字tmp被实际使用宏的代码污染了. 不过聪明的scheme实现会提供一个gensym方法, 用来生成一个独一无二的名字, 好我们试试看改进版本:

1
2
3
4
5
6
7
8
9
10
(define-macro
myor
(lambda (a b)
(let ([t (gensym)])
`(let ([,t ,a]) ; what if `a` used variable named `t`? it's ok, `a` is lexical scoping
(if ,t ,t ,b)
)
)
)
)

虽然通过gensym解决了临时变量的名字问题, 但是依然没办法阻止用户污染其他名字, 比如重新定义if..于是我们意识到:

  1. 名字(binding)在上下文环境里的重要性
  2. 被定义时的环境和被执行的环境是两个完全不同的概念

回头来看define-macro的行为, 其实它的工作只是替换代码, 本身并不携带任何binding信息, 或者说, 它的binding信息来源于真正被执行的环境, 这个环境我们叫做动态作用域

那么, 显然在这种情况下, 我们需要一种能够拥有自己独立binding环境的宏. scheme在R5RS后提供了新的语法

define-syntax

从名字可以感觉出来, 这种宏应当是工作在语法层面, 形式大致如下

1
2
3
4
5
(define-syntax name
(syntax-rules ( reserved words )
((_ arg) .. deal with arg ..)
)
)
  • _表示macro本身的placeholder
  • syntax-rules则支持模式匹配

syntax-rules

模式匹配是一个很强大的功能, 可以很方便的定义一些含有递归结构的逻辑(scala中的pattern matching甚至能够一定程度上实现语义的匹配, 用起来也是非常顺手)

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; (let*
; ((..) (..))
; body
; )
(define-syntax let*
(syntax-rules
()
((_ ((p v)) b ...)
(let ((p v)) b ...)
)
((_ ((p1 v1) (p2 v2) ...) b ...)
(let ((p1 v1)))
(let* ((p2 v2) ...) b ...)
)
)
)

可以看到let*的定义非常简单, 甚至可以像普通方法一样进行递归定义

hygienic

读者可以尝试一下在let*中进行各种”名字污染”行为(比如重新定义let), 结果当然是可以正确执行. 因为define-syntax引入了hygiene macro(卫生宏)的概念, 即: 宏内部使用的binding信息来源于被定义时的环境, 而不受到运行环境的影响, 这也叫作referential transparency. 对应的, 我们称hygiene macro工作在词法作用域

这是不是意味有了卫生宏的特性我们不需要动态作用域的功能了呢? 也不尽然, 看一个场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(define-macro
show-vars
(lambda (. vars)
`(begin
(display
(list
; question about ',x and ,x?
; remember we are generating code here!
; ,x -> gives the name, just like we input `x` in repl, and `x` will be evaluated as variable
; ',x -> gives `'some-symbol`, so it is evaluated as symbol!
,@(map (lambda (x) `(list ',x ,x)) vars))
)
(newline)
)
)
)
(let ([i 1] [j 3]) (show-vars i j)) ; gives ((i 1) (j 3))

show-vars展示了当前环境下定义的变量的名字和内容, 而这是一个运行时的环境, 恰好define-macro能够做到, 这也是词法作用域和动态作用域的区别

syntax-case

scheme也提供了比syntax-rules更细粒度的语法控制能力(为什么这么说?), 其形式如下:

1
2
3
4
5
6
7
8
9
10
11
(define-syntax some-macro
(lambda (syntax-form)
(syntax-case syntax-form ()
[(_ pattern ...)
<fender>
<expr> ...
]
...
)
)
)

关于fender的概念(不过这里没有用到):

If the optional is present, it serves as an additional constraint on acceptance of a clause. If the of a given matches the input value, the corresponding is evaluated. If evaluates to a true value, the clause is accepted; otherwise, the clause is rejected as if the pattern had failed to match the value. Fenders are logically a part of the matching process, i.e., they specify additional matching constraints beyond the basic structure of the input.

对比一下两者的特点:

1
2
3
4
5
6
7
8
9
10
(define-syntax when
(syntax-rules ()
((_ test e e* ...)
(if test (begin e e* ...)))))
(define-syntax when
(lambda (x)
(syntax-case x ()
((_ test e e* ...)
#'(if test (begin e e* ...))))))
  • 都支持pattern matching
  • syntax-case的返回有#'前缀: 实际上被用来替换在pattern matching里被捕获的模式变量
  • (语法上看不出来的)syntax-case提供了拆解重组语法对象的能力, 即操作syntax-object的能力(什么是syntax-object? 我们先往下看)

datum & syntax object

比如我们想实现这样一个宏aif:

(aif (getuid) (display it) (display "none")), it是一个动态的binding, 显然aif需要工作在动态作用域

版本1
1
2
3
4
5
6
7
;; doesn't work
(define-syntax aif
(lambda (x)
(syntax-case x ()
((_ test then else)
#'(let ((it test))
(if it then else))))))

then else 都是syntax-object, 在syntax-form中作为模板变量被替换时仅仅保留了各自的词法上下文(lexical scope), 因此它们都不能访问it (因为在他们定义的环境中并没有it, 这也是referential transparency的体现)

版本2
1
2
3
4
5
6
7
8
;; doesn't work either
(define-syntax aif
(lambda (x)
(syntax-case x ()
((_ test then else)
(let ((it (datum->syntax x 'it))) `it`(1)
#'(let ((it test)) ; this `it`(2)
(if it then else))))))) ; and this `it`(2), both not references `it`(1) (there are diffrerent objects! or sth)
  • datum->syntax用于把一个symbol变成给定syntax-form中的syntax-object
  • #'内部只会替换在pattern match里被捕获的模式变量, 其他的名字则引用自定义该macro时的词法上下文

虽然通过datum->syntax引入了一层lexical scope, 但是请注意该scope是相对于x(即整个(aif ..)调用的syntax-object)来说的, 换句话说是aif调用的上下文(例如(let ([..]) (aif ..)), 则aif的上下文即let以及let的外层环境); 而在#'(let ((it test)) ..)'中的it仅仅是展开后的一个名字, 与datum->syntax引入的it并不是同一个东西, 尽管后者确实能被then/else引用到(如果有被定义的话, 而在这个例子里, 尽管它拥有可以被then/else访问的lexical scope, 但实际上环境里并没有定义它, 因而会出现unbound variable错误)
(hint: 在drracket里可以很方便的看到referencing的情况)

版本3
1
2
3
4
5
6
7
8
9
10
11
12
;; works, but is obtuse
(define-syntax aif
(lambda (x)
(syntax-case x ()
((_ test then else)
;; invoking syntax-case on the generated
;; syntax object to expose it to `syntax'
(syntax-case (datum->syntax x 'it) ()
; following `it` is not relevant with (datum->syntax ..), so you can name it `yy` sth..
(it
#'(let ((it test))
(if it then else))))))))

引入一层定义it的环境, 然后再通过syntax-case来捕捉到这个模式变量, 此时then/else所处的环境是在it被引入且定义的环境中:

为了方便说明, 使用yy替换

1
2
3
(yy ; yy 作为模式变量, 捕获了 (datum->syntax x 'it), 这个syntax-object在将要发生的调用中的名字就是it
#'(let ((yy test)) ; expansion后实际上变成了 ((it <test所代表的表达式>))
(if yy then else))) ; 同理这里的yy也变成了it, 而此时it在let的环境中, 可以被then/else所引用

所以实际上, 我们是在then/else调用时能访问到的环境中引入了it这个名字(并通过let定义); 假设我们不是通过syntax-case来捕获到yy, 那么在模板中yy仅仅是一个名字(就像我们在repl里直接输入yy), 显然在then/else的lexical socpe里当然引用不到yy.
由此可以知道syntax-object是一个存在于某个上下文环境(有意义)的名字!

aif的例子也可以看到, define-syntax也可以具有动态作用域的能力, 实际上我们想一下scheme里面为什么把syntax-rules/case叫做transformer? 因为它们能够:

  1. 通过pattern matching来捕捉syntax-object(是不是像在分析语法树?)
  2. 捕捉到的syntax-object可以被直接eval, 也可以被再次拆解/修改/引入新的syntax-object, 并且它们都属于当前操作的syntax-form的binding(是不是感觉像在编辑语法树?)
  3. 没有被捕捉到的对象则保持定义时的binding, 从而避免了污染问题