《elisp 每日一练》—— Minor Mode 回顾与综合练习

《elisp 每日一练》—— Minor Mode 回顾与综合练习

Masimaro
2026-09-15 / 0 评论 / 1 阅读 / 正在检测是否收录...

到此,我们关于 Minor Mode 的介绍差不多就完结了,按照惯例,我们先回顾一下之前的内容,然后展开今天的练习

往期回顾

前面我们花了一些篇章,介绍了定义 Minor Mode 的宏 define-minor-mode。它的主要流程是:

  • 定义Mode 切换的标志变量
  • 定义 Mode 切换的函数
  • 管理 Mode 对应的全局hook 变量
  • 调用 add-minor-mode 往Emacs中注册一个 Minor Mode

切换函数主要的功能是:

  • 根据标志变量的值更新数据结构:local-minor-modes 注册或者删除对应的项
  • 无条件的执行同名的hook,并且在开启和关闭时会执行对应的 on hookoff hook
  • 更新显示信息

后面我们介绍了它是如何通过一些关键字管理对应的内容:

  • :init-value 可以控制标志变量初始值为 t 或者 nil 表示注册时就开启或者后续由用户自己开启
  • :global 将标志变量定义为 customize 变量,这样一处buffer启用,全局启用
  • :keymap 管理我们的keymap定义,会将我们在此关键字下定义的 keymap 注册到 add-minor-mode
  • :ligher 定义我们在mode line 上的显示,它会将内容注册到 add-minor-mode ,由该函数将内容追加到 minor-mode-alist
  • body 代码部分,这部分用来进行相关配置代码的变更或者恢复

这些内容看似很多,实际上都是围绕着 define-minor-modeadd-minor-mode 进行,读者感兴趣的话可以深入研究一下这个宏和函数的实现,相信能从中挖掘到更多宝藏。

练习

回顾完了之后,我们进入今天的练习。前面我们发现 :keymap:lighter 注册的顺序都与 add-minor-mode 中一个名为 after 的参数有关,但是 define-minor-mode 传入的 after 参数写死了,被标记为了 after。所以今天我们的练习就是:定义一个自己的扩展宏,在原来 define-minor-mode 的基础上支持一个新的 :after 关键字。并且我们实现的思路是调用 define-minor-mode 时修改传递给add-minor-mode 中 after 的值,而不是简单的将 define-minor-mode 的源码拷贝过来修修改改,或者在调用完 define-minor-mode 之后手动的修改对应的顺序。

虽然在日常使用中需要关注这个顺序的场景并不多,但是我想通过这个练习可以让读者更深入的理解lisp 中代码即数据的理念,以及将这种理念发挥到极致的宏的思想。这样读者在日后碰到像 use-package 这种复杂的插件不至于束手无策。

为了使各位读者更容易理解这个思路,我们先从lisp 中代码即数据这理念讲起。

首先我们要明确的一个概念是,Emacs中大多数内容都是一个list,同一个list既可以作为代码,也可以作为数据。例如 (message "Hello World")。我们既可以说他调用message函数并传入字符串参数,也可以说它是一个list,有两个成员: message符号,和字符串。既然它是list,所以我们就可以通过 carcdr 这些操作list的函数来操作它。例如:

