Lisp 这门语言我断断续续接触了几年,最初的目的很功利——只是想搞明白“函数式编程”这四个字到底在说什么。结果一上手就发现,它跟主流语言的思路完全不是一条路:变量可以不改,函数可以当参数传,代码本身就是一个可以被程序操作的数据结构。这篇内容写给两类人:一类是写过 Python、JavaScript 或者 Java,想认真补一补抽象能力的中级开发者;另一类是刚入门、被括号吓退过、想找一条不绕弯的学习路径的新手。我会从环境搭建一路写到能跑出一个小项目,把每一步的“为什么这么做”都讲清楚,也包括我自己踩过的那些坑。
1. 为什么一门六十多岁的老语言还值得从零学起
1.1 Lisp 到底是什么:别被括号劝退
上世纪五十年代末,一位做人工智能方向研究的学者提出了一套以“符号列表”为核心的计算模型,Lisp 这个名字就是从“列表处理”来的。那时候的编程语言大多围着数字转,它却把“符号”和“列表”当成一等公民,这个选择影响深远——直到今天,你写 Python 时的列表推导、写 JavaScript 时的箭头函数、写 React 时的“数据即视图”,都能隐约看到那条思想的影子。
刚接触的人最直观的感受就是“括号多得离谱”。我第一次看 Lisp 代码时,脑子里只有一个念头:这玩意儿凭什么能写东西?后来才慢慢明白,那些括号不是装饰,而是语法树的直接书写形式。别的语言里,你得在脑子里把a + b * c解析成一棵运算树;Lisp 里,(+ a (* b c))本身就是那棵树。省掉了“解析”这一步,编辑器和工具就能直接理解代码结构,这也是为什么 Lisp 的编辑器集成能做到那么细——它不猜,它知道你写的是哪个函数、几个参数、哪一层嵌套。
更关键的一点叫同像性:Lisp 代码的文本形式,恰好就是这门语言最基础的数据结构——嵌套列表。这句话听起来抽象,但它是后面所有“魔法”的根源。你能写出一个函数去生成代码、改写代码、拼接代码,因为代码本身就是列表,列表就是数据。宏之所以在 Lisp 里那么自然,在别的语言里却要搞编译器插件、注解处理器,原因就在这里。
所以学 Lisp 的第一课不是背语法,而是换一个视角:你写的不是“指令序列”,而是“数据结构”。想通这一点,后面的一切都顺了。
1.2 学它到底图什么:三个能落地的收益
我从不建议别人为了“情怀”去学一门语言。Lisp 值得投入,是因为它能在三件事上给你实打实的回报,而且这些回报会迁移到你日常用的主力语言里。
第一个收益是抽象边界的理解。大多数语言里,函数是最小的复用单位。Lisp 里,宏让你能自定义“语法”,也就是说,你不仅能在语言里写程序,还能在语言上长出一层专门描述你这个领域的小语言。写过宏之后,你再回头看 Java 的注解、Python 的装饰器、C 的宏,会瞬间明白它们各自解决了什么问题、又各自妥协在哪里。这种“看到语言设计意图”的能力,不是靠读文档能练出来的。
第二个收益是交互式开发习惯。Lisp 的 REPL 不是“跑一段试试”,而是真正的开发主界面。你写好一个函数,按一下快捷键,它就编译进正在运行的程序里,你可以拿真实数据立刻验证。程序不需要重启。这种反馈速度一旦习惯,就很难回去忍受“改一行、等三十秒编译、再点一遍界面”的循环。我现在写任何语言都会下意识地去找它的 REPL 或者热重载方案,这个习惯就是被 Lisp 教出来的。
第三个收益是递归和不可变思维。Lisp 里循环语法有,但社区更推崇用递归和高阶函数表达。刚开始我会觉得别扭,写多了才发现,用map、reduce、filter描述“对一批数据做什么”,比用for循环描述“第几步做什么”,出错的概率低得多——因为你不用管索引、不用管边界、不用管临时变量。
1.3 谁适合学,谁可以先放一放
说句实在话,Lisp 不适合所有人,也不适合所有阶段。
适合的情况:你已经有至少一门语言的实战经验,能独立写完一个小项目;你对“代码怎么组织”“抽象怎么做”有困惑,想找一套不同的答案;你做的方向涉及符号计算、规则引擎、DSL 设计、文本处理、AI 相关的老代码维护。这几种情况下,投入两三周学 Lisp,回报率很高。
可以先放一放的情况:你还在纠结“变量和作用域是什么”,那先把手头那门语言写熟更重要;你的团队项目强依赖某个框架生态,短期内不可能引入 Lisp,那把它当“思维训练”看就好,别指望马上变现;你只是想找个快速出活的工具,那它确实不是——它的优势在长期抽象,不在短期堆功能。
还有一点要提前打预防针:Lisp 的社区小,遇到问题搜索到的答案可能只有几条。这既是缺点也是优点——缺点是你不能像查主流语言那样一搜一大把,优点是你被迫去读源码、读标准文档,而这个过程中建立的底层理解,比抄十段示例代码都值钱。
2. 环境搭建:十分钟把 REPL 跑起来
2.1 解释器怎么选:别在选型上卡三天
Lisp 不是一门语言,而是一个语言家族。刚入门最容易被这个搞晕:Common Lisp、Scheme、Clojure、Emacs Lisp……它们共享“S 表达式”这个外形,但语义和生态差别不小。
我的建议很明确:入门选 Common Lisp 的一个主流开源实现。理由是它工业味重、标准文档扎实、内置的宏系统和条件系统(condition system)非常完整,学完之后再看别的方言会很轻松。Scheme 更简洁、更适合教学,但工程配套弱一些;Clojure 跑在 JVM 上、生态现代,但它依赖一整套 Java 工具链,入门门槛被拉高了。
下面这张表是我实际用过之后的对比,供你快速判断:
| 选项 | 特点 | 适合谁 | 我的评价 |
|---|---|---|---|
| 主流开源实现 A | 编译质量高、社区使用最广、文档全 | 绝大多数入门者 | 首选,闭眼选它 |
| 主流开源实现 B | 启动快、对某些平台支持好 | 想折腾多平台的 | 备选,日常够用 |
| 轻量实现 | 体积小、嵌入方便 | 想在别的程序里嵌 Lisp | 不适合入门 |
| Scheme 系 | 语法极简、教学资源多 | 想先搞懂纯函数式的 | 学完再回来也行 |
选完之后就别再看了,把精力放在写代码上。我在选型上浪费过整整两天,回头看毫无价值。
2.2 装好、跑起来、完成第一次对话
安装比想象中简单。常见的包管理器里都有现成的包,Linux 上一条命令,macOS 上用对应的包管理工具,Windows 直接下官方二进制包解压即可。
# 以常见包管理器为例,具体命令按你的系统来 sudo apt install sbcl # 或者 brew install sbcl装完之后在终端输入sbcl,你会看到一个提示符,这就是 REPL(读取—求值—打印循环)。它做三件事:读入你敲的表达式、求值、把结果打印出来。整个过程是循环的,所以你敲什么都行,它永远等着下一句。
CL-USER> (+ 1 2 3) 6 CL-USER> (list 1 2 3) (1 2 3) CL-USER> (defun square (x) (* x x)) SQUARE CL-USER> (square 7) 49三行代码就能看出这门语言的性格:函数调用统一写成(函数名 参数...),没有中缀、没有优先级问题;defun定义函数,返回的是函数名本身;定义完立刻就能用。
提示:第一次进 REPL 最容易卡住的是“怎么退出”。直接敲
(quit)最稳;如果你不小心进了调试器,输入:q或者选一个 abort 选项就能退出来,别慌,没坏东西。
2.3 编辑器与 REPL 的联动:这才是效率的分水岭
只用终端 REPL 能学语法,但学不到这门语言的真正工作方式。Lisp 的核心体验是“编辑器连着正在运行的进程”,你在编辑器里改函数,按一下快捷键,新版本就替换掉旧的,程序状态不丢。
实现这个体验的经典组合是 Emacs 加 SLIME(或者更新的 Sly)。我知道很多人对 Emacs 有心理阴影,客观说,第一次配置确实要花点时间。但如果你完全不想碰 Emacs,也有替代方案:VS Code 上有对应的 Lisp 扩展,能连 REPL、能求值光标处的表达式、能跳转定义,基本够用。社区还提供过预打包的便携环境,解压就能用,适合不想折腾配置的人。
无论用哪个编辑器,核心操作就四个,记住就能干活:
- 求值光标前的一个表达式:验证一个函数
- 求值整个文件:加载模块
- 编译当前函数:热更新,程序不重启
- 跳转定义 / 查看参数列表:读别人的代码必备
我个人的习惯是,学任何新函数都先在 REPL 里敲一遍,看返回值,再去看文档描述。顺序反了容易“看懂了但不会用”。
2.4 包管理与项目骨架:别再造轮子
学到第三天你就会想:有没有现成的库能用?当然有。社区有一个事实标准的包管理器,第一次用之前在 REPL 里执行两行安装代码,之后就能按名字加载成千上万个库。
;; 第一次使用时安装包管理器(示意) (load "quicklisp.lisp") (quicklisp-quickstart:install) ;; 之后加载任何库就是一句 (ql:quickload "alexandria")项目结构上,Community Standard 用.asd文件描述依赖和组件,配合defpackage定义模块导出。下面是一个最小可用的骨架,可以直接抄:
;;; my-app.asd (asdf:defsystem "my-app" :version "0.1.0" :depends-on ("alexandria") :components ((:file "package") (:file "main" :depends-on ("package"))));;; package.lisp (defpackage :my-app (:use :cl) (:export #:run)) (in-package :my-app) ;;; main.lisp (in-package :my-app) (defun run () (format t "hello~%"))defpackage里的:use表示引入某个包的全部外部符号,:export表示对外暴露哪些。这套东西跟 Java 的 package、Python 的__all__是同一个思路,只是更严格——符号是有“归属”的,两个包里的同名函数不会互相污染。
注意:
quickload会在第一次加载时联网下载,如果卡住,先确认网络,再确认下载目录权限。另外别在项目里硬编码绝对路径,.asd里统一用相对路径,换机器才不会崩。
3. 语法骨架:把括号看成结构而不是噪音
3.1 求值规则就三条,记住就不会迷路
Lisp 的求值规则简单到可以用三句话说完,但必须记牢,因为后面所有的困惑基本都源于没分清这三条。
第一条:数字、字符串、字符这类字面量,求值结果就是它自己。42求值是42,"abc"求值是"abc"。
第二条:符号会被当成变量去查值。你敲x,它会去找x当前绑定的东西,找不到就报“未绑定变量”的错误。这一条是新手最常见的报错来源。
第三条:列表会被当成函数调用。第一个元素是函数,剩下的元素先求值再传进去。(+ 1 2)就是把+找到、把 1 和 2 求值、然后调用。
所有“奇怪”的写法都是这三条的推论。比如为什么要有quote?因为(1 2 3)会被当成“用 1 当函数调用”,而我们只想要那个列表本身。'(1 2 3)就是告诉求值器:别动它,原样返回。
CL-USER> (1 2 3) ; 报错:1 不是一个函数 CL-USER> '(1 2 3) (1 2 3) CL-USER> (quote (1 2 3)) (1 2 3)'x只是(quote x)的语法糖,两者完全等价。理解这一点,你就跨过了第一道门槛。
3.2 列表三兄弟:cons、car、cdr
Lisp 的列表不是数组,是链式结构。每个节点叫 cons,包含两半:前半叫 car,后半叫 cdr。(1 2 3)实际上是一串嵌套:(1 . (2 . (3 . nil))),只是打印时省略了点号。
cons负责把元素拼到前面,car取第一个,cdr取剩下的:
CL-USER> (cons 1 '(2 3)) (1 2 3) CL-USER> (car '(1 2 3)) 1 CL-USER> (cdr '(1 2 3)) (2 3) CL-USER> (list 1 2 3) (1 2 3)理解了 cons 结构,很多“为什么”就通了。为什么取第 n 个元素是线性时间?因为要一个个 cdr 下去,链式结构没有随机访问。为什么往头部加元素很便宜、往尾部加很贵?因为头部只是新建一个 cons 指向原列表,尾部要整个复制。这也是为什么 Lisp 代码里到处是“先收集、最后反转”的写法——先把结果 cons 到前面,最后reverse一次,整体是线性的。
有一套组合函数值得单独记:first/rest是car/cdr的可读别名;second、third一直到tenth直接取对应位置;nth按下标取;last取最后一个 cons(不是最后一个元素,这个坑我踩过);append拼接两个列表,注意它只复制第一个参数的结构。
注意:
append和nconc的区别要搞清楚。前者不破坏原列表,后者会直接改结构、省内存但危险。初学阶段一律用append,等你能说清楚“谁还持有这个列表的引用”时再考虑nconc。
3.3 定义函数、绑定变量、写递归
defun的骨架是(defun 名字 (参数...) 函数体),函数体最后一个表达式的值就是返回值,不需要return。这一点跟很多语言不同,习惯一下就好。
局部变量用let,注意它的绑定是并行的——后面的绑定看不到前面的:
(let ((a 1) (b 2)) (+ a b)) ; 3 (let* ((a 1) (b (+ a 1))) (+ a b)) ; 3,let* 是顺序绑定let和let*的区别经常被忽略。我建议默认用let*,除非你明确知道各绑定之间没有依赖关系。因为一旦用了let又引用了前面的变量,报错信息会让人一头雾水。
递归是这门语言的基本功。写阶乘:
(defun my-fact (n) (if (<= n 1) 1 (* n (my-fact (1- n)))))这段代码能跑,但有个隐患:n大的时候会爆栈。真正工程里写递归,要么用尾递归(最后一步只调用自己,不留下待处理的计算),要么干脆用loop。Common Lisp 标准没有强制要求尾调用优化,但主流实现基本都支持,前提是你用(if ... (my-fact ...))这种“最后一步就是递归调用”的形式,而不是(* n (my-fact ...))这种“递归回来还要乘”。
尾递归版本长这样:
(defun my-fact (n &optional (acc 1)) (if (<= n 1) acc (my-fact (1- n) (* acc n))))多了一个累加器参数,把“还没乘的数”提前乘进去,递归调用就成了最后一步。这个技巧叫累加器模式,写列表处理时会反复用到。
3.4 高阶函数与闭包:函数真的是一等公民
这是函数式编程的核心,也是 Lisp 最舒服的地方。函数可以当参数传、可以当返回值、可以存在变量里。传函数时要用#',比如#'square,它等价于(function square)。
(mapcar #'square '(1 2 3 4)) ; (1 4 9 16) (remove-if #'evenp '(1 2 3 4 5)) ; (1 3 5) (reduce #'+ '(1 2 3 4 5)) ; 15 (funcall (lambda (x y) (+ x y)) 3 4) ; 7mapcar对每个元素应用函数并收集结果;remove-if按谓词过滤;reduce把列表折叠成一个值。这三个加上find-if、count-if、sort,日常处理数据基本够用了。写起来比for循环短,而且意图更清楚——你一眼看到的是“过滤偶数”,而不是“初始化结果列表、遍历、判断、追加、返回”。
闭包更值得单独说。函数可以“记住”它被创建时的环境:
(defun make-counter () (let ((n 0)) (lambda () (incf n)))) (defparameter *c* (make-counter)) (funcall *c*) ; 1 (funcall *c*) ; 2n在make-counter返回之后依然活着,被那个匿名函数捕获了。这就是闭包。它在实际项目里的用途是“携带状态的函数”,比如一个带超时配置的请求函数、一个绑定好数据库连接的查询函数。我以前在别的语言里靠类加字段实现这套东西,Lisp 里几行就完了。
提示:
defparameter和defvar的区别也值得记一下。前者每次都重新赋值,后者只在未定义时赋值。调试时反复加载文件,用defvar能保住你手工改过的值,这个细节在交互式开发里很有用。
4. 从语法到工程:宏、项目结构与一个小实战
4.1 宏:这门语言真正的分水岭
如果说前面都是“语法”,宏就是“元语法”。函数在运行时接收值,宏在编译期接收代码,返回新的代码。因为代码就是列表,所以宏写起来跟写普通函数一样自然。
一个最小的例子,实现unless(当条件为假时执行):
(defmacro my-unless (condition &body body) `(if (not ,condition) (progn ,@body) nil))反引号`表示“模板”,逗号,表示“这个位置要求值后插进来”,,@表示“把列表展开后插进来”。&body收集剩下的所有表达式。
验证宏是否写对,不要直接跑,先用macroexpand-1看它展开成什么:
CL-USER> (macroexpand-1 '(my-unless (= 1 2) (print "a") (print "b"))) (IF (NOT (= 1 2)) (PROGN (PRINT "a") (PRINT "b")) NIL)看到这行展开结果,你就知道它到底生成了什么代码。这是调试宏的唯一可靠方法,我强烈建议每次写完宏第一件事就是展开看一遍。
宏最容易踩的坑有两个。第一个是参数被重复求值:如果宏体里把同一个参数用了两次,而传进来的表达式带副作用,副作用就会执行两次。解决办法是用let先把参数绑到变量上。第二个是变量捕获:宏展开后引入的变量名可能和调用处的变量名撞车。标准做法是用gensym生成唯一名字:
(defmacro with-timing (&body body) (let ((start (gensym "START"))) `(let ((,start (get-internal-real-time))) (prog1 (progn ,@body) (format t "耗时:~A~%" (- (get-internal-real-time) ,start))))))gensym保证不会和用户代码冲突。这个习惯一定要养成,否则某天你会遇到一个“怎么改都不对”的 bug,查半天发现是宏里的临时变量把外面的变量覆盖了。
宏和函数怎么选?我的经验法则:能在运行时用函数解决的,绝不用宏。宏会让代码难以调试(栈信息指向展开后的代码)、难以被工具分析。只有当你要控制“求值时机”或者“引入新的语法形式”时,才上宏。
4.2 小实战:写一个日志统计器
光看语法没用,得跑一个完整的东西。这个项目的需求很朴素:读一个文本日志文件,统计每种状态码出现多少次,按次数从多到少打印出来。麻雀虽小,覆盖了文件读取、字符串处理、哈希表、排序、格式化输出。
先写按空格切分一行的小工具:
(defun split-by-space (line) (loop for start = 0 then (1+ end) for end = (position #\Space line :start start) collect (subseq line start end) while end))这段loop的写法值得拆开看:start初始 0,之后每次更新为上一个空格位置加一;end是下一个空格的位置,找不到就是nil;收集从start到end的子串;while end保证最后一轮切完就停。第一次看可能有点绕,但这是 Lisp 里处理分隔符的经典套路,比手写索引循环清晰得多。
然后是统计主体:
(defun count-status (path) (let ((table (make-hash-table :test #'equal))) (with-open-file (in path :direction :input :if-does-not-exist nil) (when in (loop for line = (read-line in nil nil) while line for parts = (split-by-space line) when (>= (length parts) 9) do (incf (gethash (nth 8 parts) table 0))))) table))with-open-file是标准宏,不管中途是否出错都会关文件,这就是宏的价值——把“打开、用、关闭,出错也要关”这个模式封装成语法。read-line的第二个参数nil表示读到结尾不报错,第三个参数nil是结尾时返回的值,配合while line正好遍历完整个文件。incf配合gethash的默认值0,第一次遇到某个键时自动从 0 开始加。
注意状态码在第几列是依赖日志格式的,我这里按常见的九列格式取了第 9 列。你换成自己的日志,先打印一行parts看清楚结构再改索引,别凭感觉猜。
最后是输出,把哈希表转成列表排序:
(defun print-report (table) (let ((rows (loop for k being the hash-keys of table using (hash-value v) collect (cons k v)))) (dolist (row (sort rows #'> :key #'cdr)) (format t "~A -> ~D~%" (car row) (cdr row)))))sort默认会破坏原列表,这里原列表是临时构造的,所以无所谓。:key #'cdr表示按每对的第二部分(次数)比较,#'>是降序。运行一下:
(count-status "./access.log") (print-report *)REPL 里的*是上一次的结果,这种随手试探的体验是脚本语言里少有的——你不用写print、不用重跑、不用加断点,上一步的返回值就在手边。
4.3 代码组织与测试:把脚本升级成项目
上面三个函数塞在一个文件里能跑,但再长就乱了。工程化的最低要求是:包定义、模块拆分、依赖声明、测试。
包定义要写清楚导出什么:
(defpackage :log-stat (:use :cl) (:export #:count-status #:print-report))测试用成熟度最高的那个测试框架就行,写法很直白:
(fiveam:def-test count-basic () (let ((table (make-hash-table :test #'equal))) (setf (gethash "200" table) 3) (fiveam:is (= 3 (gethash "200" table)))))我个人的习惯是,每个宏和每个涉及副作用的函数都必须有测试。纯函数可以靠 REPL 里手动验证,但宏和副作用一旦出错,往往是在你以为没事的地方悄悄坏掉。
还有一个容易被忽略的点:quickload加载的是已安装的库,你自己的项目要用(asdf:load-system "log-stat")。改完代码重新加载,用(asdf:load-system :log-stat :force t),force会强制重编译,省得你怀疑“是不是没生效”。
5. 常见问题与排查技巧实录
5.1 那些把我卡住过的坑,整理成速查表
学 Lisp 的头两周,我大概有一半时间耗在报错上。下面这些是我真实遇到过的,按出现频率排:
| 现象 | 原因 | 解决 |
|---|---|---|
| 提示未绑定变量 | 符号被当成变量求值了,但没定义 | 检查是不是漏了'或者拼写错了 |
| 提示某对象不是函数 | 列表第一个元素不是函数,或者括号位置错了 | 看展开后的结构,确认函数位在哪 |
| 进了调试器退不出来 | 求值出错自动进入调试器 | 输入:q,或选 abort 选项 |
| 括号对不上 | 编辑时少写或多写 | 用编辑器的括号高亮,别用肉眼数 |
(/ 1 2)返回1/2 | 整数相除得到有理数,不是浮点 | 用(/ 1.0 2)或(float 1/2) |
| 加载文件说找不到 | 相对路径是相对于启动目录的 | 用*default-pathname-defaults*打印当前目录 |
| 改了代码没生效 | 加载的是旧编译产物 | 加:force t强制重编译 |
| 字符串和字符混用 | "a"是字符串,#\a是字符 | 比较前先确认类型 |
有两个坑值得展开说。
第一个是有理数。我第一次写(/ 3 2)得到3/2时愣了半天,以为出 bug 了。其实这是特性:Lisp 默认保留精确分数,不会悄悄丢精度。真想要小数就显式转浮点,这个设计在数值计算里其实更安全,虽然一开始很不习惯。
第二个是修改常量。(setf (car '(1 2 3)) 9)这种写法在某些实现里能跑,但属于未定义行为,因为带引号的列表是编译器眼里的常量,可能被放在只读区。要改就用(list 1 2 3)新建一个,别对字面量动刀子。这个坑我在一个批量处理脚本里踩过,表现是“有时候对有时候错”,查了很久。
再补一个调试技巧:出错进调试器时别急着退出,先用:backtrace或者对应命令看调用栈。Lisp 的调试器会保留出错那一刻的所有局部变量,你可以直接在错误现场求值试探,这是它相比其他语言的一大优势。学会用它,排错速度会快一个档次。
5.2 学习路线与踩坑心得
如果你打算认真学,我给一条我自己验证过的路线,大概三到四周,每天一小时左右。
第一周只做两件事:把 REPL 用熟,把列表操作函数写一遍。不要看宏,不要看对象系统,就写mapcar、remove-if、reduce的组合。这一周的目标是“看到列表处理需求,能条件反射地想到用哪个函数”。
第二周写递归和小工具。阶乘、斐波那契、列表反转、去重、树遍历,每个都手写一遍,再想想能不能用累加器改成尾递归。这一周会很难受,因为递归思维方式要重建,但过了就好了。
第三周学宏和条件系统。先从写unless、when-let这种小宏开始,每次都用macroexpand-1验证。然后读一两个成熟库的源码,看别人怎么组织宏。
第四周做完整项目。找一个你真实需要的场景,比如批量重命名文件、分析日志、生成配置,把它从头写完,包括包定义、测试、错误处理。做完这个,你才算真的“会”了。
几个我踩过的心得,送给正在路上的你。
别一开始就上 Emacs 折腾配置。很多人卡在配置上就放弃了,其实先用终端 REPL 学语法完全够,等 syntax 熟了再折腾编辑器,动力更足。
别抄大段代码。Lisp 的代码密度高,抄一段看不懂的宏进项目,出问题你连调试的方向都没有。宁可写笨一点的版本。
遇到报错先看对象类型。Lisp 的报错信息通常会明确告诉你“期望什么、实际拿到什么”,这句话信息量很大,仔细读往往一眼就能定位。
多读源码。生态小意味着文档不一定全,但源码永远在。按M-.(或编辑器里的跳转定义)跳进去看实现,是这门语言最高效的学习方式之一。
别急着追求“纯函数式”。Common Lisp 本身是多范式的,loop、setf、对象系统都能用。先把问题解决,再考虑怎么写得优雅。我见过太多人为了“纯”把简单逻辑绕成一团,那反而背离了这门语言务实的一面。
最后一个体会是我用了几年之后才想明白的:学 Lisp 最大的收获,不是多了一门能写在简历上的语言,而是你在看任何代码时都会多问一句“这层抽象是不是可以再往上抽一层”。这种思维习惯,跟具体用什么语言写代码没关系。