news 2026/10/29 18:35:16

从0到1学Lisp:函数式编程、REPL与宏实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从0到1学Lisp:函数式编程、REPL与宏实战指南

我第一次在一份配置文件里看到满屏括号的时候,第一反应是"这东西真的有人手写吗"。后来为了改一段编辑器配置,被迫硬啃了一周的 Lisp,才发现自己被 C 系语言那种"一步一步改内存"的思维框得太久了。函数式编程那一套东西——数据不可变、函数是一等公民、用组合代替状态、用表达式代替语句——其实早就悄悄渗透进了今天几乎所有主流语言,从 Java 的 Stream 到 Python 的列表推导,从 JavaScript 的 map/filter 到 Rust 的迭代器链,追根溯源都能看到 Lisp 的影子。

这篇东西想做的事情很具体:把我从 0 到 1 学 Lisp 这条路上踩过的坑、绕过的弯、以及最后真正觉得"开窍"的那几个瞬间,按顺序摊开讲。Lisp 严格来说不是一个语言,而是一个有六十多年历史的语言家族,Common Lisp、Scheme、Racket、Clojure 各说各话,新手最容易死在选型这一步上;函数式编程也不是什么玄学,它本质上就是把"计算"理解成"表达式求值",而不是"一步步修改内存里的值"。这篇内容适合三类人看:写过至少一门语言但从没接触过函数式范式的开发者、想搞明白宏和元编程到底是什么的工程师、以及被那本经典教材劝退过一次还想再试一次的人。

1. 先把"学哪门 Lisp"这件事定下来

1.1 四个主流方言的真实差异

新手最容易犯的第一个错误,是打开搜索引擎输入"Lisp 教程",然后被一堆互相矛盾的材料同时喂。Common Lisp 的资料告诉你用defun定义函数,Scheme 的资料告诉你用define,Clojure 的资料告诉你用defn加方括号,看完之后你甚至不确定这三样东西是不是同一个语言。原因很简单:它们确实不是同一个语言,共享的只是 S 表达式这套语法外壳和一部分思想血脉。

我建议在动手之前先花二十分钟把下面这张表看完,比盲目跟着某篇教程敲三小时代码更有价值。

方言标准化程度常用实现上手难度学习曲线特征典型用途
Scheme有简洁的官方标准,另有大量 SRFI 扩展Guile、Chez、Racket(可视为超集)低语法极小,概念清晰,但标准库薄教学、语言研究、脚本、嵌入式扩展
Racket独立演进的完整平台Racket 本体低到中文档质量极高,工具链开箱即用教学、DSL 开发、语言实验
Common Lisp有 ANSI 标准,体量庞大SBCL、CCL、ECL中标准库覆盖广,但命名古老,生态偏冷长期运行的服务、AI 领域老项目、独立工具
Clojure无官方标准,社区驱动Clojure JVM、ClojureScript中强依赖 JVM 生态,不可变数据结构贯穿始终后端服务、数据处理、前端(编译到 JS)
Emacs Lisp服务于单一宿主内置低动态作用域为主(新版支持词法),只为改配置编辑器扩展

这张表里最值得说的是 Scheme 和 Common Lisp 的差别,因为它直接决定了你的学习体验。Scheme 的语言核心小到可以在一周内记住,代价是很多日常操作(字符串分割、哈希表遍历、文件路径处理)需要你自己写或者引入第三方库,你会在"这么简单的功能居然没有内置"上反复受挫。Common Lisp 反过来,标准里连loop这种近乎小型 DSL 的迭代宏都有,哈希表、格式化输出、条件系统一应俱全,但它的命名风格停留在上世纪八十年代,car、cdr、cddr、cadar这种名字第一次看会让人头皮发麻。

1.2 我的选型逻辑:先窄后宽,别一上来就挑最肥的

我自己的路径是先 Scheme 后 Common Lisp,现在回头看这个顺序是对的,但理由可能和你想的不一样。选 Scheme 不是因为"它更纯粹",而是因为你在这个阶段需要的是把注意力全部放在概念上,而不是放在库的用法上。递归怎么写、尾调用为什么重要、闭包捕获的是什么、cons和list的关系是什么,这些概念在 Scheme 里没有任何语法噪音干扰,你能看得清清楚楚。

