13 语言中支持去语法糖
关于去语法糖(desugaring),之前很多讨论都谈到、用到了,但是我们目前的去语法糖机制是薄弱的。实际上我们用两种不同的方式来使用去语法糖。一方面,我们用它来缩小语言:输入是一个大语言,去语法糖后得到其核心。另一方面,我们也用它来扩展语言:给定现有语言,为其添加新的功能。这表明,去语法糖是非常有用的功能。它是如此之有用,我们该思考一下如下两个问题:
- 我们创建语言的目的是简化常见任务的创建,那么,设计一种支持去语法糖的语言,它会长什么样子呢?请注意,这里的“样子”不仅仅指语法,也包括语言的行为特性。
- 通用语言常常被用作去语法糖的目标,那为什么他们不内建去语法糖的能力呢?比如说,扩展某个基本语言,添加上一个问题的答案所描述的语言。
本章我们将通过研究Racket提供的解决方案同时探索这两个问题。
13.1 第一个例子
DrRacket有个非常有用的工具叫做Macro Stepper(宏步进器),它能逐步逐步地显示程序的展开。你应该对本章中的所有例子尝试Macro Stepper。不过现在,你应该用#lang plai而不是#lang plai-typed来运行。
回忆一下,前文我们添加let
时,是将其当作lambda
的语法糖的。它的模式是:
(let (var val) body)
被转换为
((lambda (var) body) val)
思考题
如果这听起来不太熟悉,那么现在是时候回忆一下它是怎么运作的了。
描述这个转换最简单的方法就是直接把它写出来,比如:
(let (var val) body)
->
((lambda (var) body) val)
事实上,这差不多正是Racket语法允许你做的。
我们将其命名为
my-let
而不是let
,因为后者在Racket中已经有定义了。
(define-syntax my-let-1 ;定义语法
(syntax-rules () ;语法规则
[(my-let-1 (var val) body)
((lambda (var) body) val)]))
syntax-rules
告诉Racket,只要看到的某个表达式在左括号之后跟的是my-let-1
,就应该检查它是否遵循模式(my-let-1 (var val) body)
。这里var
,val
和body
是语法变量:它们是代表代码的变量,可以匹配该位置的任意表达式。如果表达式和模式匹配,那么语法变量就绑定为对应的表达式,并且在右边(的表达式中)可用。
您可能已经注意到一些额外的语法,如
()
。 我们稍后再解释。
右边(的表达式)——在这里是((lambda (var) body) val)
——就是最后的输出。每个语法变量都被替换(注意我们的老朋友,替换)其对应的输入部分。这个替换过程非常简单,不会做过多的处理。因此,如果我们尝试这么用
(my-let-1 (3 4) 5)
第一步Racket不会抱怨3出现在标识符的位置;相反,它会照常处理,去语法糖得
((lambda (3) 5) 4)
下一步会产生错误:
lambda: expected either <id> or `[<id> : <type>]'
for function argument in: 3
这就表明,去语法糖的过程在其功能上直截了当:它不会尝试猜测啥或者做啥聪明事,就是简单的替换重写而已。其输出是表达式,这个表达式也可以被进一步去语法糖。
前文中提到过,这种简单的表达式重写通常使用术语宏(macro)称呼。传统上,这种类型的去语法糖被称为宏展开(macro expansion),不过这个术语有误导性,因为去语法糖后的输出可以比输入更小(通常还是更大啦)。
当然,在Racket中,let
可以绑定多个标识符,而不仅仅是一个。非正式的写下这种语法的描述的话,比如在黑板上,我们可能会这样写,(let ([var val] ...) body) -> ((lambda (var ...) body) val ...)
,其中...
表示“零或更多个” ,意思是,输出中的var ...
要对应输入中的多个var
。同样,描述它的Racekt语法长的差不多就是这样:
(define-syntax my-let-2
(syntax-rules ()
[(my-let-2 ([var val] ...) body)
((lambda (var ...) body) val ...)]))
请注意...
符号的能力:输入中“对”的序列在输出中变成序列对了;换句话说,Racket将输入序列“解开”了。与之相对,同样的符号也可以用来组合序列。
13.2 用函数实现语法变换器
之前我们看到,my-let-1并不会试图确保标识符位置中的语法是真正的(即语法上的)标识符。用syntax-rules机制我们没法弥补这一点,不过使用更强大的机制,称为syntax-case,就可以做到。由于syntax-case还有很多其他有用的功能,我们分步来介绍它。
首先要理解的是,宏实际上是一种函数。但是,它并不是从常见的运行时值到(其他)运行时值的函数,而是从语法到语法的函数。这种函数执行的目的是创建要被执行的程序。注意这里我们说的是要被执行的程序:程序的实际执行可能会晚得多(甚至根本不执行)。看看去语法糖的过程,这点就很清楚了,很显然它是(一种)语法到(另一种)语法的函数。两个方面可能导致混淆:
syntax-rules
的表示中并没有明确的参数名或者函数头部,可能没有明确表明这是一个转换函数(不过重写规则的格式有暗示这个事实)。- 去语法糖指的是,有个(完整的)函数完成了整个过程。这里,我们实际写的是一系列小函数,每个函数处理一种新的语法结构(比如my-let-1),这些小函数被某个看不见的函数组合起来,完成整个重写过程。(比如说,我们并没有说明,某个宏展开后的输出是否还会进一步被展开——不过简单试一下就知道,事实确实如此。)
练习
编写一个或多个宏,以确定宏的输出会被进一步展开。
还有个微妙之处。宏的外观和Racket代码非常类似,并没有指明它“生活在另一个世界”。想象宏定义使用的是完全不同的语言——这种语言只处理语法——写就很有助于我们建立抽象。然而,这种简化并不成立。现实中,程序变换器——也被称为编译器(compiler)——也是完整的程序,它们也需要普通程序所需要的全部功能。也就是说我们还需要创立一种平行语言,专门处理程序。这是浪费和毫无意义的;因此,Racket自身就支持语法转换所需的全部功能。
背景说完了,接下来开始介绍syntax-case
。首先我们用它重写my-let-1(重写时使用名字my-let-3)。第一步还是先写定义的头部;注意到参数被明确写出:
<sc-macro-eg> ::= ;syntax-case宏,示例
(define-syntax (my-let-3 x)
<sc-macro-eg-body>)
x
被绑定到整个(my-let-3 ...)
表达式
你可能想到了,define-syntax
只是告诉Racket你要定义新的宏。它不会指定你想要实现的方式,你可以自由地使用任何方便的机制。之前我们用了syntax-rules
;现在我们要用syntax-case
。对于syntax-case
,它需要显式的被告知要进行模式匹配的表达式:
<sc-macro-eg-body> ::=
(syntax-case x ()
<sc-macro-eg-rule>)
现在可以写我们想要表达的重写规则了。之前的重写规则有两个部分:输入结构和对应的输出。这里也一样。前者(输入匹配)和以前一样,但后者(输出)略有不同:
<sc-macro-eg-rule> ::=
[(my-let-3 (var val) body)
#'((lambda (var) body) val)]
关键是多出了几个字符:#’
。让我们来看看这是什么。
在syntax-rules
中,输出部分就指定输出的结构。与之不同,syntax-case
揭示了转换过程函数的本质,因此其输出部分实际上是任意表达式,该表达式可以执行任何它想要进行的计算。该表达式的求值结果应该是语法。
语法其实是个数据类型。和其他数据类型一样,它有自己的构造规则。具体来说,我们通过写#’
来构造语法值;之后的那个s-expression被当作语法值。(顺便提一句,上面宏定义中的x
绑定的也是这种数据类型。)
语法构造器#’
有种特殊属性。在宏的输出部分中,所有输入中出现的语法变量都被自动绑定并替换。因此,比方说,当展开函数在输出中遇到var时,它会将var替换为相应的输入表达式。
思考题
在上述宏定义中去掉
#’
试试看。后果如何?
到目前为止,syntax-case似乎只是更为复杂的syntax-rules:唯一稍微好些的地方是,它更清楚地描述了展开过程的函数本质,同时明确了输出的类型,但其他方面则更加笨拙。但是,我们将会看到,它还提供了强大的功能。
练习
事实上,syntax-rules可以被表述为基于syntax-case的宏。请定义这个宏。
13.3 防护装置
现在我们可以回过来考虑到最初引致syntax-case的问题:确保my-let-3的绑定位置在语法上是标识符。为此,您需要知道syntax-case的一个新特性:每一条重写规则可以包含两个部分(如同前面的例子),也可以包含三个部分。如果有三个部分,中间那个被视为防护装置(guard):它是一个判断,仅当其计算值为真时,展开才会进行,否则就报告语法错误。在这个例子中,有用的判断函数是identifier?
,它能判定某个语法对象是否是标识符(即变量)。
思考题
写出防护装置,并写出包含防护(装置)的(重写)规则。
希望你发现了其中的微妙之处:identifier?
的参数是语法类型的。要传给它的是绑定到var的实际语法片段。回想一下,var是在语法空间中绑定的,而#’
会替换其中的绑定变量。因此,这里防护装置的正确写法是:
(identifier? #'var)
有了这些信息,我们现在可以写出整个规则:
<sc-macro-eg-guarded-rule> ::=
[(my-let-3 (var val) body)
(identifier? #'var)
#'((lambda (var) body) val)]
思考题
现在有了带防护的规则定义,尝试使用宏,在绑定位置使用非标识符,看看会发生什么。
13.4 Or:简单但是包含很多特性的宏
考虑or
,它实现或操作。使用前缀语法的话,自然的做法是允许or
有任意数目的子项。我们把or
展开为嵌套的条件(表达式),以此判断表达式的真假。
13.4.1 第一次尝试
试试这样的or:
(define-syntax (my-or-1 x)
(syntax-case x ()
[(my-or-1 e0 e1 ...)
#'(if e0
e0
(my-or-1 e1 ...))]))
它说,我们可以提供任何数量的子项(待会儿再解释这点)。(宏)展开将其重写为条件表达式,其中的条件是第一个子项;如果该项为真值,就返回这个值(待会再讨论这点!),否则就返回其余项的或。
我们来试一个简单的例子。这应该计算为真,但是:
> (my-or-1 #f #t)
my-or-1: bad syntax in: (my-or-1)
发生了什么?这个表达式变成了
(if #f
#f
(my-or-1 #t))
继续展开
(if #f
#f
(if #t
#t
(my-or-1)))
对此我们没有定义。这是因为,模式e0 e1 ...
表示一个或更多子项,但是我们忽略了没有子项的情况。
没有子项时应该怎么办?或运算的单位元是假值。
练习
为什么正确的默认值是
#f
?
我们可以通过加上这条规则,展示不止一条规则的宏。宏的规则是顺序匹配的,所以我们必须把最具体的规则放在最前面,以免它们被更一般的规则覆盖(尽管在这个例子中,两条规则并不重叠)。改进后的宏是:
(define-syntax (my-or-2 x)
(syntax-case x ()
[(my-or-2)
#'#f]
[(my-or-2 e0 e1 ...)
#'(if e0
e0
(my-or-2 e1 ...))]))
现在宏可以和预期一样展开了。虽然没有必要,但是我们加上一条规则,处理只有一个子项的情况:
(define-syntax (my-or-3 x)
(syntax-case x ()
[(my-or-3)
#'#f]
[(my-or-3 e)
#'e]
[(my-or-3 e0 e1 ...)
#'(if e0
e0
(my-or-3 e1 ...))]))
这使展开的输出更加简约,对后文中我们的讨论是有帮助的。
注意到在这个版本的宏中,规则不再是互不重叠的了:第三条规则(一个或多个子项)包含了第二条(一个子项)。因此,第二条规则与第三条不能互换,这是至关重要的。
13.4.2 防护装置的求值
之前说这个宏的展开符合我们的预期,是吧?试试这个例子:
(let ([init #f])
(my-or-3 (begin (set! init (not init))
init)
#f))
请注意,or返回的是第一个“真值”的值,以便程序员在进一步的计算中使用它。因此,这个例子返回init的值。我们期望它是什么?因为我们已经翻转了init的价值,自然而然的,我们期望它返回#t
。但是计算得到的是#f
!
这里的问题不在
set!
。比如说,如果我们在这里不放赋值,而是放上打印输出,那么打印输出就会发生两次。
要理解为何如此,我们必须检查展开后的代码:
(let ([init #f])
(if (begin (set! init (not init))
init)
(begin (set! init (not init))
init)
#f))
啊哈!因为我们把输出模式写成了
#'(if e0
e0
...)
当我们第一次写下它时,看起来完全没有问题,而这正表明了编写宏(或,其他的程序转换系统)时的一个非常重要的原则:不要复制代码!在我们的设定中,语法变量永远不应被重复;如果你需要重复某个语法变量,以至于它所代表的代码会被多次执行,请确保已经考虑到了这么做的后果。或者,如果只需要该表达式的值,那么绑定一下,接下来使用绑定标识符的名字就好。示例如下:
(define-syntax (my-or-4 x)
(syntax-case x ()
[(my-or-4)
#'#f]
[(my-or-4 e)
#'e]
[(my-or-4 e0 e1 ...)
#'(let ([v e0])
(if v
v
(my-or-4 e1 ...)))]))
这个引入绑定的模式会导致潜在的新问题:你可能会对不必要的表达式求值。事实上,它还会导致第二个、更微妙的问题:即使该表达式需要被求值,你可能在错误的上下文中对其求值了!因此,你必须仔细推敲表达式是否要被求值,如果是的话,只在正确的地方求一次值,然后存贮其值以供后续使用。
用my-or-4
重复之前包含set!
的例子,结果是#t
,符合我们的预期。
13.4.3 卫生
希望你现在觉得没啥问题了。
思考题
还有啥问题?
考虑这个宏(let ([v #t]) (my-or-4 #f v))
。我们希望其计算的结果是啥?显然是#t
:第一个分支是
#f
,但第二个分支是v
,v
绑定到#t
。但是观察展开后:
(let ([v #t])
(let ([v #f])
(if v
v
v)))
直接运行该表达式,结果为#f
。但是,(let ([v #t]) (my-or-4 #f v))
求值得#t
。换种说法,这个宏似乎神奇地得到了正确的值:在宏中使用的标识符名称似乎与宏引入的标识符无关!当它发生在函数中时,并不令人惊讶;宏展开过程也享有这种特性,它被称为卫生(hygiene)。
理解卫生的一种方法是,它相当于自动将所有绑定标识符改名。也就是说,程序的展开如下:
(let ([v #t])
(or #f v))
变成
(let ([v1 #t])
(or #f v1))
(注意到v一致的重命名为v1),接下来变成
(let ([v1 #t])
(let ([v #f])
v
v1))
重命名后变成
(let ([v1 #t])
(let ([v2 #f])
v2
v1))
此时展开结束。注意上述每一个程序,如果直接运行的话,都会产生正确的结果。
13.5 标识符捕获
卫生宏解决了语法糖的创造者常常会面对的重要痛点。然而,在少数情况下,开发人员需要故意违反卫生原则。回过来考虑对象,对于这个输入程序:
(define os-1
(object/self-1
[first (x) (msg self 'second (+ x 1))]
[second (x) (+ x 1)]))
(对应的)宏应该是什么样的?试试这样:
(define-syntax object/self-1
(syntax-rules ()
[(object [mtd-name (var) val] ...)
(let ([self (lambda (msg-name)
(lambda (v) (error 'object "nothing here")))])
(begin
(set! self
(lambda (msg)
(case msg
[(mtd-name) (lambda (var) val)]
...)))
self))]))
不幸的是,这个宏会产生以下错误:
self: unbound identifier in module in: self
;self: 未绑定的标识符
错误指向的是first方法体中的self。
练习
给出卫生展开的步骤,理解为何报错是我们预期的结果。
在正面解决该问题之前,让我们考虑输入项的一种变体,使绑定显式化:
(define os-2
(object/self-2 self
[first (x) (msg self 'second (+ x 1))]
[second (x) (+ x 1)]))
对应的宏只需要稍加修改:
(define-syntax object/self-2
(syntax-rules ()
[(object self [mtd-name (var) val] ...)
(let ([self (lambda (msg-name)
(lambda (v) (error 'object "nothing here")))])
(begin
(set! self
(lambda (msg)
(case msg
[(mtd-name) (lambda (var) val)]
...)))
self))]))
这个宏展开正确。
习题
给出这个版本的展开步骤,看看不同在哪里。
洞察其中的区别:如果进入绑定位置的标识符是由宏的用户提供的话,那么就没有问题了。因此,我们想要假装引入的标识符是由用户编写的。函数datum->syntax
接收两个参数,第一个参数是语法,它将第二个参数——s-expression——转换为语法,假装其是第一个参数的一部分(在我们的例子中,就是宏的原始形式,它被绑定为x)。为了将其结果引入到用于展开的环境中,我们使用with-syntax
在环境中进行绑定:
(define-syntax (object/self-3 x)
(syntax-case x ()
[(object [mtd-name (var) val] ...)
(with-syntax ([self (datum->syntax x 'self)])
#'(let ([self (lambda (msg-name)
(lambda (v) (error 'object "nothing here")))])
(begin
(set! self
(lambda (msg-name)
(case msg-name
[(mtd-name) (lambda (var) val)]
...)))
self)))]))
于是我们可以隐式的使用self了:
(define os-3
(object/self-3
[first (x) (msg self 'second (+ x 1))]
[second (x) (+ x 1)]))
13.6 对编译器设计的影响
在一个语言的定义中使用宏对所有其工具都有影响,特别是编译器。作为例子,考虑let
。let
的优点是,它可以被高效的编译,只需要扩展当前环境就行了。相比之下,将let
展开成函数调用会导致更昂贵的操作:创建闭包,再将其应用于参数,实际上获得的效果是一样的,但是花费更多时间(通常还要更多空间)。
这似乎是反对使用宏的论据。不过,聪明的编译器会发现这个模式老是出现,并会在其内部将左括号左括号lambda转换回let
的等价形式。这么做有两个好处。第一个好处是,语言设计者可以自由地使用宏来获得更小的核心语言,而不必与执行成本进行权衡。
第二个好处更微妙。因为编译器能识别这个模式,其他的宏也可以利用它并获得相同的优化;它们不再需要扭曲自己的输出,如果自然的输出恰好是左括号左括号lambda,将其再转化成let(否则就必须这么做)。比如说,在编写某些模式匹配(的宏)的时候,左括号左括号lambda模式就会自然的出现,而想要将其转换为let的话就必须多做一步——现在不必要了。
13.7 其他语言中的去语法糖
不仅仅是Racket,许多现代语言也通过去语法糖来定义操作。例如在Python中,for迭代就是语法模式。程序员写下for x in o
时,他
- 引入了新标识符(称之为i,但是,不要让其捕获了程序员定义的i,即,卫生的绑定i!),
- 将其绑定到从o获得的迭代器(iterator),
- 创建(可能)无限的while循环,反复调用i的.next方法,直到迭代器引发StopIteration异常。
现代编程语言中有许多这样的模式。