暗无天日

=============>DarkSun的个人博客

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-lesspstring = 可用。

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)

原因

三层问题叠加:

  1. org-back-to-heading 把光标移回 * Heading
  2. forward-line 1 只前进到 :PROPERTIES:
  3. 下一轮 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.0exp(0)-1=0 ,整个增量项为 0,stability 不变。

这不是 bug,是数学公式的正确行为:卡牌还没经过任何时间衰减,recall stability 的增量就是 0。

规避

写测试前先分析公式在边界输入下的行为( elapsed=0S→0R→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-functionvoid-variablewrong-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 前的检查清单

  1. [ ] copy-* 函数只接受一个参数,不支持关键字覆盖
  2. [ ] 字符串直接 aref 索引,不需要 string-to-vector
  3. [ ] string<=可能不存在,用 =(not (string> ...))
  4. [ ] Org buffer 遍历用 org-map-entries=,不要手动 =re-search-forward
  5. [ ] Org 写入 API 在 batch 模式下要确认 heading 结构完整
  6. [ ] 数学测试期望值从参考实现提取,不心算
  7. [ ] 公式边界条件(零值、极值)先分析再写测试
  8. [ ] 每写完一个模块立即 require 验证加载
  9. [ ] 浮点结果用容差比较,不用精确相等
Emacs : Elisp : AI : pitfall