lotuc
首发于lotuc
Programming Languages: Application and Interpretation【译13】

Programming Languages: Application and Interpretation【译13】

审校: @lotuc

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


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)。这里varvalbody语法变量:它们是代表代码的变量,可以匹配该位置的任意表达式。如果表达式和模式匹配,那么语法变量就绑定为对应的表达式,并且在右边(的表达式中)可用。

您可能已经注意到一些额外的语法,如()。 我们稍后再解释。

右边(的表达式)——在这里是((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,但第二个分支是vv绑定到#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 对编译器设计的影响

在一个语言的定义中使用宏对所有其工具都有影响,特别是编译器。作为例子,考虑letlet的优点是,它可以被高效的编译,只需要扩展当前环境就行了。相比之下,将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异常。

现代编程语言中有许多这样的模式。

文章被以下专栏收录