【问题标题】:Why aren't lisp macros eagerly expanded by default?为什么默认情况下不急切地扩展 lisp 宏?
【发布时间】:2021-11-21 09:05:32
【问题描述】:

假设我有宏 foobar。如果我写(foo (bar)),我的理解是,在大多数(全部?)lisps 中foo 将被赋予'(bar),而不是bar 如果首先扩展它会扩展。 Some lisps 有类似 local-expand 的东西,foo 的实现可以在继续之前显式请求扩展其参数,但为什么不是默认值?这对我来说似乎更自然。这是历史的偶然,还是有充分的理由像大多数 lisps 那样做?

我一直在 Rust 中注意到我希望宏以这种方式工作。我希望能够在宏调用中包装一堆声明,以便宏可以抓取声明并生成反射信息。但是,如果我使用宏来生成我想要抓取的定义,我包装声明的宏会看到将生成声明而不是实际声明的宏调用。

【问题讨论】:

  • 对我来说,首先处理内部形式,然后是父宏,然后是宏引入的所有可能的子项,这看起来更复杂;通常,如果需要,您可以自己在宏中调用 macroexpand,或者像 SBCL 的 code walker 那样更具体的实现函数。我不确定我是否能找到详细讨论这个问题的档案w.r.t。不同的 lisp 版本
  • 使用现有方法,宏可以更好地控制其所有子表单,它还可以宏扩展代码本身,例如可以重用符号来表示其他含义,例如 lamda 或 defun,以便以不同方式解释代码(例如静态分析),并且递归宏​​扩展可以终止
  • @coredump hmm.. 以我描述的方式进行操作的一个动机是它会使宏不那么特别——它们在传递之前评估它们的参数,就像函数一样,只是它们的参数是仅在语法/宏扩展级别“评估”,而不是在运行时表达式级别。它们变得更像运行时函数,恰好采用编译时已知语法,作为优化,我们可以选择在编译时运行它们。但如果这是默认设置,您需要选择退出您描述的案例......
  • @coredump 就像在宏定义中声明一个您不想在传入语法之前扩展的表单列表,因为您打算特别对待它们

标签: macros scheme lisp common-lisp racket


【解决方案1】:

如果我写成 (foo (bar)),我的理解是,在大多数(所有?)lisps 中,foo 将被赋予 '(bar),而不是任何 bar 如果先扩展它会扩展成。

这将限制 Lisp,使得 (bar) 需要成为可以扩展的东西 -> 可能是用 Lisp 语言编写的东西。

Lisp 开发人员希望看到宏,其中内部的东西可以是具有不同语法规则的全新语言。例如,FOO 不会扩展它的子表单,而是将完全/部分不同的语言转换/编译到 Lisp。没有通常的前缀表达式语法的东西:

例子

(postfix (a b +) sin)

  -> (sin (+ a b))

这里宏形式的+不是中缀+。

(query-all (person name)
   where (person = "foo") in database DB)

Lisp 宏不适用于语言解析树,但可以用于任意的、可能是嵌套的 s 表达式。这些不需要是该宏之外的有效 Lisp 代码 -> 不需要遵循通常的语法/语义。

Common Lisp 具有MACROEXPANDMACROEXPAND-1 功能,这样外部宏可以在自己的宏扩展时扩展内部代码:

CL-USER 26 > (defmacro bar (a) `(* ,a ,a))
BAR

CL-USER 27 > (bar 10)
100

CL-USER 28 > (defmacro foo (a &environment e)
               (let ((f (macroexpand a e)))
                 (print (list a '-> f))
                 `(+ ,(second f) ,(third f))))
FOO

CL-USER 29 > (foo (bar 10))

((bar 10) -> (* 10 10))
20

在上面的宏中,如果FOO 只看到一个展开的表格,它就不能同时打印源和展开。

这也适用于作用域宏。这里 BAR 宏在本地重新定义,MACROEXPANDFOO 内为相同的表单生成不同的代码:

