从 proced 定制中学到的 Elisp 模式
目录
Rahul Juliato 的 Getting Emacs proced.el to Show CPU and Memory on macOS 表面上是解决 macOS 上 proced 缺少 CPU/Mem 列的问题,实际上是一份很好的 Elisp 编程教学。本文从中提取六个可复用的编程模式——它们跟 proced 和 macOS 无关,你在任何需要异步调用外部命令、缓存数据、扩展第三方包的场景都能用。
模式一:异步进程 + sentinel
Elisp 中执行外部命令有两种方式: call-process (同步,会阻塞 Emacs )和 make-process (异步,不阻塞)。凡是执行时间不确定的命令,都应该用 make-process 。
(make-process :name "my-process" :buffer (generate-new-buffer " *my-process-temp*") :command '("env" "LC_ALL=C" "ps" "-eo" "pid=,%cpu=,%mem=") :noquery t :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (with-current-buffer (process-buffer proc) (goto-char (point-min)) ;; 在这里处理输出 (buffer-string)) (kill-buffer (process-buffer proc)))))
关键设计:
sentinel是一个回调函数,在进程状态变化时触发。通常只关心'exit状态——此时 buffer 中已有完整输出。:noquery t防止 Emacs 退出时弹出"还有进程在运行"的确认框。generate-new-buffer创建临时 buffer 承接输出,名称以空格开头(" ")的 buffer 在list-buffers中默认隐藏。- 处理完后
kill-buffer清理临时 buffer ,避免 buffer 堆积。
模式二:hash table 做进程级缓存
当你需要频繁按 key 查找数据时,hash table 比 alist 快得多( O(1) vs O(n) )。
;; 创建 (defvar my-cache (make-hash-table)) ;; 写入(用 cons 存两个值,car 和 cdr 分别取) (puthash 1234 (cons 2.5 1.3) my-cache) (puthash 5678 (cons 0.1 0.5) my-cache) ;; 读取 (car (gethash 1234 my-cache)) ;; => 2.5 (cdr (gethash 1234 my-cache)) ;; => 1.3 (gethash 9999 my-cache) ;; => nil
2.5 1.3 nil
为什么用 cons 存值?因为 car 和 cdr 是 Elisp 中最快的取值操作之一,比 plist-get 或 alist-get 快。当你只需要存两个值时,cons 是最佳选择。
刷新时不要原地修改 hash table ,而是创建一个新的再替换——这样即使有并发的读操作也不会读到半更新的状态:
(defun my-refresh-cache () (let ((new-cache (make-hash-table))) ;; 填充 new-cache ... (setq my-cache new-cache))) ;; 原子替换
模式三: rx 宏写可读正则
Elisp 的 rx 宏用 S-expression 写正则,比字符串正则更容易读和维护。对比一下:
;; 字符串正则:不直观,需要脑内解析 "^[[:blank:]]*\\([[:digit:]]+\\)[[:blank:]]+\\([.[:digit:]]+\\)" ;; rx 宏:自解释 (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.))))
rx 编译出来的结果跟手写字符串正则完全一样:
(rx (* blank)
(group (+ digit))
(+ blank)
(group (+ (any digit ?.)))
(+ blank)
(group (+ (any digit ?.))))
[[:blank:]]*\([[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\)
常用 rx 组合:
rx 写法 |
匹配内容 |
|---|---|
(+ digit) |
一个或多个数字 |
(* blank) |
零或多个空白 |
(any digit ?.) |
数字或小数点 |
(group ...) |
捕获组,对应 match-string |
模式四:timer 生命周期管理
Emacs 的 run-with-timer 可以周期性执行任务,但如果不在合适的时机取消,timer 会一直运行下去(即使对应的 buffer 已经关了)。正确的做法是在 mode hook 里启动,在 kill-buffer-hook 里取消:
(defvar my-timer nil) ;; 在 mode hook 中启动 (add-hook 'my-mode-hook (lambda () (setq my-timer (run-with-timer 0 2 #'my-refresh-function)))) ;; 在 kill-buffer-hook 中清理 (add-hook 'kill-buffer-hook (lambda () (when (and (derived-mode-p 'my-mode) (timerp my-timer)) (cancel-timer my-timer) (setq my-timer nil))))
三个要点:
run-with-timer的参数是(延迟秒数 重复间隔 函数),第一个参数0表示立即执行第一次。cancel-timer取消后要把变量设为nil,避免悬空引用。- guard 条件
(derived-mode-p 'my-mode)确保kill-buffer-hook不会在其他类型的 buffer 中误触发。kill-buffer-hook是全局的,每次任何 buffer 关闭都会触发,所以必须有 mode 判断。
模式五: file-remote-p 做 TRAMP 感知
当你的代码依赖本地系统状态(比如执行本地 ps ),必须考虑 buffer 可能在 TRAMP 远程主机上运行的情况。 file-remote-p 检测 default-directory 是否指向远程:
(unless (file-remote-p default-directory) ;; 只在本地执行 (my-run-local-command))
为什么这很重要?如果你不加检测,可能会用本地 ps 的输出作为参数在远程主机上执行命令。轻则数据显示错误,重则本地 PID 跟远程 PID 碰撞导致误操作。
模式六:通过 custom attributes 扩展第三方包
很多 Emacs 包提供 *-custom-attributes 或类似的扩展点,让你不用修改包的源码就能添加功能。 proced 的 proced-custom-attributes 就是一个例子:它接受一个 lambda 列表,每个 lambda 接收当前行的属性 alist ,返回 (keyword . value) 就能添加新列。
(setq proced-custom-attributes (list (lambda (attrs) (when-let* ((pid (cdr (assq 'pid attrs))) (v (my-lookup pid))) (cons 'my-attribute v)))))
这个模式不限于 proced 。当你需要给某个包添加自定义字段时,先看看它有没有类似的扩展点——很多设计良好的包都会提供。
小结
| 模式 | 一句话 |
|---|---|
| 异步进程 + sentinel | 用 make-process 代替 call-process ,在 sentinel 中处理输出 |
| hash table 缓存 | 高频查找用 hash table ,刷新时整体替换而非原地修改 |
rx 宏 |
用 S-expression 写正则,比字符串更可读更易维护 |
| timer 生命周期 | hook 启动、hook 清理、 derived-mode-p guard 三件套 |
file-remote-p |
任何依赖本地系统状态的代码都要加 TRAMP 检测 |
| custom attributes | 用 lambda 返回 (keyword . value) 扩展第三方包的显示 |