Lisp宏能做什么是“一等函数”无法做到的?

37 投票
8 回答
9112 浏览
提问于 2025-04-16 11:43

我觉得我明白Lisp中的宏以及它们在编译阶段的作用。

但是在Python中,你可以把一个函数作为参数传递给另一个函数。

def f(filename, g):
  try:                                
     fh = open(filename, "rb") 
     g(fh)
  finally:
     close(fh) 

所以,在这里我们可以实现懒惰求值。那么,宏能做的事情,函数作为一等公民就做不了什么呢?

8 个回答

12

宏可以改变代码

宏的作用是对源代码进行转换。懒惰求值则不一样。想象一下,你现在可以编写函数,将任意代码转换成不同的代码。

非常简单的代码转换

创建简单的语言结构只是一个非常简单的例子。考虑一下打开文件的例子:

(with-open-file (stream file :direction :input)
  (do-something stream))

(call-with-stream (function do-something)
                  file
                  :direction :input)

宏给我的,是稍微不同的语法和代码结构。

嵌入式语言:高级迭代结构

接下来考虑一个稍微不同的例子:

(loop for i from 10 below 20 collect (sqr i))

(collect-for 10 20 (function sqr))

我们可以定义一个函数 COLLECT-FOR,它可以在简单循环中做同样的事情,并且有起始、结束和步进函数的变量。

但是 LOOP 提供了一种新的语言。LOOP 宏就是这个语言的编译器。这个编译器可以进行 LOOP 特有的优化,并且可以在编译时检查这种新语言的语法。一个更强大的循环宏是 ITERATE。这些强大的语言级工具现在可以作为库编写,而不需要特别的编译器支持。

在宏中遍历代码树并进行修改

接下来是另一个简单的例子:

