20

我正在用 Haskell(GitHub 上的代码)编写一个 Lisp,作为了解更多关于这两种语言的一种方式。

我要添加的最新功能是宏。不是卫生的宏或任何花哨的东西——只是普通的代码转换。我的初始实现有一个单独的宏环境,与所有其他值所在的环境不同。在readeval函数之间,我穿插了另一个函数macroExpand,它遍历代码树并在宏环境中找到关键字时执行适当的转换,在最终表格被传递eval以进行评估之前。这样做的一个很好的优点是宏具有与其他函数相同的内部表示,这减少了一些代码重复。

不过,拥有两个环境似乎很笨重,如果我想加载一个文件,eval就必须访问宏环境以防文件包含宏定义,这让我很恼火。所以我决定引入一个宏类型,将宏与函数和变量存储在同一环境中,并将宏扩展阶段合并到eval. 起初我对如何做到这一点有点茫然,直到我想我可以写这段代码:

eval env (List (function : args)) = do
    func <- eval env function
    case func of 
        (Macro {}) -> apply func args >>= eval env
        _          -> mapM (eval env) args >>= apply func

它的工作原理如下:

  1. 如果您收到一个包含初始表达式和一堆其他表达式的列表...
  2. 评估第一个表达式
  3. 如果是宏,则将其应用于 arguments评估结果
  4. 如果它不是宏,则评估参数并将函数应用于结果

就好像宏和函数完全一样,只是 eval/apply 的顺序被切换了。

这是对宏的准确描述吗?通过以这种方式实现宏,我是否遗漏了一些重要的东西?如果答案是“是”和“否”,那么为什么我以前从未见过以这种方式解释的宏?

4

6 回答 6

21

答案是“不”和“是”。

看起来您已经从一个好的宏模型开始,其中宏级别和运行时级别位于不同的世界中。事实上,这也是Racket宏系统背后的要点之一。您可以在 Racket 指南中阅读一些关于它的简短文本,或者查看描述此功能以及为什么这样做是个好主意的原始论文。请注意,Racket 的宏系统是一个非常复杂的系统,而且它很卫生——但不管卫生如何,相分离都是一个好主意。总结主要优点,它可以始终以可靠的方式扩展代码,因此您可以获得单独编译等好处,并且您不依赖于代码加载顺序和此类问题。

然后,你进入了一个单一的环境,它失去了这一点。在大多数 Lisp 世界中(例如,在 CL 和 Elisp 中),事情就是这样完成的——显然,你会遇到上面描述的问题。(“显而易见”,因为相分离是为了避免这些问题而设计的,你只是碰巧以与历史上发生的顺序相反的顺序得到你的发现。)无论如何,为了解决其中一些问题,有一种eval-when特殊的形式,它可以指定在运行时或宏扩展时评估某些代码。在 Elisp 中,您可以通过eval-when-compile,但是在 CL 中,你会得到更多的头发,还有一些其他的“*-time”。(CL 也有阅读时间,与其他所有东西共享相同的环境会带来三倍的乐趣。)即使这看起来是个好主意,你也应该四处阅读,看看一些 lispers 是如何因为这种混乱而脱发的

在你描述的最后一步中,你甚至更进一步地发现了一些被称为 FEXPRs 的东西。我什至不会为此提供任何指示,您可以找到大量关于它的文本,为什么有些人认为这是一个非常糟糕的主意,为什么有些人认为这是一个非常好的主意。实际上,这两个“一些”分别是“最多”和“少数”——尽管 FEXPR 剩下的几个据点可以是有声的。翻译所有这些:它是爆炸性的东西……提出有关它的问题是引发长期激烈争吵的好方法。(作为最近严肃讨论的一个例子,你可以看到 R7RS 的初始讨论期,FEXPR 出现并导致了这些类型的火焰。)无论你选择坐在哪一边,有一点是显而易见的:具有 FEXPR 的语言与没有它们的语言截然不同。[巧合的是,在 Haskell 中进行实现可能会影响你的观点,因为你有一个地方可以去寻找一个理智的静态代码世界,所以“可爱”的超动态语言的诱惑可能更大......]

最后一点:既然你在做类似的事情,你应该研究一个在 Haskell 中实施方案的类似项目——IIUC,它甚至有卫生的宏。

于 2012-04-20T16:07:36.810 回答
16

不完全的。实际上,您已经非常简洁地描述了“按名称调用”和“按值调用”之间的区别;按值调用语言在替换之前将参数简化为值,按名称调用语言首先执行替换,然后进行归约。

主要区别在于宏允许您打破参照透明性;特别是,宏可以检查代码,因此可以区分 (3 + 4) 和 7,这是普通代码无法做到的。这就是为什么宏更强大也更危险的原因。如果大多数程序员发现 (f 7) 产生了一个结果而 (f (+ 3 4)) 产生了不同的结果,他们会感到不安。

于 2012-04-20T16:03:50.640 回答
6

背景杂乱无章

您所拥有的是非常晚的绑定宏。这是一种可行的方法,但效率低下,因为重复执行相同的代码会重复扩展宏。

从积极的方面来说,这对交互式开发很友好。如果程序员改变了一个宏,然后重新调用了一些使用它的代码,比如之前定义的函数,新的宏会立即生效。这是一种直观的“按我的意思去做”的行为。

在较早扩展宏的宏系统下,当宏发生变化时,程序员必须重新定义所有依赖于宏的函数,否则现有定义将继续基于旧的宏扩展,忽略新版本的宏.

一个合理的方法是让这个后期绑定宏系统用于解释代码,但一个“常规”(因为没有更好的词)宏系统用于编译代码。

