Elisp 易错点清单(AI 写 Emacs 插件参考)
目录
本文是 AI agent 编写 Emacs Lisp 代码时的高频出错清单。每条都来自实际踩坑,附错误示例、正确写法和原因。写 Elisp 前扫一遍,能避免大量运行时才暴露的问题。
cl-defstruct 的 copy-* 不接受关键字参数
错误
(copy-foo original :field-a new-value) ;; → wrong-number-of-arguments
正确
(make-foo :field-a new-value :field-b (foo-field-b original))
原因
Elisp 的 cl-defstruct 是 Common Lisp 的简化版。它自动生成的 copy-* 函数只接受一个参数(原始 struct),不支持 Common Lisp 风格的关键字参数覆盖。遇到"复制并修改个别字段"的需求时,必须手动用 make-* 逐字段构造。
字符串不需要转 vector 就能用 aref
错误
(let ((key (string-to-vector str)))
(aref key i))
正确
(aref str i) ;; 返回字符的整数编码
原因
Elisp 中字符串本身就是字符向量, aref 可以直接索引访问,返回字符的整数编码。 string-to-vector 是多余的中转。这个特性在移植其他语言的"字符数组"操作时尤其容易忽略。
string<= 在旧版 Emacs 不存在
错误
(string<= a b) ;; → void-function(Emacs < 28)
正确
(not (string> a b)) ;; 或 (string-lessp a b) ;; 只能比 <,不能比 <=
原因
string<=是 Emacs 28 才加入的。如果需要兼容旧版本,用 =(not (string> ...)) 代替。类似地, string>=也不可靠。Elisp 的字符串比较函数族只有 =string= 、 string< = 、 string> = 、 string</ 、 string>/ = 五个,老版本只保证 string-lessp 和 string = 可用。
Org buffer 中 re-search-forward + org-back-to-heading 会死循环
错误
(while (re-search-forward "^:SOME_PROP:" nil t) (org-back-to-heading t) (let ((id (org-entry-get (point) "ID"))) ;; 处理... ) (forward-line 1)) ;; 只前进到 :PROPERTIES: 行,下轮又匹配同一个 drawer
正确
(org-map-entries (lambda () (let ((id (org-entry-get (point) "ID"))) ;; 安全处理每个 heading )) "SOME_PROP<>\"\"" nil)
原因
三层问题叠加:
org-back-to-heading把光标移回* Heading行forward-line 1只前进到:PROPERTIES:行- 下一轮
re-search-forward又匹配到同一个 drawer 里的属性
此外,循环体内调用的函数如果内部也有 re-search-forward ,会破坏外层循环的光标位置(=save-excursion= 只保护当层)。
org-map-entries 把遍历逻辑完全托管给 Org 引擎,用属性查询语法过滤,不存在这些问题。在 Org buffer 里遍历 heading,*永远优先用 =org-map-entries=*。
Org 的 org-entry-put 在 batch 模式下可能触发交互提示
注意
org-entry-put 在某些场景下(如 drawer 不存在需要创建时)可能触发 y-or-n-p 之类的交互确认,在 emacs --batch 下会卡住。
规避
确保 heading 已经有 :PROPERTIES: drawer(哪怕为空)。或者在写入前先检查:
(unless (org-entry-get pom "PROPERTY") (org-entry-put pom "PROPERTY" "value"))
Org 的写入 API(=org-entry-put= 、 org-set-property )在 batch 模式下使用时,要提前确认 heading 结构完整。
数学公式的测试期望值必须从参考实现提取
错误
;; 心算 "R = (1 + 7/(9*10))^(-1)" 觉得大概是 0.5625 (should (< (abs (- r 0.5625)) 0.001)) ;; 实际值是 0.9278,断言失败
正确
// 先用参考实现(如 Node.js)跑一遍 console.log("retrievability:", Math.pow(1 + 7/(9*10), -1)); // → 0.9278350515463918
;; 把精确值写进测试 (should (< (abs (- r 0.9278)) 0.001))
原因
移植算法时,AI(和人类)都不擅长心算复合浮点表达式。一次心算错误会导致多个测试的期望值连锁错误(因为下游公式依赖上游计算结果)。正确做法是:写测试之前,用参考实现的语言跑一遍每个函数,把输出复制粘贴为期望值。不心算,不估算。
公式在边界条件下可能有反直觉行为
错误假设
;; 假设:对新卡牌评分 good,stability 一定会增加 (should (> new-stability old-stability)) ;; 失败!elapsed=0 时 new-stability == old-stability
原因
FSRS 的 recall stability 公式中有一个因子 exp((1-R)*w[10]) - 1 。当 elapsed=0 时, R=1.0 , exp(0)-1=0 ,整个增量项为 0,stability 不变。
这不是 bug,是数学公式的正确行为:卡牌还没经过任何时间衰减,recall stability 的增量就是 0。
规避
写测试前先分析公式在边界输入下的行为( elapsed=0 、 S→0 、 R→1 等),不要假设"操作一定会改变状态"。
Elisp 没有编译时检查,写完必须立即 load
问题
AI 写完 Elisp 代码后不会自动运行。笔误(如 dotify 写成 dotify 、函数名拼错)在"看起来对"的代码审查中无法发现,只有运行时才报 void-function 。
规避
每写完一个 (require 'your-module) 能加载的最小单元,立即 load 一次:
emacs --batch -L . --eval '(progn (require (quote your-module)) (princ "OK\n"))'
这一步能捕获所有 void-function 、 void-variable 、 wrong-number-of-arguments 类的错误。比攒一大堆代码最后一起调试高效得多。
Elisp 的 fround 不等于 JS 的 toFixed
差异
// JavaScript +(5.1356).toFixed(2) // → 5.14(四舍五入)
;; Elisp (/ (fround (* 5.1356 100.0)) 100.0) ;; → 5.14(浮点精度下行为一致)
虽然这个例子中结果一致,但浮点乘法再除法在某些值上可能产生 5.13999... 而非 5.14 。移植算法时,涉及 toFixed(2) 的地方要单独验证每个输出值。
规避
对需要精确到 N 位小数的结果,用容差比较而非精确相等:
;; 好 (should (< (abs (- result 5.14)) 0.005)) ;; 危险 (should (= result 5.14))
总结:写 Elisp 前的检查清单
[ ]copy-*函数只接受一个参数,不支持关键字覆盖[ ]字符串直接aref索引,不需要string-to-vector[ ]string<=可能不存在,用 =(not (string> ...))[ ]Org buffer 遍历用org-map-entries=,不要手动 =re-search-forward[ ]Org 写入 API 在 batch 模式下要确认 heading 结构完整[ ]数学测试期望值从参考实现提取,不心算[ ]公式边界条件(零值、极值)先分析再写测试[ ]每写完一个模块立即require验证加载[ ]浮点结果用容差比较,不用精确相等