哥德尔、艾舍尔、Elisp:宏之美

Lobsters Hottest 新闻

摘要

这篇文章颂扬了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

Hacker News Top

文章阐述了学习Lisp的理由,强调了其宏和可扩展性等独特特性,这些特性使程序员能够将语言适配到自己的问题域,并通过Blub悖论解释了为何来自能力较弱语言的程序员可能难以认识到Lisp的优势。

软件界的Emacs化

Hacker News Top

作者讲述了在终端中阅读 Markdown 的烦恼,并描述了如何使用 Claude 快速构建一个自定义的 macOS Markdown 查看器(MDV.app),展示了 AI 如何让人能够迅速创建个人软件工具。

The Story of Mac: A Just-So Story

Hacker News Top

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 的模式

Lobsters Hottest

文章解释了 Emacs 的架构模式,重点介绍了通用缓冲区数据模型和增量补全读取(ICR),并将其比作玫瑰的根与花瓣。文章强调了 Emacs 如何统一界面并通过 Elisp 实现可扩展性。

Forth 与 Lisp 的迁移之道

Lobsters Hottest

本章节选自《Let Over Lambda》,探讨如何使用宏在 Lisp 中实现 Forth 编程语言,强调语法的二元性和元编程技术。旨在向 Lisp 读者教授 Forth 概念,并讨论 Forth 设计背后的哲学。