lisp:何时使用函数与宏

lisp: when to use a function vs. a macro

在我不断学习 lisp 的过程中,我 运行 遇到了一个概念性问题。它有点类似于问题 here,但从主题上讲,我的问题是一个抽象级别以上的问题也许在主题上是合适的。

通常情况下,您应该在什么时候创建宏而不是函数?在我看来,也许天真地,在极少数情况下您 必须 创建一个宏而不是一个函数,并且在大多数剩余情况下,一个函数通常就足够了。在这些剩余情况中,宏的主要附加值似乎是语法清晰。如果是这样的话,那么似乎不仅是选择使用宏的决定,而且它们的结构设计对于单个程序员来说可能从根本上是特殊的。

这是错误的吗?是否有一般情况概述何时在函数上使用宏?我是对的,语言需要宏的情况通常很少吗?最后,宏是否有一种通用的语法形式,或者它们通常被程序员用作速记?

是的,第一条规则是:不要在可以使用函数的地方使用宏。

有些事情您不能用函数来做,例如代码的条件求值。其他人变得很笨重。

总的来说,我知道三个重复使用宏的用例(这并不意味着没有其他用例):

  • 定义表单(例如 defundefmacrodefine-frobble-twiddle

    这些通常需要获取一些代码片段,将其包装(例如以 lamdba 形式),然后将其注册某处,甚至可能是多个地方。用户(程序员)应该只关心代码片段。因此,这主要是关于删除样板文件。此外,宏可以处理正文,例如。 G。注册文档字符串、处理声明等

    示例:假设您正在编写一种事件微型框架。您的事件处理程序是接受一些输入并生成效果声明的纯函数(想想 Clojure 世界的重新构建)。您希望这些函数是普通的命名函数,这样您就可以使用通常的测试框架测试它们,还可以在事件循环机制的查找 table 中注册它们。您可能想要 define-handler 宏之类的东西:

    (defvar *handlers* (make-hash-table)) ; internal for the framework
    
    (defmacro define-handler (&whole whole name lambda-list &body body)
      `(progn (defun ,@(rest whole))
              (setf (gethash ,name *handlers*)
                    (lambda ,lambda-list ,@body))))  ; could also be #',name
    
  • 控制构造(例如casecondswitchsome->

    这些使用条件评估和方便的表达式重新排列。

  • With- 样式包装器

    这是为某些任意资源提供 unwind-protect 功能的习惯用法。与一般 with 构造(如在 Clojure 中)的区别在于资源类型可以是任何东西,您不必使用 Closable 接口之类的东西来具体化它。

    示例:

    (defmacro with-foo-bar-0 (&body body)
      (let ((foo-bar (gensym "FOO-BAR")))
        `(let (,foo-bar))
           (shiftf ,foo-bar (aref (gethash :foo *buzz*) 0) 0)
           (unwind-protect (progn ,@body)
             (setf (aref (gethash :foo *buzz*) 0) ,foo-bar)))))
    

    这会将嵌套数据结构中的某些内容设置为 0,并确保在任何退出(甚至是非本地退出)时将其重置为之前的值。

我从 Paul Graham 的 On Lisp 中找到了一个详细的答案,添加了 bold 重点:


宏可以做函数不能做的两件事:它们可以控制(或阻止)对其参数的求值,并且它们被直接扩展为调用上下文。任何需要宏的应用程序最终都需要这些属性中的一个或两个。

...

宏以四种主要方式使用此控件:

  1. 转型。 Common Lisp setf 宏是 class 宏中的一个,它在求值前将参数分开。一个内置的访问函数通常会有一个逆函数,其目的是设置访问函数检索的内容。 car 的逆运算是 rplacacdrrplacd 的逆运算,依此类推。使用 setf,我们可以调用此类访问函数,就好像它们是要设置的变量一样,如 (setf (car x) ’a),它可以扩展为 (progn (rplaca x ’a) ’a)。 要执行此技巧,setf 必须查看其第一个参数。要知道上面的case需要rplacasetf一定能看出第一个参数是以car开头的表达式。因此 setf 任何其他转换其参数的运算符 必须写成宏。

  2. 绑定。词法变量必须直接出现在源代码中。例如,setq 的第一个参数不会被求值,因此任何构建在 setq 上的东西都必须是一个扩展为 setq 的宏,而不是调用它的函数。同样,对于像 let 这样的运算符,其参数将作为 lambda 表达式中的参数出现,对于像 do 这样扩展为 let 的宏,等等。 任何要改变其参数的词法绑定的新运算符都必须写成宏。

  3. 条件评估。计算函数的所有参数。在像 when 这样的结构中,我们希望 某些参数仅在某些条件下被评估 。只有使用宏才能实现这种灵活性。

  4. 多次评价。不仅函数的参数都被求值,而且它们都只被求值一次。我们需要一个宏 来定义像 do 这样的结构,其中某些参数将被重复计算。