如果你已经有 JVM 经验,或者你的真实目标是"在日常工作里用上函数式思维",那 Clojure 是性价比最高的选择。它能直接调用 Java 生态里的所有库,lein或者官方 CLI 一装就能跑,而且它把不可变数据结构做成了语言默认值——{}就是不可变 map,想改?它会返回一个新的。这个设计会让你在第一周就产生"原来并发问题可以从根上绕开"的体感,比看十篇讲不可变性的文章都管用。

至于 Emacs Lisp,我的建议是不要把它当学习入口。它的历史包袱最重,动态作用域、setq满天飞、没有命名空间,学它学到的很多习惯在别的方言里是反模式。它的正确打开方式是:你已经在用 Emacs 了,想改配置、想写个小插件,这时候顺手学,动力最足。

提示:不要同时开两条线。我见过太多人在第一周同时装 SBCL 和 Racket,然后在"到底该用哪个写练习"的纠结里耗光了热情。选定一个,至少写完三十个小练习再考虑换。

2. 环境搭建:REPL 跑起来这件事,比你想的重要

2.1 三条安装路径,按你的平台挑一条

环境这块我不想写成流水账,但确实有几个人人都要过的坎。先给命令,再讲为什么。

Common Lisp 走 SBCL,这是目前维护最活跃、编译质量最好的开源实现。macOS 上brew install sbcl,Debian/Ubuntu 系sudo apt install sbcl,Windows 建议直接去官网下二进制包,别折腾编译。装完在终端敲sbcl,看到版本号和*提示符就说明成了,退出用(quit)或者:q。

Racket 最省事,官网下载安装包,双击到底,你会同时得到一个图形化的 DrRacket 和一个命令行racket。DrRacket 对新手极其友好,左边写代码右边跑,有专门的括号匹配高亮和步进式调试器,能一步一步看到表达式是怎么被求值的,这个功能在别的地方很难找到。

Clojure 稍微啰嗦一点,它需要 JDK(17 以上都行),然后装官方 CLI 工具。macOS 上brew install clojure/tools/clojure,Linux 上照着官网的一行脚本装。装完clj进入 REPL,第一次启动会拉一堆依赖,慢是正常的。如果嫌 CLI 麻烦,lein也能用,但新一代项目基本都转向 CLI 加deps.edn了。

Scheme 的话,如果你已经装了 Racket,直接#lang racket就能当 Scheme 用(虽然不完全等价);想要更正统的可以用 Guile,brew install guile或者apt install guile-3.0。Guile 的好处是启动飞快,适合写一次性脚本。

场景推荐实现启动命令首次配置耗时
想学概念、跟教材Racketracket约 5 分钟
想写能落地的工具SBCLsbcl约 10 分钟
有 Java 背景、要上生产Clojure CLIclj约 20 分钟(含 JDK)
想写系统脚本Guileguile约 5 分钟

2.2 REPL 不是玩具,它是你的主战场

这一点我要重点讲,因为它是 Lisp 系语言和绝大多数主流语言在工作方式上的根本差别,也是很多人学了一半没感觉出好处的原因。

你在 Java 或者 Go 里写代码,流程大概是:改文件、编译、跑测试、看结果。反馈周期以秒甚至分钟计。Lisp 的 REPL 是另一套东西:你把编辑器连上正在运行的解释器,光标停在一个函数定义上按一个快捷键,这个函数就被重新编译并替换掉了,整个程序还在跑,状态还在,你立刻可以在 REPL 里调用它看结果。这个过程叫交互式开发,它不是"方便一点",而是把开发变成了一个连续的、可探索的过程。

举个具体例子。我在写一个处理日志文件的函数时,不确定某个正则该怎么写,标准做法是去搜文档、试错、再改代码。在 REPL 里我直接输入表达式,看返回值,不对就改一个字符再回车,十秒钟能试二十次。等你习惯了这种节奏,回到"改代码-重启-复现问题"的循环里会觉得慢得难以忍受。

