之前在做 Web 后端时,总能看到 JDK 8 之后 Java 里大量出现lambda表达式,C++ 也在 C++11 标准里引入了 lambda。时间久了,会想当然地认为 lambda 就是“匿名函数语法糖”。直到有人推荐我去读一本叫《Let Over Lambda》的英文书,我才意识到 lambda 背后还有一层被大多数人忽略的图景:它不仅是写回调、写链式调用的工具,更是理解闭包、宏、代码生成、DSL 的入口。这本书官网提供了 PDF 免费在线阅读版本,内容硬核,但很多初学者拿到书后不知从哪读起。这篇文章结合原书核心思想、Common Lisp 示例,以及 Java / C++ 的 lambda 对比,帮大家梳理出一条顺畅的阅读与练习路径。无论你只是想搞懂 lambda,还是想深入研究 Lisp 宏,这篇都能给你一个完整框架。
1. 《Let Over Lambda》是什么:一本讲透闭包与宏的书
1.1 书名来源
《Let Over Lambda》这个书名看起来像一句代码,其实它本身就是一种编程模式的缩写。
在 Common Lisp 中,let用来创建词法环境,lambda用来创建函数。把let写在lambda外层,也就是“让let包住lambda”,函数体里的自由变量就会被let捕获,形成闭包。这种“外层let+ 内层lambda”的结构,被称为let over lambda。
(let ((x 10)) (lambda (y) (+ x y)))上面这段代码返回一个函数,这个函数可以记住x = 10。即使外层let已经结束,函数内仍然可以访问x。这就是闭包的最小形态,也是整本书最核心的模式基础。
作者 Doug Hoyte 用这个模式作为书名,用意很明显:这本书不是在讲 API 怎么调用,而是在讲“用 Lisp 的方式思考”。当你理解了let over lambda,你就理解了词法作用域、闭包、状态封装,再往深走,就能理解宏为什么能改变语言本身。
1.2 这本书解决什么问题
《Let Over Lambda》最大的价值在于,它直接挑战了主流编程语言的教育盲区:大多数人会用函数,但不理解闭包底层发生了什么;大多数人知道宏的存在,但不知道宏能做什么;大多数人写代码时,代码和数据泾渭分明,但 Lisp 里二者可以互相转化。
这本书围绕以下核心问题展开:
- 如何在 Common Lisp 中利用闭包封装状态,写出类似面向对象的代码。
- 如何用宏在语法层面对语言进行扩展,创造出新的控制结构和领域专属语言。
- 如何写出“生成代码的代码”,也就是宏的宏。
- 如何理解 anaphoric 宏、符号宏、读表宏等高级宏技巧。
- 如何在 Lisp 中实现对象系统、自定义控制流、延续等高级抽象。
很多读者读这本书,不只是为了学 Lisp,而是想理解“代码生成”和“语言扩展”背后的本质思想。这些思想放到今天依然不过时:注解、AOP、代码生成器、模板引擎、DSL 设计,本质上都在做类似的事。
1.3 适合谁读
这本书不适合没有任何编程经验的人。它默认读者已经具备一定编程基础,最好了解基本的数据结构、递归、函数式编程思想。
适合阅读的人群包括:
- Common Lisp 初学者,已经掌握基本语法,想深入闭包和宏。
- 对函数式编程感兴趣的 Java / C++ / Python 开发者,想理解 lambda 和闭包的底层机理。
- 对 DSL 设计、代码生成、编译原理感兴趣的工程师。
- 被
On Lisp中宏内容吸引,想继续深入宏编程的读者。
如果你只想知道 Java lambda 怎么用,那这本书不是必读;但如果你想问“lambda 为什么会出现在现代语言里”,这本书会给你一个非常深刻的答案。
2. 为什么现代开发者值得读这本书
2.1 Lambda 已经从 Lisp 走进主流语言
现在回看 Java 8 和 C++11,lambda 已经成为语言标配。Java 里最常见的写法是这样:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); names.stream() .filter(name -> name.startsWith("A")) .forEach(System.out::println);C++ 里更复杂一些,lambda 可以按值或按引用捕获外部变量:
#include <iostream> #include <vector> #include <algorithm> int main() { std::vector<int> values = {1, 2, 3, 4, 5}; int threshold = 3; auto count = std::count_if(values.begin(), values.end(), [&](int x) { return x > threshold; }); std::cout << count << std::endl; return 0; }这两种现代 lambda 的核心能力都是“创建匿名函数 + 捕获外部上下文”,也就是闭包。闭包概念从 Lisp 时代就已经成熟,今天的语言只是把它包装成更容易接受的语法。
但如果只停留在“会用 lambda”,就无法理解为什么 lambda 可用[&]捕获引用,为什么 Java lambda 要求捕获的局部变量必须是 effectively final。这些细节的背后,是“闭包的内存模型”“变量生命周期”“函数式状态管理”等更深层的问题。
2.2 从“用 lambda”到“写宏”的思维跃迁
《Let Over Lambda》不只是讲 lambda 本身,它更侧重的是 lambda 与宏结合后产生的“语言级扩展能力”。
普通程序是在已有语法规则下写逻辑,宏则是修改语法规则本身。举个例子,Java 里的try-with-resources是一种语法结构,它自动管理资源释放。这个能力在语言层面是固定的,开发者无法自己定义新的资源管理语法。但在 Lisp 里,你可以用宏自己定义一个类似with-open-file的结构,甚至能定义完全属于你自己的控制结构。
这种“代码即数据,代码生成代码”的能力,是 LoL 整本书试图传达的核心精神。
读完这本书,你再去看 Spring 的注解、MyBatis 的动态 SQL、Gradle 的构建脚本时,会有一种“原来底层是这样设计出来的”感觉。因为它揭示了一个通用规律:当你觉得某段重复代码无法用函数消除时,真正需要的是语法层面的抽象,也就是宏。
2.3 免费 PDF 与阅读方式
这本书由 Doug Hoyte 撰写,作者在官网上提供了免费在线阅读版本,支持 PDF 格式,不需要付费购买就能看到完整内容。对于国内读者来说,有三个建议:
- 至少准备一个支持中英对照的阅读工具,不一定要全文翻译,但要能快速查词。
- 阅读时尽量不要跳着看,前面几章是后面所有宏技巧的基础。
- 只看不练效果很差,配置好 Common Lisp 环境,边读边敲。
3. 读这本书之前的环境准备
3.1 需要哪些基础
在阅读《Let Over Lambda》之前,并不需要你完全学完一门 Lisp 方言,但至少应该掌握以下基础知识:
- 基本的表达式语法,比如
(defun ...)、(defparameter ...)、(let ...)、(lambda ...)。 - 函数调用和函数定义的区别。
- 列表和 cons cell 的基本概念。
- 递归函数的写法。
- 变量作用域的基本理解:全局变量、局部变量、词法变量。
如果这些概念还不熟悉,建议先读一遍《Practical Common Lisp》的前面章节,或者看一些 Common Lisp 快速入门教程,再开始读《Let Over Lambda》,体验会好很多。
3.2 安装 Common Lisp 环境
读这本书需要动手运行大量示例,所以我建议先装一个 Common Lisp 实现。目前最常见的是 SBCL,它免费、跨平台、优化好,适合学习和跑示例。
在 Ubuntu / Debian 系统上,可以用 apt 安装:
sudo apt update sudo apt install sbclmacOS 上使用 brew:
brew install sbclWindows 上可以参考 SBCL 官方发布页下载安装包,或者使用 WSL 在 Ubuntu 子系统中安装。
安装完成后,输入sbcl就能进入交互式环境:
$ sbcl This is SBCL 2.x.x, an implementation of ANSI Common Lisp. * (+ 1 2) 3 **是 REPL 提示符,在提示符后输入表达式,回车即可看到结果。这本书里的代码,绝大部分都可以直接复制到 SBCL 中运行。
如果你不想在命令行折腾,也可以使用在线 Common Lisp 解释器,但建议还是本地安装,因为后续可能需要加载文件、调试宏展开,本地环境更方便。
3.3 开发环境选择
Lisp 社区最经典的开发环境是 Emacs + SLIME,但这套组合对新手有一定学习成本。更友好的方式是 VS Code 加上 Alive 或者 Common Lisp 相关的扩展,也可以直接使用 SBCL 自带的命令行 REPL。
推荐组合:
- 先使用 SBCL 命令行跑通代码。
- 再使用 VS Code 编辑
.lisp文件,配合终端运行。 - 等需要调试复杂宏时,再尝试 Emacs + SLIME,或者 Portacle、Lem 等更完整的 Lisp IDE。
不推荐一开始就折腾复杂编辑器配置,因为学习 Lisp 的核心是语言本身的思维,而不是编辑器的花哨功能。
4. 核心概念拆解:从闭包到宏
4.1 什么是 let over lambda 模式
来看最基本的let over lambda例子:
(defun make-adder (x) (lambda (y) (+ x y))) (defparameter add10 (make-adder 10)) (funcall add10 5) ;; => 15make-adder返回一个匿名函数。这个匿名函数内部使用了x,但x是make-adder的参数。函数返回后,x本来应该失效,但闭包保留了x的值。调用add10时,仍然能访问x = 10。
这个过程可以用一句通俗的话解释:let创建了一个“环境”,lambda把这个环境“装进”一个函数里带走。
4.2 闭包的威力:用闭包模拟对象
面向对象的核心能力之一是封装状态。Lisp 没有强制要求你必须用defclass或者结构体,闭包本身就能实现类似效果。
下面用闭包实现一个简单的账户对象:
(defun make-account (initial-balance) (let ((balance initial-balance)) (lambda (op &optional amount) (ecase op (:deposit (incf balance amount)) (:withdraw (decf balance amount)) (:balance balance))))) (defparameter account (make-account 100)) (funcall account :deposit 50) ;; => 150 (funcall account :withdraw 30) ;; => 120 (funcall account :balance) ;; => 120这个示例里,balance被let封闭在闭包内部,外部只能通过传入的操作符间接访问。这就是信息隐藏,也是面向对象编程中“对象持有私有状态”的天然实现。
《Let Over Lambda》从这里引申出一个观点:与其说 Lisp 没有面向对象,不如说 Lisp 提供了更底层的构造能力,对象的本质就是“环境 + 函数”。你可以用闭包实现对象,也可以用宏实现完整的对象系统,甚至能设计出自己的多重继承方案。
4.3 宏:在语法层面写代码
函数接收的是值,返回的是值;宏接收的是代码,返回的是代码。这是宏与函数最本质的区别。
考虑这样一个场景:你想在某个代码块执行完毕后打印耗时。用函数实现会比较别扭,因为函数无法拿到调用者的代码块;但用宏很容易实现。
(defmacro with-timer (&body body) `(let ((start (get-internal-real-time))) ,@body (format t "elapsed: ~a~%" (- (get-internal-real-time) start))))使用示例:
(with-timer (sleep 1) (format t "work done~%"))这段宏展开后,相当于在代码块前后插入了计时逻辑。宏没有创建一个新函数,而是对调用处的代码进行了改写。
这就是“代码生成”。虽然现代语言里反射、注解处理器、代码生成器也能做类似的事,但 Lisp 宏的优势在于:
- 宏在编译期执行,不会带来运行时开销。
- 宏完全遵循宿主语言的语法,不需要额外解析字符串。
- 宏生成的就是普通代码,可以继续被其他宏处理。
这本书后面还会介绍更复杂的宏,比如回指宏(anaphoric macro)、符号宏、读表宏等。它们的共同思路是:不要接受语言现有的语法边界,而是把语言当成可塑的工具。
4.4 从宏到 DSL 再到“写宏的宏”
《Let Over Lambda》最大的看点,不是告诉你“宏能做什么”,而是带你一步步设计出完整的领域语言。
举个例子,如果业务里经常需要重试失败操作,你可以设计一个with-retry宏:
(defmacro with-retry (retry-count &body body) `(let ((attempt 0)) (loop (handler-case (return (progn ,@body)) (error (e) (incf attempt) (when (>= attempt ,retry-count) (error e)) (format t "retry ~a~%" attempt))))))当然,这个实现还需要考虑延时、异常类型等细节。但重点在于:宏允许你把“重试”这个业务概念,变成可复用的语法结构。调用处不再需要手写try-catch和循环,只需要写:
(with-retry 3 (dangerous-operation))这种把复杂控制逻辑封装成语法结构的方式,就是 DSL 设计的核心。书中后半部分甚至展示了如何用宏动态生成宏,让代码生成的力度更进一步。
5. 与 C++ / Java lambda 表达式的对比
5.1 现代语言里的 lambda 只是“闭包”
Java 和 C++ 中的 lambda,本质上是“匿名函数 + 闭包捕获”,能力范围主要集中在函数式编程和回调场景。
Java 示例:
Function<Integer, Integer> multiply = x -> x * 2; System.out.println(multiply.apply(5)); // 10C++ 示例:
auto multiply = [](int x) { return x * 2; }; std::cout << multiply(5) << std::endl; // 10Lisp 示例:
(setf multiply (lambda (x) (* x 2))) (funcall multiply 5) ;; => 10三者语法不同,但闭包模型很接近。区别在于:
- Java lambda 对捕获变量限制较严格,局部变量必须是 effectively final。
- C++ lambda 可以按值或按引用捕获外部变量,还支持移动捕获。
- Lisp 则没有把这些当作特殊语法,闭包从一开始就是语言的基础设施。
5.2 语法对比表
| 语言 | 定义形式 | 捕获外部变量 | 核心应用 |
|---|---|---|---|
| Common Lisp | (lambda (x) (* x 2)) | 天然支持词法闭包 | 函数式抽象、高阶函数 |
| Java | x -> x * 2 | 局部变量必须 effectively final | Stream、回调、函数式接口 |
| C++ | [](int x) { return x * 2; } | 支持[]、[&]、[=]等捕获方式 | STL 算法、回调、异步任务 |
| Python | lambda x: x * 2 | 支持闭包 | 简洁表达式、高阶函数 |
这个对比能看出来,现代语言都在尝试把“闭包”这个能力用更贴近主流的语法表达出来,但背后的模型,Lisp 几十年前就已经说清楚了。
5.3 能力边界对比
现代语言的 lambda 与 Lisp 的 lambda 最大差异,不在 lambda 本身,而在 lambda 周围的环境。
Java 和 C++ 的语法结构是相对固定的。Java 无法让开发者定义全新的语法来控制方法调用,C++ 虽然有模板元编程和宏,但宏的展开机制比较原始,容易踩坑。而 Lisp 中函数与宏都是普通代码,lambda 和宏可以互相嵌套,形成高度灵活的抽象。
这就是为什么《Let Over Lambda》不只讨论 lambda,而是花大量篇幅讨论宏。作者想表达的是:lambda 只是“值级别的函数”,宏才是“语法级别的函数”。当两者结合,你写代码的维度和普通语言完全不同。
6. 从书中提炼的实战示例
6.1 用闭包实现一个计数器
先看一个经典实例:计数器工厂。
(defun make-counter () (let ((count 0)) (lambda () (incf count)))) (defparameter counter (make-counter)) (funcall counter) ;; => 1 (funcall counter) ;; => 2 (funcall counter) ;; => 3这个例子中,count被闭包保存,每次调用都会在上一次基础上加一。这不是全局变量,每个make-counter返回的计数器相互独立:
(defparameter counter2 (make-counter)) (funcall counter2) ;; => 1 (funcall counter) ;; => 4理解闭包的内存机制后,这段代码一点也不神秘:每个函数对象都携带了自己的词法环境。
6.2 用宏实现一个简易计时 DSL
下面这个with-timer宏实现了一个简单的计时 DSL,适合用来分析某段操作的耗时:
(defmacro with-timer (&body body) `(let ((start (get-internal-real-time))) (progn ,@body) (format t "elapsed: ~a ms~%" (round (* (- (get-internal-real-time) start) 1000 internal-time-units-per-second)))))运行示例:
(with-timer (sleep 1)) ;; => elapsed: 1001 ms关键在于反引号与逗号的使用。反引号表示“这是一个模板”,逗号表示“这里插入实际值”,@表示展开列表。掌握这些语法后,写宏就变成“设计代码模板”。
6.3 用 let-over-lambda 实现一个账户对象
前面已经提过账户对象,这里再补充一个更完整的版本,支持查询当前余额和交易记录:
(defun make-account (initial-balance) (let ((balance initial-balance) (log '())) (lambda (op &optional amount) (ecase op (:deposit (incf balance amount) (push (list :deposit amount) log) balance) (:withdraw (when (> amount balance) (error "Insufficient funds")) (decf balance amount) (push (list :withdraw amount) log) balance) (:balance balance) (:log log)))))这个模式在《Let Over Lambda》中反复出现:外层let保存状态,内层lambda对外暴露操作接口。相比直接定义结构体,闭包方式更灵活,因为你可以非常自然地隐藏内部状态,只暴露需要暴露的行为。
7. 常见问题与学习建议
7.1 读不下去怎么办
很多读者第一次打开这本英文 PDF,会被满屏括号和宏术语劝退。这不是你的问题,而是阅读顺序的问题。
建议按下面的顺序阅读:
- 先跳过序言和宏的高级章节,只读前面与 lambda、闭包相关的部分。
- 每出现一段代码,就在 SBCL 里运行一遍,不要只靠“看”理解。
- 把书里的代码改一改,比如改变量名、调整参数,观察结果变化。
- 遇到读不懂的宏,先在纸上手动展开,再对照
macroexpand-1的输出。
Common Lisp 中可以用macroexpand-1查看宏展开结果:
(macroexpand-1 '(when t (format t "hello")))输出会显示宏展开后的代码,这对理解宏非常有用。
7.2 宏与函数有什么区别
这是初学者最容易混淆的问题。最简单的判断标准:
- 函数接收运行时值,返回运行时值。
- 宏接收源代码形式,返回新的源代码形式。
宏在代码执行前发生作用,函数在执行时发生作用。正因为宏在更高层操作代码,所以它可以实现普通函数做不到的事情,比如延迟求值、自定义控制流、创建新的绑定作用域等。也正因如此,宏更难调试,使用起来需要格外克制。
7.3 这本书里的代码能直接跑吗
大部分早期章节的代码可以直接在 SBCL 中运行,但需要注意:
- 个别代码依赖第三方库,比如
usocket、cl-ppcre等,需要先用 Quicklisp 安装。 - 代码基于 Common Lisp 标准编写,不是 Clojure,也不是 Racket,语法和 API 会有差异。
- 书中一些示例偏向演示概念,直接用到生产环境前需要充分测试。
建议准备一个简单的.lisp文件,按章节组织实验代码,不要把所有示例都塞进 REPL。
8. 宏编程的最佳实践与工程建议
8.1 宏的适用边界
宏不是银弹。写宏之前,可以先问自己:
- 这个问题能通过函数解决吗?如果函数能解决,优先用函数。
- 是否需要在调用处展开大量模板代码?
- 是否需要创建新的绑定关系或延迟求值?
- 是否会对现有代码产生额外的可读性负担?
宏适合封装重复的语法模式,但一旦宏过于复杂,调试成本会迅速上升。一个宏应该尽量简洁、容易展开、行为可预测。
8.2 代码生成与调试
Lisp 宏展开发生在编译期,因此报错信息往往指向展开后的代码,而不是你写的宏调用。遇到这种情况,先用macroexpand-1或macroexpand查看完整展开结果,再判断是生成逻辑错了,还是展开后的代码本身有问题。
建议在宏里使用唯一的嵌套命名,避免捕获调用处的同名变量;如果宏内部需要中间变量,可以使用gensym生成符号:
(defmacro my-dotimes (n &body body) (let ((var (gensym))) `(let ((,var ,n)) (dotimes (,var ,var) ,@body))))gensym生成的符号是全新的、不与他人冲突的符号,这是宏工程中最重要的细节之一。
8.3 把 Lisp 思想带回日常项目
即使你不打算在生产环境使用 Common Lisp,这本书里的思想依然可以迁移到日常项目:
- 设计可重复使用的回调结构时,学习闭包的状态封装思路。
- 写 Java 注解处理器或代码生成器时,学习宏的模板展开思想。
- 设计 YAML、JSON 等外部 DSL 时,学习 Lisp 宏如何控制表达力。
- 写测试框架时,学习如何通过代码生成减少重复断言。
现代语言中,反射和代码生成工具越来越强,但理解源码级别的抽象仍然是基础。读过这本书后,再看 Spring 的 AOP、MyBatis 的动态代理、Gradle 的 Task 编排,都会有更清晰的底层感知。
9. 总结与下一步学习路线
《Let Over Lambda》不是一本轻松的入门书,但它是深入理解 lambda、闭包和宏编程最直接的路径之一。它用一个看起来很简单的let over lambda模式,串起了词法作用域、闭包、状态封装、宏展开、代码生成和 DSL 设计等一系列高阶主题。
如果你刚开始读,建议按“闭包 -> 宏 -> 宏展开 -> DSL”的顺序推进,每个知识点都动手运行。读完后可以做两件事:
- 用 Common Lisp 写一个小工具,比如配置解析器、任务调度器,把宏用在真正能减少重复代码的地方。
- 回去复习 Java / C++ 中的 lambda 与 Stream API,思考它们背后的闭包模型与限制原因。
如果本文对你有帮助,可以收藏备用。后面我会再整理 Common Lisp 宏展开的详细调试笔记,以及 Lisp 中对象系统的多种实现方式,欢迎持续关注。