还有几种方法可以利用宏的内联扩展。重要的是要强调扩展因此出现在宏调用的词法上下文中,因为宏的三种用法中的两种取决于这一事实。他们是:

  1. 使用调用环境。宏可以生成包含变量的扩展,该变量的绑定来自宏调用的上下文。以下宏的行为: (defmacro foo (x) ‘(+ ,x y)) 取决于调用 foo 的 y 的绑定。 这种词汇交流通常被视为一种传染源,而不是一种快乐的源泉。通常写这样的宏是不好的风格。函数式编程的理想也适用于宏:与宏通信的首选方式是通过其参数。事实上,很少需要使用调用环境,大多数情况下它都是错误发生的...

  2. 包装新环境。宏还可以使其参数在新的词法环境中进行计算。 classic 示例是 let,它可以在 lambda 上实现为宏。在 (let ((y 2)) (+ x y)) 等表达式的主体内,y 将引用一个新变量。

  3. 保存函数调用。内联插入宏扩展的第三个结果是在编译代码中没有与宏调用相关的开销。在运行时,宏调用已被其扩展所取代。 (声明为内联的函数原则上也是如此。)

...

那些可以用任何一种方式编写的运算符呢[即作为一个函数还是一个宏]?...当我们面临这样的选择时,有几点需要考虑:

优点

  1. 编译时计算。宏调用涉及两次计算:展开宏时,以及计算展开时。 Lisp 程序中的所有宏展开都是在程序编译时完成的,并且在编译时可以完成的每一位计算都不会在 运行 时减慢程序的速度。 如果可以编写一个运算符在宏扩展阶段完成它的一些工作,那么将它变成一个宏会更有效率,因为无论智能编译器无法完成的工作,函数都必须在运行时做。 第 13 章描述了像 avg 这样的宏,它们在扩展阶段做一些工作。

  2. 与 Lisp 集成。有时,使用宏而不是函数会使程序与 Lisp 集成得更紧密。与其编写程序来解决某个问题,不如使用宏 将问题转换为 Lisp 已经知道如何解决的问题 。这种方法,如果可能的话,通常会使程序更小和更高效:更小是因为 Lisp 正在为你做一些工作,而更高效是因为生产 Lisp 系统通常比用户程序有更多的脂肪。这种优势主要出现在嵌入式语言中,从第 19 章开始描述。

  3. 保存函数调用。宏调用直接扩展到它出现的代码中。所以如果你把一些经常使用的代码写成一个宏,你可以在每次使用时节省一个函数调用。在早期的 Lisp 方言中,程序员利用这个 属性 宏来在运行时保存函数调用。在 Common Lisp 中,这项工作应该由声明为内联的函数接管。 通过将函数声明为内联函数,您要求将其直接编译到调用代码中,就像宏一样。但是,这里理论与实践之间存在差距; CLTL2(第 229 页)说“编译器可以自由地忽略此声明”,一些 Common Lisp 编译器也这样做。如果您被迫使用这样的编译器,使用宏来保存函数调用可能仍然是合理的...

