资讯详情

Lisp入门到项目实战:函数式编程、REPL与宏

📅 2026/10/29 18:35:03 | 华诺云谱 👁 阅读
Lisp入门到项目实战:函数式编程、REPL与宏
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求值是42abc求值是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、cdrLisp 的列表不是数组是链式结构。每个节点叫 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)) ; 3let* 是顺序绑定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 最大的收获不是多了一门能写在简历上的语言而是你在看任何代码时都会多问一句“这层抽象是不是可以再往上抽一层”。这种思维习惯跟具体用什么语言写代码没关系。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