资讯动态

深入理解lambda与闭包:从《Let Over Lambda》学宏编程

发布时间:2026/8/28 8:08:59 来源:尧图企业网站定制
之前在做 Web 后端时总能看到 JDK 8 之后 Java 里大量出现lambda表达式C 也在 C11 标准里引入了 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 和 C11lambda 已经成为语言标配。Java 里最常见的写法是这样ListString 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::vectorint 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 上使用 brewbrew 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 示例FunctionInteger, 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))天然支持词法闭包函数式抽象、高阶函数Javax - x * 2局部变量必须 effectively finalStream、回调、函数式接口C[](int x) { return x * 2; }支持[]、[]、[]等捕获方式STL 算法、回调、异步任务Pythonlambda 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 中对象系统的多种实现方式欢迎持续关注。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价