扩展宏不需要单独的环境。它不应该,因为本地宏应该与变量在同一个命名空间中。例如,在 Common Lisp 中,如果我们这样做(let (x) (symbol-macrolet ((x 'foo)) ...)),内部符号宏会隐藏外部词法变量。宏扩展器必须知道变量绑定形式。反之亦然!let如果变量有一个内部x,它会影响一个外部symbol-macrolet。宏扩展器不能盲目地替换x正文中出现的所有事件。所以换句话说,Lisp 宏扩展必须知道宏和其他类型的绑定共存的完整词法环境。当然,在宏扩展期间,您不会以相同的方式实例化环境。当然,如果有(let ((x (function)) ..)(function)没有被调用,x也没有被赋予一个值。但是宏扩展器知道x在这个环境中有一个,所以出现的x不是宏。

因此,当我们说一个环境时,我们真正的意思是统一环境有两种不同的表现形式或实例化:扩展时间表现形式和评估时间表现形式。后期绑定宏通过将这两个时间合并为一个来简化实现,就像您所做的那样,但不必如此。

另请注意,Lisp 宏可以接受&environment参数。如果宏需要调用macroexpand用户提供的某些代码,则需要这样做。这种通过宏递归回到宏扩展器必须传递正确的环境,以便用户的代码可以访问其词法周围的宏并正确扩展。

具体例子

假设我们有这样的代码:

(symbol-macrolet ((x (+ 2 2)))
   (print x)
   (let ((x 42)
         (y 19))
     (print x)
     (symbol-macrolet ((y (+ 3 3)))
       (print y))))

这对打印的影响4,426。让我们使用 Common Lisp 的 CLISP 实现,并使用 CLISP 的特定于实现的函数来扩展它system::expand-form。我们不能使用常规的标准,macroexpand因为它不会递归到本地宏中:

(system::expand-form   
  '(symbol-macrolet ((x (+ 2 2)))
     (print x)
     (let ((x 42)
           (y 19))
       (print x)
       (symbol-macrolet ((y (+ 3 3)))
         (print y)))))

-->

(LOCALLY    ;; this code was reformatted by hand to fit your screen
  (PRINT (+ 2 2))
  (LET ((X 42) (Y 19))
    (PRINT X)
    (LOCALLY (PRINT (+ 3 3))))) ;

(首先,关于这些locally表单。为什么它们在那里?请注意,它们对应于我们有 a 的地方symbol-macrolet。这可能是为了声明。如果symbol-macrolet表单的主体有声明,它们必须限定在该主体, 并且locally会这样做。如果扩展symbol-macrolet没有留下这个locally包装,那么声明将有错误的范围。)

从这个宏展开你可以看到任务是什么。宏扩展器必须遍历代码并识别所有绑定构造(实际上是所有特殊形式),而不仅仅是与宏系统有关的绑定构造。

注意其中一个实例(print x)是如何被单独留下的:在 . 范围内的实例(let ((x ..)) ...)。另一个变成(print (+ 2 2))了,按照符号宏换x

我们可以从中学到的另一件事是宏扩展只是替换扩展并删除了symbol-macrolet形式。所以剩下的环境是原来的环境,减去在膨胀过程中被擦掉的所有宏观材料。宏扩展在一个大的“大统一”环境中尊重所有词汇绑定,但随后优雅地蒸发,只留下类似的代码(print (+ 2 2))和其他痕迹(locally ...),只有非宏绑定构造导致减少版本原来的环境。

因此,现在在评估扩展代码时,只有简化环境的运行时特性开始发挥作用。let绑定被实例化并填充初始值等。在扩展期间,这些都没有发生;非宏绑定只是在那里声明它们的范围,并暗示运行时未来的存在。

于 2012-04-20T21:45:10.627 回答
4

您缺少的是,当您将分析与评估分开时,这种对称性就会破坏,这是所有实际的 Lisp 实现所做的。宏观扩展将在分析阶段发生,因此eval可以保持简单。

于 2012-04-20T15:40:59.113 回答
2

我真的建议随身携带一些 Lisp 书籍。推荐例如Christian QueinnecLisp in Small Pieces。这本书是关于Scheme的实现的。

http://pagesperso-systeme.lip6.fr/Christian.Queinnec/WWW/LiSP.html

第 9 章是关于宏的:http: //pagesperso-systeme.lip6.fr/Christian.Queinnec/WWW/chap9.html

于 2012-04-20T18:15:31.650 回答
1

对于它的价值,Scheme R 5 RS 部分语法关键字的绑定结构有这样的说法:

Let-syntaxandletrec-syntax类似于letand letrec,但它们将句法关键字绑定到宏转换器,而不是将变量绑定到包含值的位置。

见:http ://www.schemers.org/Documents/Standards/R5RS/HTML/r5rs-ZH-7.html#%_sec_4.3.1

这似乎意味着应该使用单独的策略,至少对于syntax-rules宏观系统而言。


您可以在为宏使用单独“位置”的方案中编写一些......有趣的代码。在任何“真实”代码中混合同名的宏和变量没有多大意义,但如果您只是想尝试一下,请考虑来自 Chicken Scheme 的这个示例:

#;1> let
Error: unbound variable: let
#;1> (define let +)
#;2> (let ((talk "hello!")) (write talk))
"hello!"
#;3> let
#<procedure C_plus>
#;4> (let 1 2)
Error: (let) not a proper list: (let 1 2)

    Call history:

    <syntax>                (let 1 2)       <--
#;4> (define a let)
#;5> (a 1 2)
3
于 2012-04-20T18:39:26.330 回答