具体操作上,SBCL 里(load "foo.lisp")加载整个文件,(compile-file "foo.lisp")编译成 fasl 再(load "foo.fasl"),在 REPL 里直接(defun ...)定义一个函数,回车即生效。Racket 的 DrRacket 更直观,上面的定义区写完点 Run,下面交互区随便调。Clojure 的clj里(require '[my.ns :as m] :reload)就能热重载命名空间。

注意:REPL 里定义的东西只存在于当前会话。关机即失。我第一周写了几十个练习函数,关掉终端之后全部消失,当时那种感觉记忆犹新。养成习惯:凡是你想留下的代码,立刻粘回文件里。

2.3 编辑器配置的最小可用集

我不打算在这里劝你换编辑器,但有个事实得说清楚:用普通文本编辑器写 Lisp,体验会非常糟糕,因为括号的层级关系靠肉眼几乎不可能维护。你需要的是结构化编辑能力,也就是按表达式而不是按字符来移动、选择、删除。

如果你本来就在用 Emacs,恭喜,装上 SLIME(Common Lisp)或者 CIDER(Clojure)或者 Geiser(Scheme)之后就是满血状态。C-M-f向前跳一个表达式,C-M-k删掉整个表达式,C-c C-c编译光标所在的顶层形式,C-c C-z切到 REPL。这几个快捷键熟练之后,写 Lisp 的效率和写别的语言完全不是一个量级。

如果你不想换编辑器,VS Code 也有能用的插件:Common Lisp 用alive,Clojure 用Calva,Racket 用Magic Racket。这些插件做不到 Emacs 那么细的控制,但括号高亮、结构化选择、发送表达式到 REPL 这些核心功能都有,够用了。

还有一个土办法但极其有效:永远不要让一行超过 80 列,每个嵌套层级固定缩进两个空格,闭合括号单独成行或者堆在末尾都行,但整个文件必须统一。这条规矩听起来像小事,实际上它能让你在三个月后回看自己代码时不至于想删库。

3. 心法篇:S 表达式、递归、闭包、宏

3.1 代码即数据:从 (+ 1 2) 到 quote

Lisp 最反直觉的地方,是它的语法只有一种结构:括号包起来的一串东西。(+ 1 2)是一个列表,第一个元素是函数+,后面是参数。(defun square (x) (* x x))也是一个列表,第一个元素是宏defun。没有运算符优先级,没有if后面必须跟括号这种特例,所有的东西长得一模一样。

这个设计的直接后果是:代码和数据在形式上是同构的。一个列表既可以是被求值的程序,也可以是被传递的数据,区别只在于你求不求值它。(1 2 3)如果直接写,解释器会认为你要调用函数1,报错;写成'(1 2 3)或者(quote (1 2 3)),它就是三元素列表,原样返回。

理解了这一层,car、cdr、cons这几个老古董名字才不显得莫名其妙。它们描述的是链表这个底层结构:cons把两个东西拼成一个点对,car取前半,cdr取后半,list是从头构建一条链。(list 1 2 3)等价于(cons 1 (cons 2 (cons 3 nil)))。你在纸上画一遍这个嵌套,再看(car '(1 2 3))返回 1,就完全通了。

这里有个新手常踩的坑:'只对一个表达式生效,'a b是(quote a)后面跟着一个未定义的b,会报错。想引用一整串要用'(a b)或者(list 'a 'b)。我在这上面浪费过半小时,因为报错信息只说b未定义,完全没提 quote 的事。

3.2 括号阅读法:怎么在一秒内看懂嵌套

第一次看别人的 Lisp 代码,最痛苦的体验是"我知道每个符号什么意思,但我不知道这一坨整体在干嘛"。这不是能力问题,是方法问题。人的眼睛从左到右扫,而 Lisp 的结构是从内往外嵌套的,方向反了。

正确的读法有三条:从最内层的括号开始、先看第一个位置是什么、把缩进当归类标签用。

比如这段(把列表里所有偶数平方后求和):

(reduce #'+ (mapcar (lambda (x) (* x x)) (remove-if #'oddp '(1 2 3 4 5))))

眼睛先落在'(1 2 3 4 5)上,这是输入。然后往外一层是remove-if #'oddp,去掉奇数,剩(2 4)。再往外是mapcar加一个 lambda,每个元素平方,得(4 16)。最外层reduce #'+求和,得 20。整个表达式读下来,就是数据处理流水线,从右往左、从内往外,一层一层的。

养成这个习惯之后,你会开始讨厌那些把逻辑揉成一大坨的代码,因为它违反了这个线性的阅读顺序。这也是为什么 Lisp 社区特别强调"每个函数只做一件事",不是为了好看,是为了可读。

3.3 递归不是炫技,它是 Lisp 的循环骨架

C 系语言里递归是"特殊情况",循环才是常规操作。Lisp 里反过来——早期方言根本没有for循环,所有重复都靠递归。现在虽然有了loop宏,但递归仍然是表达很多算法的自然形式,尤其是处理树、列表这类递归数据结构。

写递归有个固定套路:先写终止条件,再写递归步骤,最后考虑要不要加累加器。比如求和 1 到 n:

(defun sum-to (n) (if (zerop n) 0 (+ n (sum-to (1- n)))))

这个版本直观,但有隐患:调用 10000 层会栈溢出,因为每一层的+都还在等着内层结果返回,栈帧不能释放。改成尾递归版本,把中间结果往下传:

(defun sum-to (n &optional (acc 0)) (if (zerop n) acc (sum-to (1- n) (+ acc n))))

这个版本里,递归调用是函数体最后一步,之前算完的(+ acc n)作为参数直接传下去,当前栈帧没有未完成的工作,理论上可以被复用。这就是尾调用优化。

这里必须说清楚一个容易混淆的点:Scheme 标准强制要求实现做尾调用优化,你写尾递归就一定有常量栈空间;Common Lisp 标准不强制,但 SBCL 在默认编译设置下会做,解释执行时不保证,所以你在 REPL 里测试尾递归结果靠谱,测试深度就不一定靠谱。这个差别在实际工程里会咬人:我写过一个遍历十万行日志的递归函数,在 SBCL 的 REPL 里跑得好好的,用sbcl --script一跑就爆栈,原因就在这。

真要在 Common Lisp 里安全处理超长序列,老老实实用loop或者dolist,别硬凹递归,这不是水平问题,是工程取舍。

3.4 高阶函数与闭包:把函数当零件用

函数是一等公民这句话,落到实处的意思就是:函数可以当参数传、当返回值返、存在变量里、放进列表里。mapcar接收一个函数和一个列表,sort可以接收一个比较函数,remove-if接收一个谓词。你写的 lambda 和内置函数在语法上没有任何区别,都可以直接传。

闭包在此基础上多走一步:函数能记住它被创建时的环境。经典例子:

(defun make-counter () (let ((n 0)) (lambda () (incf n)))) (defvar *c1* (make-counter)) (funcall *c1*) ; => 1 (funcall *c1*) ; => 2

每次调用make-counter都会创建一个独立的n,返回的 lambda 把这个n关在自己的环境里。*c1*关着一个,再创建一个就是另一个,互不干扰。这个机制是很多设计模式的基础——状态封装、缓存、装饰器,本质上都是闭包。

我当年看到这段代码最困惑的是"n在make-counter返回之后不是应该被销毁吗"。答案是:只要还有人引用它,它就不销毁。闭包持有的是对环境的引用,不是副本。这一点在 JavaScript 里叫闭包,在 Lisp 里也叫闭包,在 Java 里靠匿名内部类勉强模拟,思想完全一致。

Clojure 因为强制不可变,写法不一样:

(defn make-counter [] (let [n (atom 0)] (fn [] (swap! n inc))))

用atom显式建模可变状态,swap!保证原子性。这个设计把"哪里可变"摆到明面上,反而更清楚。

4. 动手:从空文件到一个能用的词频统计器

4.1 需求拆解与数据流设计

光看概念容易虚,我们写个真东西。目标:读一个文本文件,统计每个单词出现次数,输出出现最多的前 20 个。这个需求小到能一口气写完,又包含文件 IO、字符串处理、哈希表、排序这些日常操作,很适合当第一个完整项目。

先设计数据流,别急着写代码。四步:读文件流、逐行切词、累加到计数表、排序取前 N。每一步的输入输出类型都清楚:文件路径进,字符串列表出;字符串列表进,词到次数的映射出;映射进,有序的键值对列表出。

这个拆分看起来理所当然,但它决定了后面的实现方式。比如第三步如果选哈希表,那排序时需要先转成列表;如果一开始就选关联列表(association list),插入时每次都要线性查找,数据量一大就完蛋。这里的选型判断是:数据规模不确定的时候,默认选哈希表,O(1)插入比什么都实在,转换成本是一次性的。

4.2 逐层实现,边写边在 REPL 里验证

第一步,切词。Common Lisp 标准库没有split函数,这是个新手必踩的坑。要么用 SBCL 自带的uiop:split-string,要么自己写一个。生产代码建议用前者,它稳定且够快。

(defun tokenize (line) (remove-if #'zerop (mapcar #'string-downcase (uiop:split-string line :separator " .,;:!?\"'()[]{}<>/\\|@#$%^&*"))))

uiop:split-string的:separator接收的是一个字符集合字符串,不是分隔符列表,这一点文档里写得不算明显。它会把里面任何一个字符都当分隔符。remove-if #'zerop是为了去掉连续分隔符产生的空字符串,string-downcase统一小写,避免The和the算两个词。

第二步,累加。哈希表用equal测试(因为键是字符串,eql比的是对象身份,会出错,这个坑后面细说)。

(defun count-words (path) (let ((table (make-hash-table :test #'equal))) (with-open-file (in path :direction :input :external-format :utf-8) (loop for line = (read-line in nil nil) while line do (dolist (word (tokenize line)) (incf (gethash word table 0))))) table))

这里(gethash word table 0)的第三个参数是默认值,键不存在时返回 0,然后incf加一并写回。这个惯用法非常常用,比(if (gethash ...) ... ...)干净得多。with-open-file保证异常时也能关闭文件,read-line的第二个参数nil表示到 EOF 不报错,返回nil,配合while line就能干净地读完。

第三步,排序取前 N。

(defun top-n (table n) (let ((pairs '())) (maphash (lambda (k v) (push (cons k v) pairs)) table) (subseq (sort pairs #'> :key #'cdr) 0 (min n (length pairs)))))

maphash遍历哈希表,顺序是随机的,不需要在意。sort的:key #'cdr表示"按每个元素的 cdr 比较",也就是按次数排。注意sort是破坏性的,它会修改原列表,所以如果你之后还要用pairs,得先copy-list。又一个坑。

完整调用:

(let ((tbl (count-words "sample.txt"))) (dolist (pair (top-n tbl 20)) (format t "~10d ~a~%" (cdr pair) (car pair))))

format的~10d是右对齐占十列,~%是换行。这个格式化语言本身就是个小 DSL,值得单独花时间看一遍。

同样的功能在 Clojure 里,因为语言自带了frequencies和线程宏,会短很多:

(defn top-n [path n] (->> (slurp path) (re-seq #"[\p{L}\p{N}]+") (map clojure.string/lower-case) (frequencies) (sort-by val >) (take n)))

两段代码对照着看,能感受到两种设计哲学:Common Lisp 给你一堆基础零件,你自己组装;Clojure 把常用组合直接做成标准函数,你拿来就用。没有优劣,看场景。

4.3 宏:什么时候值得写,什么时候是自找麻烦

学 Lisp 的人最容易上头的地方就是宏。写了一个能工作的宏之后,会有一种"我掌握了语言本身"的错觉,然后开始把什么都写成宏。我干过这事,结果三个月后自己都看不懂那段代码。

宏的本质是:在编译期运行的、操作代码的函数。普通函数接收值,宏接收代码,返回新代码。正因为它操作代码,所以能做函数做不到的事——改变求值顺序、引入新语法、消除重复的样板。

一个真正值得写宏的场景:你发现自己反复在写同样的结构,而且这个结构里包着不同的代码块。比如测耗时:

(defmacro with-timing (&body body) (let ((start (gensym "START"))) `(let ((,start (get-internal-real-time))) (prog1 (progn ,@body) (format t "~&耗时 ~,3f 秒~%" (/ (- (get-internal-real-time) ,start) (float internal-time-units-per-second)))))))

用法(with-timing (heavy-function)),它会打印耗时并返回原值。这里有三个关键点必须理解:反引号`是模板,逗号,是求值插入,,@是把列表展开插入。gensym生成唯一符号,防止你传进来的变量名跟内部变量名撞车——这个叫变量捕获,是写宏最常见的错误来源。我见过最诡异的 bug 就是宏里用了tmp,而调用方的代码里恰好也有个tmp,结果互相覆盖,报错信息完全指不到源头。

什么时候不该写宏?判断标准很简单:如果写成函数也能达到目的,就写函数。函数能被传递、能被调试、能被单独测试,宏不能(宏不能传给mapcar,宏展开出错时错误信息指向的往往是展开后的代码,极难定位)。我给自己定的规矩是:一个模式重复到第五次,且其中包含必须延迟求值的代码块,才考虑上宏。

5. 排查实录:新手最容易卡住的几个地方

5.1 括号错误与作用域错误

括号问题其实分两类。第一类是数量不匹配,这类好办,编辑器会高亮,编译会报 unexpected EOF。第二类是结构错误但数量正确,这才要命,比如(if (> x 0) (print "正") (print "负"))少写了一对,变成(if (> x 0) (print "正") print "负"),括号数是配平的,但语义完全变了,报错信息可能指向几百行之外。

对策只有一个:写完一个表达式就立刻编译它,不要一口气写五十行再跑。Lisp 的交互式开发不是让你"享受 REPL",是让你把调试粒度降到单个表达式。

作用域问题里最常见的是动态作用域和词法作用域混淆。老式 Emacs Lisp 是动态作用域,一个函数里的变量名会影响调用它的函数里的同名变量,这个行为在今天看来非常危险。Common Lisp 默认词法作用域,但用defvar定义的全局特殊变量(名字两边带星号)是动态绑定的,let绑定它会影响整个调用链。所以你会看到社区约定:特殊变量名必须带星号,就是为了提醒你这个东西不是局部的。看到*standard-output*这种名字,就知道它可能被外层改过。

5.2 报错信息速查表

现象大概率原因处理方式
The variable X is unbound变量没定义,或拼写错误,或let作用域外使用检查defvar/let的作用范围,注意大小写
Undefined function X函数没加载,或定义在别的包里没导出(load ...)确认加载,检查包前缀
Odd number of &key arguments关键字参数传了奇数个数一下:key value是否成对
not of type LIST把非列表传给了car/mapcar用type-of打印实际类型
Stack overflow深递归且没做尾调用优化改loop/dolist,或调整优化声明
哈希表取不到值:test用了eql但键是字符串改成:test #'equal
Package X not found依赖没装,或asdf没加载系统(ql:quickload "系统名")或检查deps.edn

那个哈希表的问题我要单独说,因为它太典型了。make-hash-table默认:test是eql,对于数字和符号没问题,但两个内容相同的字符串是不同的对象,eql比较对象身份,所以(gethash "abc" table)拿不到当初用另一个"abc"存进去的值。我第一次遇到时排查了快一小时,因为逻辑上看不出任何毛病,最后是打印了eq的结果才恍然大悟。

5.3 性能与调试的实操心得

性能上有几个新手容易忽略的点。第一,SBCL 默认在 REPL 里是不编译的,如果你在*scratch*里直接defun然后测试性能,测出来的数字没有意义。用(compile 'function-name)显式编译,或者把代码写进文件用compile-file。第二,(declaim (optimize (speed 3) (debug 0)))能显著提速,但代价是错误信息变得模糊,且类型声明写错会导致未定义行为,建议功能稳定之后再做。第三,format在循环里密集调用是性能杀手,输出量大时先拼字符串再一次性打印。

调试方面,Common Lisp 的条件系统是它相对其他语言的一大优势。出错时不是直接崩,而是进入调试器,你能看到完整的调用栈,能选择重试某个栈帧、修改变量值、或者带参数重新执行某个函数。SBCL 里敲:help能看到所有选项,最常用的是:r(重试当前帧)、:q(退到顶层)、backtrace。

刚接触时会觉得"怎么报个错就卡住不动了",习惯之后会发现这比打印日志高效得多——错误现场被原样冻结,你可以反复检查,而不是事后猜测。

注意:生产环境跑脚本时一定要加--non-interactive或者--disable-debugger,否则一个可恢复错误会让进程挂在那里等待输入,而不是正常退出。这个坑我在定时任务里踩过一次,进程静默堆积了一整晚。

5.4 关于学习节奏的一点个人经验

最后聊点方法论。Lisp 这东西的入门曲线特别怪:前三天极其痛苦,因为你要同时适应前缀表达式、括号嵌套、car/cdr这些陌生词汇,写出来的代码像噪音;第四天到第二周开始有点感觉,能写出能跑的递归函数;真正开窍通常发生在你第一次用宏消除了一段重复代码,或者第一次在 REPL 里调试一个多层嵌套的问题而不需要重启的时候。

我给自己的练习安排是每天三个小函数,从(defun square (x) (* x x))这种到处理嵌套列表的都有,写完必须在 REPL 里调一遍。三周下来大概写了六十多个,回头看整体水平提升明显。

如果你是那种需要项目驱动才能坚持的人,建议直接接一个真实任务:写个批量重命名文件的小工具,写个把日志格式转成 CSV 的脚本,写个简单的表达式求值器。这三个项目的难度递进很合理,而且都能真正用起来。

我现在回头看,Lisp 对我最大的改变不是让我多会了一门语言,而是让我在写任何语言的代码时都会下意识地问两个问题:这个函数有没有副作用?我能不能把它拆成更小、更纯粹的部分然后组合起来?这套思维方式,比任何具体语法都值钱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 18:34:26

PhotoCap 400中文版照片批处理实战:模板、水印与重命名

1. 为什么我在一堆图像工具里偏偏留下了这个中文版1.1 一个真实的工作场景&#xff1a;几百张活动照的交付压力去年帮一家做线下培训的客户收拾活动现场的照片&#xff0c;需求说出来特别朴素&#xff1a;四百多张原片&#xff0c;统一压到长边一千六百像素、加一个半透明的角标…

作者头像 李华
网站建设 2026/10/29 18:33:09

从400错误到RCE:Web安全渗透实战解析

1. 靶场渗透实战&#xff1a;从400错误到RCE的完整攻击链解析上周在Amaterasu靶场发现一个有趣的渗透路径&#xff1a;从普通的400 Bad Request错误页面入手&#xff0c;最终实现了远程代码执行&#xff08;RCE&#xff09;。这个案例完美展示了Web安全攻防中"小漏洞酿成大…

作者头像 李华
网站建设 2026/10/29 18:32:56

Python轻量CLI工具实现Agent式交互:无LLM的会话记忆设计

1. “Agent-Reach”不是新框架&#xff0c;而是一个被误读的CLI工具命名现象最近在GitHub上搜“Agent-Reach”&#xff0c;你会发现它不像LangChain或LlamaIndex那样有密集的文档、教程和社区讨论。它没有PyPI包页&#xff0c;没有官方Docker镜像&#xff0c;也没有技术博客深度…

作者头像 李华
网站建设 2026/10/29 18:32:00

PNG文件结构与CTF隐写分析实战

1. PNG文件结构与CTF中的常见考点 在CTF比赛中&#xff0c;MISC类题目经常涉及PNG图片分析。要真正掌握这类题目&#xff0c;必须深入理解PNG文件的二进制结构。一个标准的PNG文件由8字节的文件头签名和多个数据块&#xff08;Chunk&#xff09;组成&#xff0c;每个数据块都有…

作者头像 李华
网站建设 2026/10/29 18:31:16

通信原理期末复习:用PPT打造考点地图与公式卡

简介&#xff1a;面向华南理工大学通信原理期末复习的课件资料&#xff0c;内容涵盖模拟信号抽样与量化、均匀与非均匀量化、脉冲编码调制、差分脉冲编码调制与增量调制、信息论基础与香农公式、数字基带传输、数字载波调制、传输信道、差错控制编码及同步原理等核心模块&#…

作者头像 李华