哥德尔、艾舍尔、Elisp:宏之美
摘要
这篇文章颂扬了Emacs Lisp宏和同像性之美,将其与侯世达的怪圈和艾舍尔的艺术相提并论,并介绍了诸如macrostep之类的工具来探索宏。
<p><a href="https://lobste.rs/s/rqjjkq/godel_escher_elisp_beauty_macros">评论</a></p>
查看缓存全文
缓存时间: 2026/08/05 01:52
# Gödel,Escher,Elisp:宏之美
来源:https://www.chiply.dev/post-elisp-macros-are-beautiful
## Gödel,Escher,Elisp:宏之美
## 目录
- 1\. TLDR (https://www.chiply.dev/post-elisp-macros-are-beautiful#tldr)
- 2\. 程序即数据,数据即程序emacselisplisp (https://www.chiply.dev/post-elisp-macros-are-beautiful#programs-as-data)
- 3\. 宏究竟是什么emacselispmacros (https://www.chiply.dev/post-elisp-macros-are-beautiful#what-a-macro-is)
- 4\. 你其实一直在用宏emacselispmacros (https://www.chiply.dev/post-elisp-macros-are-beautiful#macros-in-plain-sight)
- 5\. 打造你自己的宏emacselispmacrosconfig (https://www.chiply.dev/post-elisp-macros-are-beautiful#rolling-your-own)
- 6\. 怪圈与画手emacselisphofstadtergeb (https://www.chiply.dev/post-elisp-macros-are-beautiful#strange-loops)
- 7\. 看穿魔法emacselisptooling (https://www.chiply.dev/post-elisp-macros-are-beautiful#seeing-through-the-magic)
- 8\. 能力越大emacselispmacros (https://www.chiply.dev/post-elisp-macros-are-beautiful#responsibility)
- 9\. 弯折金属emacselisplisp (https://www.chiply.dev/post-elisp-macros-are-beautiful#bending-the-metal)
## 1\.TLDR
escher-drawing-hands.jpg
如果你是一位目光敏锐的 Emacs 用户,你一定会注意到,在 Emacs Lisp 中,*代码就是数据*。毕竟,“Lisp”是“List Processing”的缩写。Elisp 最迷人的特性之一,便是处理列表的事物(程序)与列表本身(数据)之间那恰到好处的模糊地带。Elisp 中的`macro`正是利用这一模糊性的工具,它让你能够以许多有用且迷人的方式,利用程序与数据之间的这种二元性。
在这篇文章中,我想尽情抒写对宏的赞美,解释“homoiconic”究竟意味着什么,展示它们在 Elisp 中的无处不在,借道 Hofstadter 的怪圈和 Escher 的版画描绘它们的美,最后展示一些既能加深理解又能提升欣赏能力的工具(`macroexpand`、`emacs-lisp-macroexpand`、macrostep (https://github.com/emacsorphanage/macrostep))。
以下是我们在文中会借用的 Escher 图像:
Escher 的《莫比乌斯环 II》Escher 的《巴别塔》Escher 的《画廊》Escher 的《爬虫》Escher 的《手持球体反射的双手》Escher 的《变形 II》Escher 的《瞭望台》Escher 的《上升与下降》Escher 的《瀑布》
## 2\.程序即数据,数据即程序emacselisplisp
> Lisp 的内核具有水晶般的纯净,不仅赏心悦目,而且使 Lisp 成为一种远比大多数其他语言更灵活的语言。—— Douglas Hofstadter
这篇文章的一个重要关键词是*homoiconic*。当一种语言的程序是用该语言自身的数据结构所书写时,这种语言就是同像的。
很少有语言是同像的,而 Lisp 家族最自豪地拥有这一特性,其中 Emacs Lisp(Elisp)是许多人最熟悉的一种方言。在 Elisp 中,源代码*就是*列表、符号、字符串和数字。代码看起来与你用`cons`构建、用`car`和`cdr`拆解的列表一模一样。
程序与数据的区别由一个特定而特殊的字符来体现,那就是光荣的`'`:
```
;; 一个程序:求值为 3
(+ 1 2)
;; 数据:一个包含三个元素的列表 —— 一个符号和两个数字
'(+ 1 2)
```
引号告诉求值器不要运行紧随其后的形式,而是将其视为普通数据(一个列表)。
为了强调在 Elisp 中程序和数据是等价的,对带引号的列表运行`eval`(如`(eval '(+ 1 2))`)会将其变成一个程序,其中的函数是加法,参数是数字 1 和 2。
这种二元性位于语言的核心。任何一段代码只差一个字符就会变成你可以检查、转换和重建的值;任何形状合适的值只差一次函数调用就会变成程序。
因此在 Elisp 中,我们说*程序 = 数据*,尽管这有点过于简单化,因为我们看到了 Lisp 解释器如何正确地区分*列表*何时被表示为程序、何时被表示为数据……关键在于*列表*:那是统一的形式。或者更恰当地说,我们可以说*程序和数据采用相同的形式*,或者如前所述,Elisp 的程序是用 Elisp 自身的数据结构书写的。
这篇文章的写作动机来自于对Douglas Hofstadter (https://en.wikipedia.org/wiki/Douglas_Hofstadter) 的作品和 Elisp 宏的同时迷恋,所以请准备好迎接大量来自 Hofstadter 最喜爱的艺术家之一M\.C\. Escher (https://en.wikipedia.org/wiki/M._C._Escher) 的形象化隐喻。这是第一个:
Escher 以一种木刻版画的方式描绘了这种二元性。《莫比乌斯环 II》中的蚂蚁似乎同时行走在环带的两面上。这幅图像初看便令人深思,但我邀请你追随其中任意一只蚂蚁绕行一周,然后发现这两面其实是同一个连续表面。程序与数据,正是 Elisp 同像莫比乌斯环的两面。
escher-mobius-strip-ii.jpg
图 1:M\.C\. Escher 的《莫比乌斯环 II》(1963)。两个面,一个表面。© The M\.C\. Escher Company。
在大多数语言中,元编程存在于一个单独的层面,拥有自己表示代码的方式,如模板、反射 API、记号流、准引用 AST。其中有些层面粗糙,有些确实精巧,但每一层都是在代码和数据之间竖起的一堵墙。在 Elisp 中,从来就没有一堵需要你打穿的墙。Elisp 支持元编程,但始终是同样的语言,同样的数据结构,一路到底。
## 3\.宏究竟是什么emacselispmacros
考虑 Elisp 中的普通函数。函数接收*值*并在运行时计算出一个值。
另一方面,宏接收*代码*(在调用点所写的原始、未求值的形式)并返回新代码,然后新代码会*在其原位置*被求值。宏在展开期运行,早于程序执行。我喜欢把宏看作一些小程序,它们根据所能接受的任意形式来编写其他程序。从“形式”到“程序”这种映射的复杂度几乎是无限的,至少受限于你能用 Elisp 表达的一切,而后者很可能只受限于你的想象力。
宏的“形式 → 程序”工具包是准引用:反引号`` ` ``构建代码模板,逗号`,`插入计算出的片段,`,@`则拼接一个列表。以下是我能写出的最小的真实宏,一个对`unless`的重新实现:
```
(defmacro my-unless (condition &rest body)
"Run BODY unless CONDITION is non-nil."
(declare (indent 1))
`(if ,condition nil ,@body))
```
我们实际上可以请 Emacs 展示这个宏会被展开成什么。第一个代码块是对`macroexpand-1`的调用,第二个是它返回的展开结果:
```
(macroexpand-1
'(my-unless (file-exists-p "~/notes")
(make-directory "~/notes")
(message "created it")))
```
```
(if (file-exists-p "~/notes") nil
(make-directory "~/notes") (message "created it"))
```
先预判一个常见问题:为什么`my-unless`不能是函数呢?函数参数会被急切求值,在函数看到它们之前就已经完成求值。函数版本会在准备参数时就计算`(make-directory "~/notes")`,而那时条件还未被检查。因此,当`~/notes`已经存在时,它不会什么都不做,而是会报出`file-already-exists`错误,`message`也就根本不会执行。换句话说,函数接收的是那部分代码的*结果*,但这里使用宏的目的是决定那部分代码是否要执行。宏以惰性数据的形式接收整个代码体,因此控制流本身也可以由你掌控。这样一来,你就在*扩展这门语言所能表达的东西*。
## 4\.你其实一直在用宏emacselispmacros
宏可能看起来像是专家或古怪人士的专属……我希望这能让正在阅读的 Elisp 使用者们感到惊讶!当初我就被惊到了!
- `when`和`unless`是建立在`if`之上的宏。
- `dolist`和`dotimes`是建立在`while`之上的宏。
- `push`、`pop`和`setf`是宏,它们会根据你给出的位置,将自身改写成适当的赋值操作。
- `with-current-buffer`、`with-temp-buffer`和`ignore-errors`是宏,它们将你的代码包裹在正确的保存与恢复仪式中,使你永远不必手动输入这些样板代码。
- 甚至`defun`也是一个宏!
在任何 Emacs 配置中,当之无愧最有名的宏是`use-package`:
```
(use-package magit
:bind ("C-c g" . magit-status)
:hook (git-commit-mode . flyspell-mode))
```
`:bind`和`:hook`并不是 Elisp,而是一个小型配置语言中的关键字,`use-package`宏则是它的“编译器”,它将这条声明展开为`require`调用、键位映射绑定、钩子和自动加载延迟处理;否则这些都需要以手写形式完整、冗长地写出来。
`define-minor-mode`宏也与此类似。一条声明就展开为一个变量、一个可交互的切换命令、键位映射接线和文档。这就是所谓宏让你*让语言向着问题生长*的含义。通过使用宏,你的配置读起来更像是对你想要之物的声明式描述,因为已经有人为你构建了这样的宏(就`use-package`而言,这里要感谢 John Wiegley)。
在我深入探索时,最令人震惊的例子是`defun`。求值`(macrop 'defun)`会得到`t`,这表明,没错,`defun`是一个宏(同样,第一个代码块是对`macroexpand-1`的调用,第二个是它返回的展开结果):
```
(macroexpand-1 '(defun greet (name) "Say hi." (message "Hi, %s" name)))
```
```
(defalias 'greet #'(lambda (name) "Say hi." (message "Hi, %s" name)))
```
定义一个函数原来意味着:
- 构造一个匿名函数
- 将一个符号别名指向它
还有更多惊喜……
你知道吗,`lambda`本身也是一个宏(虽然它小得可爱,只是展开成一个带*函数引号*的自身版本)?当然不会是我心爱的`defcustom`吧?是的,我的 Emacs 同胞,它也是一个宏。
```
(macroexpand-1
'(defcustom chiply/favorite-lithograph "Drawing Hands"
"Which Escher lithograph to contemplate while macroexpanding."
:type 'string
:group 'chiply))
```
```
(custom-declare-variable
'chiply/favorite-lithograph '"Drawing Hands"
"Which Escher lithograph to contemplate while macroexpanding."
:type 'string :group 'chiply)
```
(如果你在`*scratch*`或`ielm`中尝试这个,你会看到更复杂一些的结果:默认值会回来时被包装在`(funcall #'(lambda () ...))`中。在词法绑定下——自 Emacs 27 起,`*scratch*`、`ielm`和`M-:`的默认绑定——`defcustom`会将默认值包装在一个闭包中,以便 Custom 稍后重新求值它。Org 是在动态绑定下求值这个代码块的,因此上面是更简单的形式。无论哪种方式,要点不变:`defcustom`是一个宏,离普通函数调用只差一次展开。只需知道,宏的展开可能依赖于它展开时所在的环境。)
`cl-loop`,那个完整的迭代迷你语言,被我本人过度使用?那也是一个宏!
如果你想看看所有的宏,运行下面这段代码即可。
```
(let (names)
(mapatoms (lambda (s) (when (macrop s) (push (symbol-name s) names))))
(with-temp-buffer
(setq fill-column 72)
(insert (mapconcat #'identity (sort names #'string<) " "))
(fill-region (point-min) (point-max))
(buffer-string)))
```
看起来你写的所有代码似乎都在某种程度被原地编译成其他代码,那么这一切止于何处?它止于*特殊形式*——`if`、`let`、`setq`、`while`、`quote`、`save-excursion`、`condition-case`,以及它们在 C 语言中实现的同类。当你把任何 Elisp 程序一直往下宏展开,剩下的是由特殊形式、普通函数调用以及它们操作所及的变量和常量组成。函数负责实际工作(大约一千五百个是像`car`和`cons`那样的 C 原语;其余用 Elisp 编写)。特殊形式决定求值如何流动。而在这层地基之上的每一块*语法*砖石(`when`、`dolist`、`setf`、`use-package`,甚至`defun`)都是用 Elisp 编写的宏。地基是 C,但整个建筑是用塔自身的砖块砌成的。
*特殊形式*与*宏*之间的区别值得更仔细地观察,因为在调用点,宏和特殊形式难以区分。特殊形式和宏都不会以正常方式求值它们的参数,这就是为什么`when`和`if`感觉相似。区别在自省时便清晰了。`when`是一个宏,因此它必须解释自己:`macroexpand`会将它变成`if`。与`when`不同,`if`不会对自身作任何解释——它*就是*一条求值规则,直接连在解释器的 C 代码中。宏总是必须展开殆尽,而特殊形式则是展开停止之处。换一种说法,宏是一种你可以自己编写的特殊形式,条件是你*必须*将它编译成真正的形式。我写这篇文章所用的 Emacs 中,真正的特殊形式恰好有二十二个(只要把上面普查里的`macrop`换成`special-form-p`就能见到它们),其中包含`cond`、`and`和`or`。
Escher 在 1928 年创作了他的《巴别塔》,其主题是一个失败的建造工程,因为建造者们不再使用同一种语言。Elisp 的高塔之所以矗立,恰恰是因为相反的原因:从塔顶的`use-package`到塔基的特殊形式,每一层都用同一种语言书写。
escher-tower-of-babel.jpg
图 2:M\.C\. Escher 的《巴别塔》(1928)。巴别塔的建造因建造者语言分化而停滞;Elisp 的高塔不断上升,因为每一层都表达着同一种语言(Elisp)。© The M\.C\. Escher Company。
## 5\.打造你自己的宏emacselispmacrosconfig
我认为这是一个常见的用例。假设你一直在编写这样的命令:设置某个变量并报告发生了什么。也许你有一个针对`debug-on-error`的,一个针对`truncate-lines`的,诸如此类……*思维模式*是:“给我一个能切换这个变量的命令。”在这种情况下,把这个模式捕捉到宏里是有用的:
```
(defmacro deftoggle (var)
"Define a command `chiply/toggle-VAR' that toggles the variable VAR."
`(defun ,(intern (format "chiply/toggle-%s" var)) ()
,(format "Toggle the variable `%s'." var)
(interactive)
(setq ,var (not ,var))
(message "%s is now %s" ',var (if ,var "on" "off"))))
```
从此以后每个开关只需一行:
```
(deftoggle debug-on-error)
(deftoggle truncate-lines)
```
展开第一个会展示你实际写了什么:
```
(macroexpand-1 '(deftoggle debug-on-error))
```
```
(defun chiply/toggle-debug-on-error nil
"Toggle the variable `debug-on-error'." (interactive)
(setq debug-on-error (not debug-on-error))
(message "%s is now %s" 'debug-on-error
(if debug-on-error "on" "off")))
```
我们这一行宏调用做了很多事情:
- 它*创建了一个新符号*——`M-x chiply/toggle-debug-on-error`现在作为命令存在了。
- 它*编写了一份文档字符串*,在展开期计算出来,能正确显示在`C-h f`中。
- 它生成了一个`interactive`声明。
现在,一个合理的反驳是:普通函数也可以在运行时产生这三样效果——`defalias`可以创建符号,接受文档字符串,并包装一个`(interactive)`的 lambda。函数无法给你的是调用点和时机。你得写成`(make-toggle 'debug-on-error)`,把一个带引号的符号交给运行时机制,而字节编译器永远看不到这个定义。宏接收的是*裸的*名字,并在展开后的源码中留下一个真正的`defun`,从而让`C-h f`、字节编译器以及所有其他源码工具都能找到它!
你不仅仅是编写了一个辅助工具;更重要的是,你为这门语言添加了一种新的*定义形式*,它是`defun`和`defvar`的一个小兄弟,用*你的*问题领域的话语来交流。这正是 Elisp 宏的另一处美:抽象与解释发生在与代码本身相同的层次上,
相似文章
通往Lisp之路:为何选择Lisp
文章阐述了学习Lisp的理由,强调了其宏和可扩展性等独特特性,这些特性使程序员能够将语言适配到自己的问题域,并通过Blub悖论解释了为何来自能力较弱语言的程序员可能难以认识到Lisp的优势。
软件界的Emacs化
作者讲述了在终端中阅读 Markdown 的烦恼,并描述了如何使用 Claude 快速构建一个自定义的 macOS Markdown 查看器(MDV.app),展示了 AI 如何让人能够迅速创建个人软件工具。
The Story of Mac: A Just-So Story
An excerpt from Peter Seibel's 'Practical Common Lisp' that uses a just-so story to explain how Lisp macros work, comparing them to a junior programmer filling in code from notes.
推荐一下……理解 Emacs 的模式
文章解释了 Emacs 的架构模式,重点介绍了通用缓冲区数据模型和增量补全读取(ICR),并将其比作玫瑰的根与花瓣。文章强调了 Emacs 如何统一界面并通过 Elisp 实现可扩展性。
Forth 与 Lisp 的迁移之道
本章节选自《Let Over Lambda》,探讨如何使用宏在 Lisp 中实现 Forth 编程语言,强调语法的二元性和元编程技术。旨在向 Lisp 读者教授 Forth 概念,并讨论 Forth 设计背后的哲学。