使用读取宏编译Lisp代码

7
我有点难以理解在将lisp代码编译成字节码或原始汇编(或fasl文件)时,读取宏的处理方式。也许我理解了但不确定。我很困惑。
当你使用读取宏时,难道不需要可用的源代码吗?
如果需要,那么您必须执行组成读取宏函数的源代码。如果不需要,那么像read-char这样的操作怎么可能有效呢?
为了实现这一点,如果你想要读取宏使用预定义变量,你必须执行它之前的所有代码,所以这变成了运行时,这会破坏一切。
如果你不运行它之前的代码,那么在它上面定义的内容将无法使用。
那么定义读取宏的函数或编译器宏呢?我认为除非你要求或加载一个未编译的文件,否则它们根本不起作用。但是如果它们被编译了,那么就不能使用它们?
如果我的一些猜测是正确的,那么它意味着“哪些数据将可用于宏”和“哪些宏将可用于函数”之间存在很大差异,具体取决于您是编译整个文件以便以后运行还是逐行解释文件(也就是说,读取、编译和评估一个表达式接一个表达式)。
简而言之,似乎要将一行编译成可以在没有进一步宏处理或其他处理的情况下执行的形式,您必须读取、编译并运行前面的行。
请记住,这些问题适用于编译lisp,而不是解释它,您可以按照每行输入的方式运行它。
对不起,我说了很多废话,但我是lisp的新手,想更多地了解它的工作原理。
3个回答

5
这实际上是一个有趣的问题,也是许多初学者在Lisp编程中所遇到的困难之一。其中一个主要原因是大部分情况下都可以“按预期”工作,只有当你开始使用更高级的Lisp功能时才会真正开始思考这些问题。
简短回答您的问题是:是的,为了使代码正确编译,必须执行一些先前的代码。请注意“一些”这个关键词。我们来看一个小例子,假设有一个文件,其内容如下:
(print 'a)

(defmacro bar (x) `(print ,x))

(bar 'b)

正如您已经了解的那样,如果在此文件上运行COMPILE-FILE,生成的.fasl文件将只包含以下代码的编译版本:

(print 'a)
(print 'b)

“但是”,你可能会问,“为什么DEFMACRO表单在编译时执行,而PRINT表单却没有执行?”答案在Hyperspec的3.2.3节中解释。其中包含以下句子:
“通常,在使用compile-file编译的文件中出现的顶层表单只有在加载生成的编译文件时才会被评估,而不是在编译文件时评估。然而,通常情况下,文件中的某些表单需要在编译时评估,以便可以正确地读取和编译文件的其余部分。”
有一个表单可用于控制何时评估表单。您可以使用EVAL-WHEN来实现此目的。事实上,这正是Lisp编译器本身实现DEFMACRO的方式。您可以从REPL中键入以下内容来查看Lisp如何实现它:
(macroexpand '(defmacro bar (x) `(print ,x)))

显然,不同的Lisp实现会有不同的实现方式,但关键重要的是它将定义包装在一个形式中:(eval-when (:compile-toplevel :load-toplevel :execute) ...)。这告诉编译器该形式应在文件被编译时和加载时都进行评估。如果不这样做,您将无法在与宏定义相同的文件中使用该宏。如果该形式仅在文件被编译时进行评估,则在加载后的不同文件中也无法使用该宏。

2
编译后的文件不仅包含两个打印语句,还包含宏定义。 - Rainer Joswig
1
在编译时和运行时同时运行编译器宏实际上是我对编译器宏的预期。然而,这并没有解决阅读宏的问题(至少我没看到)。编译器宏是简单的函数(它们将真实对象作为参数,因此理论上可以延迟扩展到运行时),但是阅读宏依赖于文本。它们是如何工作的呢? - Seth Carnegie
@SethCarnegie 编译是增量的。每个表单在读取时都会被处理(在此上下文中,处理意味着编译或评估,或两者兼而有之)。这意味着第一个表单可以修改读取器行为,并且这种新行为将影响随后读取的表单。 - Elias Mårtenson
非顶层宏是否有异常情况? - Seth Carnegie