缺点

  1. 函数是数据,而宏更像是对编译器的指令。函数可以作为参数传递(例如传递给 apply)、由函数返回或存储在数据结构中。 None 这些事情都可以通过宏来实现。 在某些情况下,您可以通过将宏调用包含在 lambda 表达式中来获得您想要的结果。例如,如果您想要 applyfuncall 某些宏:> (funcall #’(lambda (x y) (avg x y)) 1 3) --> 2,则此方法有效。然而,这是一个不便。它也不总是有效:即使像 avg 一样,宏有一个 &rest 参数,也没有办法向它传递不同数量的参数。

  2. 源代码的清晰度。与等效的函数定义相比,宏定义更难阅读。因此,如果将某些东西写成宏只会使程序稍好一些,那么使用函数可能会更好。

  3. 运行时的清晰度。宏有时比函数更难调试。如果您在包含大量宏调用的代码中遇到运行时错误,您在回溯中看到的代码可能包含所有这些宏调用的扩展,并且可能与您最初编写的代码几乎没有相似之处。 而且因为宏在展开时会消失,所以它们在运行时不负责。您通常不能使用 trace 来查看宏是如何被调用的。如果一切正常,trace 会向您显示对宏的扩展函数的调用,而不是宏调用本身。

  4. 递归。 在宏中使用递归并不像在函数中那么简单。虽然扩展函数宏的可能是递归的,扩展本身可能不是。第 10.4 节处理宏中的递归主题...

考虑了宏可以做什么之后,下一个要问的问题是:我们可以在哪些应用程序中使用它们?最接近宏使用一般描述的是 它们主要用于句法转换。 这并不是说宏的范围受到限制。由于 Lisp 程序是由列表构成的,列表是 Lisp 数据结构,“语法转换”确实可以走很长的路...

宏应用程序在像 while 这样的小型通用宏和后面章节中定义的大型专用宏之间形成了一个连续统一体。一方面是实用程序,类似于每个 Lisp 内置的宏。它们通常很小,很笼统,并且是孤立地编写的。但是,您也可以为特定的 classes 程序编写实用程序,并且当您拥有一组用于图形程序的宏时,它们开始看起来像一种图形编程语言。在连续统一体的远端,宏允许您使用与 Lisp 截然不同的语言编写整个程序。据说以这种方式使用的宏可以实现嵌入式语言。

[这是一个更长、不完整的答案的简化版本,我认为它不适合 SE。]

没有您必须使用宏的情况。事实上,根本没有 必须 使用编程语言的情况:如果您乐于了解所用机器的订购代码并且能够使用键盘,那么您就可以编程那样。

我们大多数人都不喜欢这样做:我们喜欢使用编程语言。这些有两个明显的好处和一个不太明显但重要得多的好处。两个明显的好处:

  • 编程语言使编程更容易;
  • 编程语言使程序可以跨机器移植。

更重要的原因是构建语言是解决人类问题的一种非常成功的方法。它是如此成功,以至于我们一直都在这样做,甚至没有想过我们正在这样做。每次我们为某物发明一些新术语时,我们实际上是在发明一种语言;每当数学家发明一些新的符号时,他们就是在发明一种语言。人们喜欢嘲笑这些语言,称它们为 'jargon'、'slang' 或 'dialect',但众所周知:a shprakh iz a dialekt mit an armey un flot(译:语言是方言,有陆海军)。

编程语言和自然语言的情况是一样的,只是编程语言的设计目的是与其他人和机器进行交流,而机器需要非常精确的指令。这意味着构建编程语言可能相当困难,因此人们倾向于坚持使用他们知道的语言。

除了他们不这样做:构建一种语言来描述某些问题的方法 如此 强大,以至于人们实际上还是这样做了。但是他们不知道他们正在做这件事,而且他们没有工具来做这件事,所以他们最终得到的往往是一个可怕的怪物,它是由其他具有奶油冻的坚固性和可读性的东西拼接而成的。我们都处理过这样的事情。一个共同特征是 'language in a string',其中一种语言出现在另一种语言的字符串中,这种内部语言的构造通过外部语言中的字符串操作组合在一起。如果你真的很幸运,这将深入几个层次(我已经看过三个)。

这些东西令人厌恶,但它们仍然是处理大问题区域的最佳方法。好吧,如果你生活在一个构建新的编程语言如此困难以至于只有特别聪明的人才能做到的世界里,它们是最好的方法

但这很难,因为如果你唯一的工具是 C,那么一切看起来都像 PDP-11。相反,如果我们使用一种工具,通过允许以轻量级的方式根据自身的更简单版本来定义它们,从而使编程语言的增量构建变得容易,那么我们就可以构建整个编程语言家族,在其中讨论各种问题,其中每一个都只是 space 可能语言中的一个点。任何人都可以做到这一点:这比编写函数要难一点,因为制定语法规则比想新词要难一点,但不会难很多。

这就是宏的作用:它们让您定义编程语言,以一种极其轻量级的方式讨论特定的问题领域。 Common Lisp 就是这样一种语言,但它只是 Lisp 家族语言 space 中的一个起点:您可以从这一点构建您真正想要的语言(当然,人们会贬低这些语言称呼它们 'dialects':好吧,编程语言只是标准委员会的一种方言。

函数让您可以添加到您正在构建的语言的词汇表中。宏可让您添加到语言的 grammar。在它们之间,您可以定义一种新语言来讨论您感兴趣的问题领域。这样做就是 Lisp 编程的 重点:Lisp 是 关于 构建语言来讨论问题领域。

一旦你对宏有点熟悉,你就会想知道为什么你会有这个问题。 :-)

宏绝不是函数的替代品,反之亦然。如果你在 REPL 上工作,似乎就是这样,因为在你按下 [enter] 的那一刻,宏展开、编译和 运行 就发生了。

  • 宏在编译时是 运行,因此任何宏处理都会在您的定义运行时完成。无法在涉及此宏的定义的运行时 "call" 宏。
  • 宏只计算 S-exprs,然后传递给编译器。
  • 只要把宏想象成是为你编码的东西。

与 REPL 的小定义相比,在编辑器中使用更多的代码更容易理解。祝你好运!