(let ((sexp '(message "hello world"))
      (user "Jack"))
  (eval (list (car sexp) ;; message
              "Hello, %s"
              user)))

这里我们将 (message "hello world") 这个list作为数据。后续我们根据这个数据,取第一个符号 message,后续跟上 "Hello, %s" 和 user 这个变量组织成一个新的list (message "Hello, %s" user)。最终我们调用 eval 执行这个list,最终得到了一个动态变化的代码。相当于我写代码修改了原来的代码。

我们再来看看宏,实际上宏就是利用上述操作形成一组新的 list 数据,调用宏就是对宏生成的 list 执行 eval 操作。例如:

(defmacro say-hello()
  '(message "Hello World"))

(say-hello)

因为我们只是想要宏展开生成的 list 数据,并不希望执行表达式,所以这里使用 ' 。而调用它的过程就是将其进行展开得到 (message "Hello World") 数据结构,并调用 eval 进行执行。

得到宏展开的函数有 macroexpandmacroexpand-1 ,顾名思义,后者只展开一层,而前者会完全展开整个宏。

因为这个宏也是写死了输出的内容,我们尝试一下实现一个新的宏,接受一个user参数,调用时输出 hello, user 字样。因为 say-hello 宏展开会得到一个list,所以我们只要操作这个展开的list,就能实现修改并添加参数的需求。

(defmacro say-hello-to-user (user)
  (let ((sexp (macroexpand-1 '(say-hello))))
    (list (car sexp);;message
          "hello, %s" user)))

(say-hello-to-user "Tom")

上述代码我们的思路是,通过 macroexpand-1 得到展开后的list数据,然后操作list,构建一个新的list并返回,在执行的时候执行这个list 就好了。

这里提醒一下对宏不太熟悉的读者,我们在展开宏的时候需要使用 ' 或者 ` 表示这是一个引用不需要执行这个表达式。后面我们使用list 的办法是为了与前面的例子作为呼应减少理解难度,更地道的写法是
(defmacro say-hello-to-user (user)
  (let ((sexp (macroexpand-1 '(say-hello))))
    `(,(car sexp) "Hello, %s" ,user))) ;;使用 `表示后面,开头的表达式需要求值,最终也是生成一个 list 结构

实际上我们完成上述需求也是这个思路,展开 define-minor-mode、在一堆list中找到我们想要的以 add-minor-mode 开头的list,修改list 中 after 对应位置的内容。

解释如何改的问题,下面再看看如何解析 :after 参数。

根据 define-minor-mode 的定义来看,它接受的关键字实际上是位于参数 body 位置的。body位置本身传入的也是一个list。对于 :keyword 这种形式的内容,是一组特殊的符号,在求值时永远返回它自己。关键字后面跟随的是它对应的具体的值。对于这种结构组成的list是一个 property list,我们可以使用 plist-getplist-put 增加或者获取一个属性对应的值。我们要判断list中某个成员是否是这种形式的符号,可以使用 keywordp

所以说不管body中有哪些内容,他本质上还是一个list,仍然可以用操作list 的办法操作它。下面我们来看一个具体的例子:

(defmacro say-hello (&rest body)
  (let ((user nil) ;; 存储user的值
        (rest nil) ;; 存储剔除 user 项之后的表达式
        (body-content body)) ;; 用于循环的临时变量
    ;; 扫描 body,抽离 :user
    (while body-content
      (if (and (keywordp (car body-content)) (eq (car body-content) :user))
          (progn
            (setq user (cadr body-content))      ; :user 后面的表达式
            (setq body-content (cddr body-content)))
        (push (car body-content) rest)
        (setq body-content (cdr body-content))))
    (setq rest (nreverse rest))                  ; 前面的 push 打乱了原本的顺序恢复顺序
    ;; 构造list, 作为宏的返回值
    `(progn
       (message "Hello, %s" ,user)
       ,@rest)))

(say-hello :user "Tom"
           (message "doing something else")
           (message "finish"))

上面的代码结合注释不难理解,我们通过一个循环来提取我们关注的关键字 :user 之后的表达式。然后将剩余部分的代码整个组装并返回。

大家看到这里对宏是否有了新的理解呢?宏本身就是一组操作list的固定代码,它将我们传入的数据或者代码进行打散重新组装,并输出我们想要的list组合。现在再来看 define-minor-mode 的代码可能就不那么难以理解了。

到了这里相信各位读者对于本次我们要完成的功能应该知道如何下手了,甚至有的读者也跃跃欲试了,但是别着急,我们先来看看define-minor-mode 中有哪些代码我们可以借鉴的。

首先我觉得值得聊聊的一处是开头位置 :

  (declare (doc-string 2)
           (indent defun)
           (debug (&define name string-or-null-p
               [&optional [&not keywordp] sexp
                &optional [&not keywordp] sexp
                &optional [&not keywordp] sexp]
               [&rest [keywordp sexp]]
               def-body)))

这段代码对于我们后续开发自己的宏很有借鉴意义。这段代码对于实际功能没有什么大的帮助,它们表达的含义如下:

  • (doc-string 2) 表示宏的第二参数,也是就是 doc 参数是一个文档字符串,这样Emacs中的文档生成工具可以正确收集这个宏的说明文档
  • (indent defun) 定义代码的缩进格式,按照函数的缩进方式进行
  • debug 告诉调试器如何解析这个宏的语法结构

因为我们要为自己的宏加上关键字 :after,我们看看 define-minor-mode 是如何处理这类参数的,有没有可以借鉴的:

    (while (keywordp (setq keyw (car body)))
      (setq body (cdr body))
      (pcase keyw
    (:init-value (setq init-value (pop body)))
    (:lighter (setq lighter (purecopy (pop body))))
    (:global (setq globalp (pop body))
                 (when (and globalp (symbolp mode))
                   (setq setter `(setq-default ,mode))
                   (setq getter `(default-value ',mode))))
    (:extra-args (setq extra-args (pop body)))
    (:set (setq set (list :set (pop body))))
    (:initialize (setq initialize (list :initialize (pop body))))
    (:type (setq type (list :type (pop body))))
    (:keymap (setq keymap (pop body)))
    (:interactive (setq interactive (pop body)))
        (:variable (setq variable (pop body))
                   (if (not (and (setq tmp (cdr-safe variable))
                                 (or (symbolp tmp)
                                     (functionp tmp))))
                       ;; PLACE is not of the form (GET . SET).
                       (progn
                         (setq setter `(setf ,variable))
                         (setq getter variable))
                     (setq getter (car variable))
                     (setq setter `(funcall #',(cdr variable)))))
    (:after-hook (setq after-hook (pop body)))
    (_ (push keyw extra-keywords) (push (pop body) extra-keywords))))

它的处理方式没有脱离我们上面介绍的处理框架。它也是遍历body列表,然后依次获取想要的内容。一口气将它不关心的参数放到了 extra-keywords 中。剩余的内容就被放到了 body 变量中。后面就是根据这些变量的值生成对应的list结构了。

基于这些知识我们就可以编写需求对应的代码了:

(defmacro my/define-minor-mode-ex (mode doc &rest body)
  "自定义的 define-minor-mode,支持额外传入 :after 关键字参数。"
  (declare (doc-string 2)
           (indent defun)
           (debug (&define name string-or-null-p
                           [&optional [&not keywordp] sexp
                            &optional [&not keywordp] sexp
                            &optional [&not keywordp] sexp]
                           [&rest [keywordp sexp]]
                           def-body)))
  (let ((after-eval nil)
         (clean-body nil) ;;剔除 after 之后的list
         (temp-body body)) ;;循环变量
    ;;循环剔除 :after 关键字
    (while temp-body
      (if (and (keywordp (car temp-body)) (eq (car temp-body) :after))
          (progn
            (setq after-eval (cadr temp-body))
            (setq temp-body (cddr temp-body)))
        (push (pop temp-body) clean-body)
        (push (pop temp-body) clean-body)))
    (setq clean-body (nreverse clean-body))
    ;;展开原生的宏,得到对应的list数据
    (let ((expand-code (macroexpand-1 `(define-minor-mode ,mode ,doc ,@clean-body))))
      ;;如果指定了 :after 则修改里面调用 add-minor-mode 的参数
      (when after-eval
        (setq expand-code
              (mapcar (lambda (form)
                        (if (and (listp form)
                                 (eq (car form) 'with-no-warnings)
                                 (listp (cadr form))
                                 (eq (car (cadr form)) 'add-minor-mode))
                            ;;修改参数列表
                            (let ((add-form (cadr form))) ;;获取参数列表
                              `(with-no-warnings
                                 (add-minor-mode
                                  ,(nth 1 add-form) ;; TOGGLE
                                  ,(nth 2 add-form) ;; NAME
                                  ,(nth 3 add-form) ;; KEYMAP
                                  ,after-eval ;;这里就是我们要替换的 after 参数
                                  ,@(nthcdr 5 add-form)))) ;;剩余的一口气全塞进去
                          form)) expand-code)))
      expand-code)))

(my/define-minor-mode-ex my/focus-mode
  "test mode"
  :after 'bar-mode)

到此我们的任务就完成了,这个是基于当前宏展开的,我们是按照 with-no-warnings 在顶层list中可以遍历到,并且 add-minor-mode 是 with-no-warnings 所在列表的第二个参数来编写的。如果后续它们的顺序改变了,这段代码可能就不那么准了,如果要严谨一些的话,我们可以递归遍历所有列表的子元素进行查找。这个读者可以自行尝试,我们就不在这里再展开了。

0

评论 (0)

取消