通往Lisp之路:为何选择Lisp
摘要
文章阐述了学习Lisp的理由,强调了其宏和可扩展性等独特特性,这些特性使程序员能够将语言适配到自己的问题域,并通过Blub悖论解释了为何来自能力较弱语言的程序员可能难以认识到Lisp的优势。
暂无内容
查看缓存全文
缓存时间: 2026/07/09 16:37
# 通往 Lisp 之路:为什么是 Lisp?
来源:https://scotto.me/blog/2026-07-09-why-lisp/
大多数程序员第一次看到 Lisp 代码时,毫无疑问会问:“这到底是什么?”我第一次读到它那非传统的语法时,也问了自己同样的问题:那些括号、奇怪的缩进,还有谁想到把 `format` 的第一个参数用来输出到标准输出的?
```lisp
(defun flip-coin-for-real () (<= (random 100) 80))
(defun hello-lisp ()
(write-line "What is your name?")
(let ((name (read-line))
(learn-it (if (flip-coin-for-real) "should" "should not")))
(format t "Hello, ~A.~%" name)
(format t "The Oracle said... you ~A learn Lisp!~%" learn-it)))
```
在习惯了阅读有这么多括号的代码后,我还需要学习如何使用包和符号、如何创建新项目并导入库、如何使用 REPL,以及如何使用条件和重启。最重要的是,在构建算法时,我必须切换一种新的思维方式。与大多数常见语言相比,Lisp 的学习曲线很陡峭。但它也能解锁其他语言永远无法提供的技能。为什么?因为**在 Lisp 中,你可以做其他语言做不到的事情**。Lisp 开启了新的可能性,允许算法呈现不同的形态,赋予程序员更多的力量和灵活性。
Paul Graham 创造了 **Blub 悖论**(https://paulgraham.com/avg.html)这一术语,用来解释为什么只使用过能力较弱语言的程序员难以理解 Lisp 的强大。简而言之,是因为他们缺少感知那些语言所欠缺的概念以及 Lisp 相对优势所需的认知框架。Louis Armstrong 对爵士乐也说过类似的话:
> 如果你还要问什么是爵士乐,那你永远不会懂。——Louis Armstrong
在本文中,我将解释几个特性,说明为什么这门特别的语言值得学习。这条路并不容易,要真正理解它的力量,你需要亲自使用 Lisp。即使你最终没有使用 Lisp,你也会对编程语言能做什么获得不同的视角。Lisp 会让你成为更好的程序员,因为**它改变了你用代码思考问题的方式**。更具体地说,它会教会你一种其他编程语言无法实现的方法。你将能够编程 Lisp 本身,从而将你使用的语言适应于你正在解决的问题。使用 Lisp,你将学会*让语言向你的问题生长,然后用那种语言编写程序*。
## 可扩展性
Lisp 可以在自身内部进行扩展。专家(称为 Lispers)通常称其为**可编程的编程语言**¹(https://scotto.me/blog/2026-07-09-why-lisp/#user-content-fn-1)。你不仅会为你的程序编写代码,还可以编写扩展 Lisp 本身的代码。这要归功于 **宏** 操作符。如果你在 C、Rust 或 Swift 等语言中使用过宏,不要指望它们是同样的东西。那些宏主要是一种消除样板代码或生成重复代码的方式。除此之外,Lisp 中的宏允许你创建成为语言本身一部分的新结构。对于 Lisp 程序员来说,这是最难掌握的特性之一,所以我不会在这里教你如何使用它们或它们的工作原理。我只想让你感受一下它们能做什么。
例如,C 程序员可能希望使用 `while` 操作符来定义一个简单的循环。Common Lisp 并没有提供它,因此程序员可以编写一个宏并将其添加到语言中。
```lisp
(defmacro while (condition &body body)
`(loop while ,condition do (progn ,@body)))
```
新的 `while` 宏会在 `condition` 为真时执行一系列指令(`body`)。它使用了 `loop`,这个宏本身也是用来编写复杂迭代的(注意它也使用了 `while` 符号)。`progn` 是一种特殊形式,它接受多个表达式,按顺序执行它们,并返回最后一个表达式的结果。我们不必写成 `(loop while ... do (progn ...))`,而是可以使用新的 `while` 操作符,缩短代码并使其类似于 C。我们只是扩展了可用的语言。
```lisp
(let ((counter 3))
(while (> counter 0)
(print counter)
(decf counter)))
;; 3
;; 2
;; 1
```
初学者可能无法理解这里真正神奇的地方,因为除了独特的语法外,`defmacro` 看起来和第一个代码示例中用于定义新函数的 `defun` 非常相似。为了更好地区分两者,我们定义一个作为函数的 `while`,然后调用它。
```lisp
(defun fake-while (condition body)
(loop while condition do (funcall body)))
(let ((counter 3))
(fake-while (> counter 0) (progn (print counter) (decf counter))))
```
`loop` 和之前使用的宏一样;`funcall` 调用作为第一个参数接收到的函数,这里就是 `body`。尝试在 Common Lisp REPL 中执行上述代码。你会收到一个类型为错误的条件,类似这样:`The value 2 is not of type FUNCTION`。这是因为 `fake-while` 的参数会被立即求值。`(> x 3)` 求值为 `t` —— 真。因为 `progn` 是一个函数,它的参数会被求值。`(print counter)` 会打印 `3`,而 `(decf counter)` 返回 `2`。由于 `progn` 返回其最后一个表达式的值,`body` 变成了 `2`。因此 fake-while 接收的第一个参数是 `t`,第二个是 `2`,而不是一块代码。它会进入 `while` 循环(因为条件为真),然后 `funcall` 期望 `body` 是一个可调用的函数,但 `body` 是值 `2`。这就引发了上述条件。编译器实际执行的代码是下面的。`(funcall 2)` 引发条件。
```lisp
(loop while t do (funcall 2))
```
之前定义的宏 `while` 之所以有效,是因为它保留了参数 —— `condition` 和 `body` —— 不对它们进行求值,直到需要时才求值。我们可以在 REPL 中检查宏所进行的转换。这种转换称为 **展开**。`macroexpand` 是一个特殊的 Lisp 命令,我们可以用它来展开代码。
```lisp
CL-USER> (macroexpand-1 '(while (> counter 0) (print counter) (decf counter)))
(LOOP WHILE (> COUNTER 0) DO (PROGN (PRINT COUNTER) (DECF COUNTER)))
```
宏展开返回的代码就是编译器接收并执行的代码。我们可以看到,这一次 `while` 宏保留了参数,以便它们能与其余代码一起执行。与函数不同,宏不会预先对参数求值。相反,它把它们当作纯数据。这是可能的,因为(几乎)你在 Lisp 中看到的所有东西都是由列表构成的。列表是主要的数据结构,并且被用来编写代码。
## 全是列表
Lisp 程序由一系列 **符号表达式**(简称 *s-expr*)组成。*表达式*是数学术语,指任何可以求值为一个值的东西;*符号*意味着表达式是使用值和符号创建的。一个 *s-表达式* 是以下两种之一:
- **原子**,它是一个数据单元(数字、字符串、符号等)
- **列表**,它是元素的集合,元素可以是原子或其他列表
```lisp
1 ;; 一个原子数字
"hello" ;; 一个原子字符串
'(1 "y" :c) ;; 一个原子列表(一个数字、一个字符串、一个关键字)
(+ 1 2) ;; 一个由符号(加号)和两个数字组成的列表
```
Lisp 意为 **LISt Processing**(列表处理),所以语言的目的是处理构成程序的、以列表形式表达的一系列指令。由于列表既被用作主要数据结构,也被用来编写代码,我们可以提取出语言的一个有趣特性。在 Lisp 中,**代码即数据** 意味着代码和数据都是用列表表示的。这个特性被称为 **同像性**(homoiconicity)。
```lisp
CL-USER> (+ 1 2)
3
CL-USER> '(+ 1 2)
(+ 1 2)
```
我使用了两个内容相同的列表;唯一的区别是我在第二个前面加了一个 `'`,但输出不同。第一个列表被视为 **代码**。它被求值,具体来说,符号 `+` 映射到一个执行参数求和的函数。Lisp 使用所谓的**波兰表示法**(https://en.wikipedia.org/wiki/Polish_notation),函数名是列表的第一个元素,后面跟着参数。所以我们写成 `(print "hello")` 而不是 `print("hello")`,把函数名放在括号内。输出是 3,这是代码执行的结果。
第二个列表被视为 **数据**。`'` 是一个操作符,告诉解释器不要对后面的列表求值,而是将其保留为可操作的数据。输出是同一个列表,没有被求值。
代码和数据之间的这种微妙区别正是宏成为可能的原因。我们可以像操作数据一样操作代码。通过转换源代码,我们能够编写 **能编写程序的程序**,这种能力其他语言花费了几十年时间试图模仿。扩展语言意味着,通过为程序特定领域创建更具表达力的新构造,可以使代码更 **简洁**。重复的样板代码可以用自定义宏替换,以缩短代码并节省时间。可以创建新的控制结构来控制资源访问和形式的求值。宏可以生成代码、提高性能,最重要的是,创建更易于使用的新语法抽象,以隐藏复杂且容易出错的代码。
## 一个活的系统
Lisp 不仅仅是一种编程语言,它是一个 **活的系统**。为了阐明这一点,让我们先看看 Lisp 的工作流程与其他语言有多么不同。对于其他语言,你打开编辑器中的项目,然后开始写代码。而 Lispers 做的第一件事是启动一个 Lisp 进程,将其连接到 REPL,然后将项目加载到该进程中。**读取-求值-打印循环**(REPL)是一个交互式环境,用于对代码求值并立即看到结果。它是 Lisp 正在运行的程序的一个窗口,这个程序目前正在运行你的程序。
在其他语言中,一旦你修改了代码,你必须停止并编译,然后运行项目来观察、测试和调试更改。如果有问题,你又回到写-编译-运行-调试的循环中。相反,Lispers 不断地在运行中的进程里对代码求值,并直接在 REPL 中观察输出,因为 **Lisp 进程就是正在运行的程序**。每个函数都可以立即在 REPL 中作为一个独立的代码单元进行测试,其他开发有用的操作也是如此,比如查询数据库、检查变量或调试。
![[Lisp REPL]](https://scotto.me/assets/img/REPL.png)
一个使用 Calva 和 Clojure 的 REPL 示例。求值结果打印在每行附近,也在输出窗口中。
一个 Lisp 进程通常可以连续运行数周,因为没有必要停止它。它可以长时间保持在编辑器中的 REPL 连接。新的函数、宏和变量被持续定义和重定义,这个过程称为 **绑定**,发生在一个称为 **环境** 的内部内存中。这种特定的工作流程甚至有一个名字:**REPL 驱动的开发**。
这是一个真正的杀手级特性。自 60 年代 Lisp 发明以来就一直存在,并且(在可能的情况下)被其他语言模仿,就像许多其他 Lisp 原创特性一样。*热重载* 这个词让你想到了什么吗?我很幸运,因为我职业生涯一开始就从事 Web 前端开发,应用的 JavaScript 代码会在每次页面硬刷新时热重载。得益于现代工具,我现在只需要 *保存文件*,网页就会自动刷新并应用新的更改。在底层,热重载意味着有一个进程在监视文件,重新编译更改过的模块,并使用 WebSocket 连接静默地将更改注入页面。
但并非所有软件栈都这么幸运。一些后端框架仍然需要重新编译;桌面和移动开发的情况则参差不齐;低层语言每次都需要完整编译。在 Lisp 中,热重载并不是一个独立的工具或技术。它是在实时环境中对代码求值时自然产生的。每次我重定义一个符号,它就会采用新的定义,无论是函数、数据还是宏。我不需要停止和编译,不需要单独运行测试,也不需要特殊的调试器。一切都在 REPL 中通过对代码求值自动完成。
一个很好的类比是,用 Lisp 编程更像是 **进化** 一个程序,而不是构建它。一旦一个新的 Lisp 进程启动并且代码被求值到其中,那个系统就会成为程序本身。Lisp 让你以新的方式进行编程。你可以在编写程序的同时进化它们,同时,如前所述,根据需求进化语言本身。一个 Lisp CAD 程序通过将 Lisp 转化为一种编写 CAD 程序的语言而进化。一个高频交易系统通过适应于编写类似交易系统的语言而进化。这就是 Lisp 的本质,它提供了许多优势。
## 可扩展的软件
这可能是甚至更不为人所知的一点,但上面解释的两个特性(语言可扩展性和 Lisp 活系统)使你能够轻松地 **创建可扩展的软件**。我们习惯于桌面程序主要通过插件进行扩展,例如文本编辑器,或者有时通过脚本语言进行自定义,如游戏 mod。这些解决方案通常运行良好,但它们要求程序员设计和维护一整套可扩展性系统,并要求用户学习一套特定的 API 来编写插件或扩展。在 Lisp 中,这几乎是自然实现的。在扩展语言的同时,Lispers 编写宏来封装操作并使代码易于重用。这样做,他们不仅仅是创建新的符号,实际上是在为程序编写 **DSL**(领域特定语言)。当 Lispers 需要让他们的软件可扩展时,他们只需要让用户使用他们所创建的 DSL。
为了理解这个概念,让我们看一些使用场景。我编写了一个自定义的 CMS 服务器来服务客户的网站。我编写了一些宏来在服务器端生成网页。
```lisp
(html
(:h1 "Lorem Ipsum")
(:p "Dolor sit " (:b "amet") "."))
```
我可以把我内部编写的同一个 DSL 暴露给用户,这样他们就可以编写自己的视图并自定义他们的 CMS 实例。
```lisp
;; 用户可以使用 Lisp 的全部能力编写动态页面
(let ((user-name "Alice")
(items '("Apples" "Bananas" "Oranges")))
(html
(:h1 (format nil "Welcome back, ~A!" user-name))
(:ul (dolist (item items)
(html (:li item))))))
```
注意 `html` 宏是如何与 Lisp 的变量(`user-name`)、循环(`dolist`)和字符串格式化(`format`)无缝集成的。在传统的模板语言中,你需要学习一种单独的语法,使用像 `{{ user_name }}` 或 `{% for %}` 这样的特殊括号。而在 Lisp 中,你不需要一门新的语言,因为你只需使用 Lisp 本身来构建模板。这种集成意味着你的 DSL 继承了 Lisp 的所有能力:条件、递归、高阶函数,甚至可以在 REPL 中逐步调试模板的能力。
另一个场景:我编写了一个数学软件,用户可以编写公式,程序将它们渲染在笛卡尔坐标系上。所以我编写了一个 DSL 来编写数学和绘图。我可以向用户暴露语言的一个小子集,这样他们就可以直接绘图,而不必使用数学公式。
```lisp
(graph
(:curve (lambda (x) (* x x)) 0 0) ; 绘制 y = x²
(:point 0 0)) ; 标记原点
```
使用这个 DSL,用户可以用最少的代码绘制复杂的数学函数。由于它只是 Lisp,他们可以使用变量、定义辅助函数,甚至可以在循环中生成多条曲线。这为用户提供了一个干净的接口,同时保留了底层系统的全部灵活性。
## 结论
Lisp 不仅仅是一门语言,它是一种思维方式。它教会你,编程语言不是固定不变的,而是可以根据问题进行调整的工具。通过宏、同像性和 REPL 驱动的开发,Lisp 提供了独特的可能性,让你能够
¹ 这个说法由 Douglas Crockford 推广,但可能更早出现。
相似文章
Lisp在网页应用中的运用(2001)
Paul Graham结合自己创办Viaweb的经验,讨论了在网页应用中使用Lisp的优势,包括语言自由度、增量开发以及快速修复bug。
Lisp 对 Ruby 的影响
一篇技术博客文章,探讨 Ruby 的设计(包括闭包、一等函数、符号和方法命名约定)如何受到 Lisp 的影响。
在浏览器中试用 LispE
关于在浏览器中试用 LispE(一种 Lisp 方言)的简要介绍或链接。
Hyperpolyglot Lisp:Common Lisp、Racket、Clojure、Emacs Lisp
一份对照参考表,比较Common Lisp、Racket、Clojure和Emacs Lisp的语法与特性。
一次一台Lisp机器,创造未来
Larry Masinter和Frank Halasz回顾了他们在Xerox PARC的经历,讲述了Interlisp和NoteCards的开发,以及当前Medley/Interlisp的重生计划,反思了研究文化以及早期计算环境的持久价值。