CL-USER 30 > (macrolet ((bar (a)
                          `(expt ,a ,a)))
               (foo (bar 10)))

((bar 10) -> (EXPT 10 10))
20

【讨论】:

    【解决方案2】:

    如果foo 是一个宏,那么(foo (bar)) 必须将原始语法(bar) 传递给foo 宏扩展器。这是绝对必要的。

    这是因为foo 可以赋予bar 任何意义。

    考虑defmacro 宏本身:

    (defmacro foo (bar) body)
    

    这里,参数(bar) 是一个参数列表(“macro lambda list”),而不是一个表单(待评估表达式的通用 Lisp 行话)。它说宏应该有一个名为bar 的参数。因此,在将(bar) 交给defmacro 的扩展器之前尝试扩展它是非常错误的。

    只有当我们知道一个表达式将被计算时,将它扩展为一个宏才是合法的。但是我们不知道作为宏参数的表达式。

    其他反例很容易想出。 (defstruct point (x 0) (y 0)):(x 0) 不是对运算符x 的调用,而是一个槽x,其默认值为0。(dolist (x list) ...):x 是一个要跨步的变量list

    也就是说,有关于宏扩展时间的实现选择

    Lisp 实现可以在评估或编译任何顶层表单之前对其进行宏扩展。或者它可以增量扩展,例如在处理(+ x y) 时,xy 被查看之前就已经被宏扩展并评估或编译为某种中间形式。

    Lisp 的纯语法树解释器始终保持代码的原始形式,并在评估时始终扩展(和重新扩展)代码,具有一定的交互性优势。您重写的任何宏都会立即在您输入到 REPL 的所有现有代码中“生效”,例如现有的函数定义。就执行速度而言,这显然是非常低效的,但是您调用的任何代码都使用宏的最新定义,而无需告诉系统重新加载该代码以使其再次扩展。这也消除了您正在测试的东西仍然基于您修复的某些宏的旧的、有缺陷的版本的风险。如果您曾经编写过 Lisp,请牢记扩展的时间选择范围,以便您有意识地拒绝不适合的选择。

    反过来,说,宏观扩张的时机有一些限制。可以想象,Lisp 解释器或编译器在处理整个文件时,可以遍历所有顶层表单并在处理其中任何一个之前一次展开所有表单。实现者很快就会发现这很糟糕,因为一些后期形式依赖于早期形式的副作用。比如,哦,正在定义宏!如果第一种形式定义了一个宏,第二种形式使用,那么我们不能在不评估第一种形式的效果的情况下扩展第二种形式。

    在 Lisp 中,将物理顶级形式拆分为逻辑形式是有意义的。假设有人编写(或使用宏生成)像(progn (defmacro foo ...) (foo)) 这样的代码。整个progn 在评估之前不能预先进行宏扩展;它行不通!必须有一条规则,例如“只要顶级表单基于 progn 运算符,那么 progn 运算符的子级将被所有处理特殊处理顶级表单的处理视为顶级表单,并且递归地应用此规则。”然后,宏扩展代码遍历器的顶级入口点必须包含特殊情况黑客来执行逻辑顶级表单的这种识别,将它们分解并递归到不再进行这些检查的较低级别扩展器.

    【讨论】:

      【解决方案3】:

      我一直在 Rust 中注意到我希望宏以这种方式工作。 我希望能够在宏中包装一堆声明 调用,以便宏可以抓取声明并生成 反射信息。

      听起来local-expand 确实是适合这项工作的工具。

      但是,另一种方法是这样的:

      假设wrapper 是我们的外部宏,并且预期的 语法是:

      (wrapper decl1 decl2 ...)
      

      其中decl 是一个可能使用某种标准形式declare 的声明。

      我们可以让 (包装 decl1 decl2 ...) 展开为

      (let-syntax ([declare our-declare])
         decl1 decl2 ... 
         (post-process-reflection-information))
      

      其中our-declare 是一个辅助宏,它既可以扩展为标准声明,也可以扩展为存储反射信息的某种形式, post-process-reflection-information 也是另一个宏 做任何需要的后期处理。

      【讨论】:

      • 这是一个不错的方法。不幸的是,特别是在 Rust 中,卫生规则阻止了这种情况,被包装的宏调用要么必须引用一个已经存在的宏,要么外部宏需要将它们换成不同的东西。如果外部宏只是扩展以提供它自己的包装宏定义,则用户的调用将不会引用它。
      【解决方案4】:

      我认为您正在尝试使用宏来解决它们并非旨在解决的问题。宏主要是一种文本/代码替换机制,在 Lisp 的情况下,这看起来很像一个简化的术语重写系统(另见 How does term-rewriting based evaluation work?)。对于如何替换代码模式以及替换顺序有不同的策略,但是在 C/C++ 预处理器宏、LaTeX 和 Lisp 中,该过程通常通过计算展开来完成,直到表单不再可展开,从最上面的项开始。这个顺序是很自然的,因为它有别于普通的求值规则,它可以用来实现普通求值规则不能实现的东西。

      在您的情况下,您有兴趣访问某个对象/类型的所有声明,这些声明属于自省/反射类别(正如您自己所说)。但是用宏实现反射/自省看起来并不完全可行,因为宏在抽象语法树上工作,这可能是访问所需元数据的一种糟糕方式。

      通常编译器会解析/分析结构定义并构建结构的权威、规范表示,即使有不同的语法表达方式;它甚至可以使用不能直接作为源代码获得的先验信息来计算更有趣的元数据(例如,如果你有继承,可能会有一组属性继承自另一个模块中定义的类型(我认为这不适用于 Rust) )。

      我认为目前 Rust 不提供编译时或运行时自省工具,这解释了为什么要使用宏路由。在 Common Lisp 中,宏绝对不用于自省,而是使用评估后(在不同时间)获得的实际值来获取有关对象的信息。例如,defclass 扩展为一组指令,在语言中注册一个类,但为了获得一个类的所有槽,你要求语言给你,例如:

      (defclass foo () (x))       ;; define class foo with slot X
      (defclass bar () (y))       ;; define class bar with slot Y
      (defclass zot (foo bar) ()) ;; define class zot with foo and bar as superclasses
      
      USER> (c2mop:class-slots (find-class 'zot))                                                                                                                                                   
      (#<SB-MOP:STANDARD-EFFECTIVE-SLOT-DEFINITION X>                                                                                                                                               
       #<SB-MOP:STANDARD-EFFECTIVE-SLOT-DEFINITION Y>)                                                                                                                                              
      

      我不知道您的问题的解决方案是什么,但除了其他答案之外,我认为这不是宏观系统的具体故障。如果将宏定义为通常仅作为术语重写系统完成,那么在语义级别上执行某些任务总是会遇到困难。但是 Rust 仍在不断发展,因此将来可能会有更好的方法来做事。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2023-03-23
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-05-13
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多