4
文件编译在Common Lisp中有定义:CLHS Section 3.2.3 File Compilation 在编译时,若要使用一个read宏表达式,必须将该read宏的实现提供给编译器。
通常可以使用defsystem工具来处理这样的依赖关系,描述系统(类似于一个项目)中各个文件之间的依赖关系。为了编译某个文件,必须将另一个文件(最好是已编译的版本)加载到编译Lisp中。
如果您想在同一文件中定义read宏并使用其符号,则需要确保编译器知道该read宏及其实现。文件编译器具有编译环境,但默认情况下不会将同一文件的已编译函数加载到此环境中。
为了让编译器意识到文件中的特定代码,Common Lisp提供了EVAL-WHEN
让我们来看一个read宏示例:
(set-syntax-from-char #\] #\)) 

(defun reader-example (stream char)
  (declare (ignore char))
  (let ((class (read stream t nil t))
        (args (read-delimited-list #\] stream t)))
    (apply #'make-instance
           class
           args)))

(set-macro-character #\[ 'reader-example)

(defclass example ()
  ((name :initarg :name)))

(defvar *examples*
  (list [example :name e1]
        [example :name e2]
        [example :name e3]))

如果您加载上述源代码,一切都很好。但是,如果我们使用文件编译器,它在未加载文件的情况下无法编译。例如,可以通过调用带有路径名的函数COMPILE-FILE来调用文件编译器。
现在开始编译文件:
(set-syntax-from-char #\] #\)) 

以上内容不会在编译时执行。新的语法更改在编译时不可用。

(defun reader-example (stream char)
  (declare (ignore char))
  (let ((class (read stream t nil t))
        (args (read-delimited-list #\] stream t)))
    (apply #'make-instance
           class
           args)))

上述函数已经编译完成,但未被加载。在后续步骤中,编译器无法使用其实现。
(set-macro-character #\[ 'reader-example)

以上表单不会被执行 - 只是生成了相应的代码。

(defclass example ()
  ((name :initarg :name)))

编译器注意到了这个类,但后续无法创建它的实例。
(defvar *examples*
  (list [example :name e1]
        [example :name e2]
        [example :name e3]))

上述代码会触发错误,因为读取宏在编译时不可用 - 除非它已经在之前被加载过。
现在有两个简单的解决方案:
1. 将读取宏的实现放在一个单独的文件中,并确保在使用读取宏的任何文件之前编译和加载它。 2. 在需要在编译时生效的代码周围放置一个 EVAL-WHEN。
示例:
(EVAL-WHEN (:compile-toplevel :load-toplevel :execute)
  (do-something-also-at-compile-time))

编译器将会看到并执行以上内容。现在你必须确保代码在编译时拥有其调用的所有内容(所有所需定义)。

不用说,尽可能减少这样的编译依赖关系是一种好习惯。通常将所需功能放入单独的文件中,并确保在编译使用它的文件之前,该文件被编译并加载到 Lisp 中。


1

宏(包括读取宏)就是函数,和其他函数一样进行处理。一旦函数或宏被编译,您无需保留源代码。

许多Lisp实现根本不进行任何解释。例如,SBCL默认只编译而不切换到解释模式,即使对于eval也是如此。一个重要的细微差别是,Common Lisp编译是增量的(与许多Scheme实现和类似C和Java的语言中的分离编译相反),这允许您编译函数或宏并立即在同一“编译单元”中使用它。


实际上,SBCL 有解释模式,只是默认情况下被关闭了:http://www.sbcl.org/manual/Interpreter.html - Vsevolod Dyomkin
Common Lisp的编译是增量式的,但文件的编译定义略有不同。 - Rainer Joswig
@Rainer,你能详细说明一下吗?我不熟悉它。 - Seth Carnegie

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接