(with-slots (age name) some-person
  (print name)
  (princ " "
  (princ age))

和类似的东西:

(flet ((age (person) (slot-value person 'age))
       (name (person) (slot-value person 'name)))
   (print (name))
   (princ " ")
   (princ (age)))

WITH-SLOTS 宏会遍历整个包含的源代码树,并将变量 name 替换为 (SLOT-VALUE SOME-PERSON 'name) 的调用:

(progn
  (print (slot-value some-person 'name))
  (princ " "
  (princ (slot-value some-person 'age)))

在这个例子中,宏可以重写代码的特定部分。它理解 Lisp 语言的结构,并知道 nameage 是变量。它还理解在某些情况下 nameage 可能不是变量,因此不应该被重写。这是一个所谓的 代码遍历器 的应用,它可以遍历代码树并对其进行修改。

宏可以修改编译时环境

另一个简单的例子,是一个小文件的内容:

(defmacro oneplus (x)
  (print (list 'expanding 'oneplus 'with x))
  `(1+ ,x))

(defun example (a b)
   (+ (oneplus a) (oneplus (* a b))))

在这个例子中,我们不关注宏 ONEPLUS,而是关注宏 DEFMACRO 本身。

它有什么有趣的地方?在 Lisp 中,你可以有一个包含上述内容的文件,并使用 文件编译器 来编译这个文件。

;;; Compiling file /private/tmp/test.lisp ...
;;; Safety = 3, Speed = 1, Space = 1, Float = 1, Interruptible = 1
;;; Compilation speed = 1, Debug = 2, Fixnum safety = 3
;;; Source level debugging is on
;;; Source file recording is  on
;;; Cross referencing is on
; (TOP-LEVEL-FORM 0)
; ONEPLUS

(EXPANDING ONEPLUS SOURCE A) 
(EXPANDING ONEPLUS SOURCE (* A B)) 
; EXAMPLE
;; Processing Cross Reference Information

所以我们看到,文件编译器 扩展了对 ONEPLUS 宏的使用。

这有什么特别之处?文件中有一个宏定义,而在下一个形式中我们已经使用了这个新宏 ONEPLUS。我们从未将宏定义加载到 Lisp 中。编译器以某种方式知道并注册了定义的宏 ONEPLUS,然后能够使用它。

因此,宏 DEFMACRO 在编译时环境中注册了新定义的宏 ONEPLUS,使得编译器知道这个宏——而无需加载代码。这个宏可以在宏展开时在编译时执行。

使用函数我们无法做到这一点。编译器为函数调用生成代码,但不执行它们。但宏可以在编译时运行,并为编译器添加“知识”。这种知识在编译器运行期间有效,之后部分会被遗忘。DEFMACRO 是一个在编译时执行的宏,然后通知编译时环境一个新宏的存在。

还要注意,宏 ONEPLUS 也会运行两次,因为它在文件中使用了两次。副作用是它会打印一些东西。但 ONEPLUS 也可能有其他任意副作用。例如,它可以检查包含的源代码是否符合某些规则,并在代码违反规则时提醒你(想想样式检查器)。

这意味着,宏,这里是 DEFMACRO,可以在编译文件时改变语言及其环境。在其他语言中,编译器可能提供特殊的编译指令,这些指令在编译时会被识别。有很多这样的定义宏影响编译器的例子:DEFUNDEFCLASSDEFMETHOD 等等。

宏可以让用户代码更简洁

一个典型的例子是 DEFSTRUCT 宏,用于定义类似于记录的数据结构。

(defstruct person name age salary)

上面的 defstruct 宏创建了以下代码:

  • 一个新的结构类型 person,有三个槽
  • 用于读取和写入值的槽访问器
  • 一个检查某个对象是否属于 person 类的谓词
  • 一个创建结构对象的 make-person 函数
  • 一个打印表示

此外,它可能会:

  • 记录源代码
  • 记录源代码的来源(文件、编辑器缓冲区、REPL 等)
  • 交叉引用源代码

定义结构的原始代码是一行简短的代码,而扩展后的代码则要长得多。

DEFSTRUCT 宏不需要访问语言的元级别来创建这些不同的东西。它只是将一小段描述性代码转换为通常更长的定义代码,使用典型的语言结构。

19

这是Matthias Felleisen在2002年的回答(通过http://people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg01539.html):

我想提议,宏有三种有条理的使用方式:

  1. 数据子语言:我可以写看起来简单的表达式,然后用宏整齐地创建复杂的嵌套列表、数组或表格,像是用引号、取消引号等。

  2. 绑定结构:我可以用宏引入新的绑定结构。这可以帮助我摆脱lambda表达式,并把属于同一组的东西放得更近。例如,我们的一个教学包里有一个表单
    (web-query ([last-name (string-append "你好 " first-name " 你的姓是什么?"]) ... last-name ... first-name ...) 这暗示了程序和网络用户之间的明显互动。
    [注意:在ML中,你可以写 web-query(fn last-name => ...)string_append(...),但这实在是麻烦且不必要的模式。]

  3. 评估重排序:我可以引入一些结构,按需延迟或推迟表达式的评估。想想循环、新的条件语句、延迟/强制等。
    [注意:在Haskell中,你不需要这个。]

我知道Lisp程序员使用宏是出于其他原因。老实说,我认为这部分是因为编译器的不足,部分是因为目标语言中的“语义”不规则性。

我挑战大家在说语言X可以做到宏能做到的事情时,考虑这三个问题。

-- Matthias

Felleisen是宏领域最有影响力的研究者之一。(不过我不知道他现在是否还同意这个观点。)

更多阅读:Paul Graham的《On Lisp》(http://www.paulgraham.com/onlisp.html; Graham 绝对不同意Felleisen认为这些是宏唯一有用的用法),以及Shriram Krishnamurthi的论文《通过宏实现自动机》(http://www.cs.brown.edu/~sk/Publications/Papers/Published/sk-automata-macros/)。

33

首先,Lisp 也有一等函数,所以你也可以问:“如果我已经有一等函数,为什么还需要宏?”答案是,一等函数无法让你玩弄语法。

从表面上看,一等函数让你可以写 f(filename, some_function)f(filename, lambda fh: fh.whatever(x)),但不能写 f(filename, fh, fh.whatever(x))。虽然这样说可能有点道理,因为在最后一种情况下,fh 突然出现的地方就不太清楚了。

更重要的是,函数只能包含有效的代码。所以你不能写一个高阶函数 reverse_function,让它接受一个函数作为参数,然后“反向”执行它,比如 reverse_function(lambda: "hello world" print) 会执行 print "hello world"。但用宏你就可以做到。当然,这个例子有点傻,但这个能力在嵌入特定领域语言时非常有用。

例如,你无法在 Python 中实现 Common Lisp 的 loop 结构。实际上,如果 Python 的 for ... in 结构不是内置的,你甚至无法用 Python 自己实现它——至少不能用那种语法。当然,你可以实现类似 for(collection, function) 的东西,但那就没那么好看了。

撰写回答