/eltips

Emacs-lisp 奇淫异技

Emacs-lisp 奇淫异技

开场白

经过深思熟虑的拍脑门后,二呆同学说:

50% 的决策都是拍脑门决定的。。。。

谁应该读这个文档

符合下面特征的同学值得读一下这个文档:

  1. 刚刚从编写 Emacs configure 转到编写 emacs library
  2. 希望将这个(些) library 打包成一个 package
  3. 愿意将这个 package 和其他同学共享。

读这个文档之前,同学们需要知道,下面所有的示例代码 都有两个前提:

  1. 当前 library 或者 package 的名称为:eltips
  2. Eltips 依赖的一个名字为 erdai 的 library :-)

Configure vs library

从 Emacs-lisp 语言角度来说,两者没有多大的区别,可能写 library 的时候, Emacs-lisp 语言应用的更加规范一点。

但编写 configure 和编写 library 需要遵循的理念有很大不同:

  1. Configure 是写给自己用的,如果其他同学使用你的 configure 出了问题, 那么他只能自认倒霉,你可以不负任何责任。
  2. Library 编写出来主要是给其他人使用的,如果出了问题,维护者是有直接 责任的:-)

Library vs package

Emacs-lisp 很早就有了 library 的概念, 但 package 的概念是随着 package.el 和 elpa 引入的,时间不是很长。

简单来说:package 是 library 的发布形式,一个 package 可以是一个 单独的 library 文件,也可以是一组功能联系紧密的 library 文件打包 生成的 zip 文件。

具体细节请阅读 elisp info 中的 “Preparing Lisp code for distribution” 章节。

https://www.gnu.org/software/emacs/manual/html_mono/elisp.html#Packaging-Basics

library 编写原则

  1. 成本效益原则:
    1. 编写的 library 不能含有恶意代码,不能窃取用户隐私。
    2. 编写的 library 值得用户花时间和精力去学习使用, 重复制造轮子的决定要慎重。
  2. 正确使用 Emacs-lisp
  3. 尊重社区惯例原则:主要是使用公认的代码缩进方式,让代码容易维护等。
  4. 尊重用户选择原则:
    1. 不能随意覆盖自己 library 的用户接口变量
    2. 不能随意覆盖其他 library 的任何全局变量, 包含用户接口变量和内部使用全局变量。
  5. 用户知情同意原则:当一个 library 必须 覆盖用户接口变量时, 这个 library 的维护者 尽自己最大的努力 让用户知道哪些用户 接口变量被覆盖了,这样做的话,即使出问题,用户也容易调试。

library 命名

给你的 library 命名这个工作非常非常重要,千万不能马虎,因为对 一个已经存在的 library 重命名是非常麻烦的事情,需要做许许多多 兼容性工作。

Emacs-lisp 和其他 lisp 不同,Emacs-lisp 的全局变量的作用范围非常大, 只要一个 library 加载了,那么在当前 Emacs session 的任何代码处,都 可以随意的访问和设置这个 library 的所有全局变量。

Emacs-lisp 这个特性很容易发生变量冲突,为了防止变量冲突,Emacs 社区 有这么一个惯例: 一个 library 的全局变量,函数定义以及宏定义等,名字 都应该使用统一的前缀,比如: company 的全局变量,命名时都使用 “company-” 前缀。

Emacs-lisp 语言本身对 library 的名称没有多少限制,但并不代表你可以 随意使用任何字符串做为一个 library 的名称,建议新手遵循以下惯例:

  1. library 的名字不能是其他 library 正在使用或者曾经使用过的名字。
  2. library 的名字 只能使用 小写字母,数字和下划线。
  3. library 的名字要好记,好写,好认。
    1. 最好是 一个 单词或者 一个 缩写,比如:company 或者 elpa 。
    2. 最好不要超过2个单词。
    3. 不要太长, 个人感觉 最好不超过15个字符。
  4. library 的名字中,最好不要包含太常见的单词,比如:emacs, chinese, good 之类的。
  5. library 的前缀最好使用 library 名称加连字符。

package 命名

最好的选择是:package 的名字就选 library 的名字。

选择合适的协议

这个需要早做决定,package 的维护者可以按照自己的喜好选择一个开源协议, 但最常用的是: “GNU General Public License” , 如果你想你的 package 加入 elpa, 那么你只能选择 GPL :-)

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

选择合适的发布方式

到目前为止,最常见的发布途径有两种:

  1. Melpa
  2. Elpa

library 的维护者应该早做决定,因为 elpa 要求 library 代码的所有供献者都签署 GNU 的纸质协议,如果这个事情在 library 编写的早期不作的话,后面的工作量就大了。

Melpa 的限制相对比较小了。

创建 library 框架文件

方法非常多,比较简单方便的一种方式是:

  1. 新建并打开一个文件 eltips.el
  2. 运行 auto-insert 命令(建议临时关闭 ivy-mode)。

    就会得到类似下面的一个 library 框架,然后开始 写代码就 OK 了。

    ;;; eltips.el --- Emacs-lisp tips                    -*- lexical-binding: t; -*-
    
    ;; Copyright (C) 2018  Feng Shu
    
    ;; Author: Feng Shu <tumashu@163.com>
    ;; Keywords: docs
    
    ;; This program is free software; you can redistribute it and/or modify
    ;; it under the terms of the GNU General Public License as published by
    ;; the Free Software Foundation, either version 3 of the License, or
    ;; (at your option) any later version.
    
    ;; This program is distributed in the hope that it will be useful,
    ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
    ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ;; GNU General Public License for more details.
    
    ;; You should have received a copy of the GNU General Public License
    ;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
    
    ;;; Commentary:
    
    ;;
    
    ;;; Code:
    
    
    
    (provide 'eltips)
    ;;; eltips.el ends here
        

了解 Emacs-lisp Style

仔细阅读下面这个项目中的所有文档:

https://github.com/bbatsov/emacs-lisp-style-guide

定义变量的正确方式

Emacs-lisp 有许多定义变量的方法,但下面几种是最最常用的。

  1. 定义一个用户接口变量
    (defcustom eltips-name "eltips"
      "The name of elptips.")
        

    这是最正统的方式,但许多人嫌麻烦,最开始都使用下面的方式 定义一个用户接口变量,等到 library 相对稳定后,再改用上面的 方式:

    (defvar eltips-name "eltips"
      "The name of elptips.")
        
  2. 定义一个只读全局变量
    (defconst eltips-name "eltips"
      "Eltips's name")
        

    值得注意的是,defconst 并不能保证这个变量完全只读,而不被修改 它只是告诉同学们,library 的维护者可能不会在代码里面重新设置 这个变量,至于真的会不会,只有天知道,所以这个操作符的功能和 下面的这段代码类似:

    (defvar eltips-name "eltips"
      "The name of elptips.
    Please note: this variable is used as const variable.")
        
  3. 定义一个 library 内部使用 的全局变量
    (defvar eltips--name "eltips"
      "The name of elptips.")
        

    注:Lisp 有一个惯例:使用前缀加 -- 来表示这个全局变量是 library 内部使用的全局变量,用户不应该使用它,library 的维护者可以 随意添加,删除一个内部全局变量,可以对一个内部全局变量任意赋值, 更重要的是 library 维护者不需要维护内部全局变量的向后兼容性。

  4. 定义一个局部变量
    (let ((a 1)
          (b 2)
          c)
      (+ a b))
        
    (let* ((a 1)
           (b 2)
           (c (+ a b)))
      c)
        

变量赋值的正确方式

简单来说,变量必须先被定义,才能对其赋值。

可惜的是:这个规则非常简单,但新手往往不太注意。

在 Emacs-lisp 中,最常用的变量赋值操作符是:setq, 在一个 library 中,一般只能出现下面 两种 setq 赋值结构:

  1. 对一个 library 内部使用 的全局变量进行赋值:
    (defvar eltips--name "eltips"
      "The name of elptips.")
    (setq eltips--name "eltips2")
        
  2. 对一个局部变量进行赋值:
    (let ((a 1)
          (b 2)
          c)
      (setq c (+ a b)))
        

其他形式的 setq 赋值结构都是有问题的:

  1. 在 library 中对一个用户接口变量进行赋值
    (defcustom eltips-name "eltips"
      "The name of elptips.")
    (setq eltips-name "eltips2")
        

    这种做法是最应该避免的!!!

    无论这个用户接口变量属于自己 library 还是其他 library,都不应该 这么做,因为它直接违反了 “尊重用户选择” 这个原则,在一定条件下, 加载 library 会覆盖用户的设置,比如:

    (setq eltips-name "eltips3")
    (require 'eltips)
        
  2. 不能直接使用 setq 来定义变量

    setq 是变量赋值操作符,不是变量定义操作符,但 setq 有一个特性: 如果被赋值的变量不存在, setq 会首先定义这个 全局变量, 然后再赋值,下面两个例子是等价的:

    (setq eltips-name "eltips")
        
    (defvar eltips-name nil) ;这个全局变量会被用户当成用户接口变量
    (setq eltips-name "eltips")
        

    我个人感觉,Emacs-lisp 给 setq 添加这个特性是为了编写 configure 时省事, 但编写 library 的时候,这样做有覆盖用户设置的风险。

  3. 给一个没有定义的 局部变量 赋值
    (let ((a 1)
          (b 2))
      (setq c (+ a b)))
        

    这个例子本质是定义并赋值了一个 全局变量 c, 正确的写法应该是:

    (let ((a 1)
          (b 2)
          c) ; 这个 c 绝对不能遗漏
      (setq c (+ a b)))
        

    由于这种方式很容易出现遗漏,而且带来的问题不太容易调试( 因为容易覆盖 Emacs-lisp 核心使用的全局变量),所以建议使用 let* 来处理类似情况:

    (let* ((a 1)
           (b 2)
           (c (+ a b)))
      c)
        

对变量赋值的再思考

通过 “变量赋值的正确方式” 的讨论,我们可以发现,在编写 library 的 时候,setq 最合理的使用方式只有 一种 , 即:对 library 内部使用的 全局变量赋值:

(defvar eltips--name "eltips"
  "The name of elptips.")
(setq eltips--name "eltips2")

局部变量 赋值时要慎用 setq, 优先考虑使用 let* , 如果必须使用, 一定要确保这个局部变量已经在 let 结构中定义了。

在其他情况使用 setq 可能就是滥用了,当然我这里只是说 可能, 只要你的 使用方式遵循 library 编写原则,那也许就是合理的用法 :-)

如果必须设置用户接口变量,该怎么办?

虽然 library 维护者不应该随意覆盖用户接口变量,但现实情况是: 我们有时候必须这样做,理想很丰满,但现实却很骨感。。。

这时候,我们就要退而求其次,遵循 “用户知情同意原则”, 尽最大努力 减小影响范围。

常见的方式有四种,但一般只建议使用前两种方式,后面两种方式是 黑科技, 一定要谨慎使用,不合理的应用会让你遭到唾弃。

  1. 在 library 文档中指导用户自己设置

    这种方法是最稳妥可靠的,大多数情况下,我们只能使用这种方式。

  2. 使用 let 表达式来 局部覆盖 一个用户接口变量
    (let ((erdai-name "erdai2"))
      (erdai-return-name))
        

    在 let 定义的局部范围, erdai-name 会被强制绑定到另外一个值, 这个用法 非常的常用 ,当满足下面两个条件时,就可以这么用。

    1. library 所依赖的函数无法通过参数设置,只能通过全局变量来改变其行为。
    2. 对这个全局变量局部绑定,不会对所依赖的 library 造成影响。

    比如:

    (defun erdai-return-name ()
      (message erdai-name))
    
    (defun erdai-return-fakename ()
      (interactive)
      (let ((erdai-name "erdai2"))
        (erdai-return-name)))
        

    注:这种方式让熟悉词法作用域的同学很不习惯,确实是这样子的,在 Emacs-lisp 中全局变量无论什么时候,都是按照动态作用域的规则来处理。

  3. 使用激活函数来覆盖用户接口变量
    (defun elptip-erdai-enable ()
      (interactive)
      (setq erdai-name "erdai2")
      (message "eltips: `erdai-name' has been override."))
        

    这种方式要注意:

    1. 激活函数不能默认运行,只能通过文档告诉用户在它们的配置中添加。
    2. 如果无法做到完全无影响,就要提示用户哪个或者哪些 “用户接口变量” 被强制覆盖了。
    3. 最好告诉用户,如何简单的取消激活,如果可以,添加一个 disable 函数, 但令人遗憾的是,disable 函数看似容易编写,其实往往是不可行的。 像这种覆盖用户接口变量的激活函数,一般也只能让用户删除这行配置, 然后重启 emacs, 别无它法。

    比如下面这个例子,看似可行,实际是不合理的。。。。

    (defun elptip-erdai-disable ()
      (interactive)
      (setq pkgxx-name "erdai")))
        

    除非万般无奈,这种方式不建议使用。

  4. 使用激活函数来覆盖影响用户接口变量的函数

    假设 erdai 中有一个函数专门用来处理用户 接口 erdai-name :

    (defun erdai-return-name ()
      (message erdai-name))
        

    我们可以通过替换 `erdai-return-name’ 这个函数来改变 其行为,但我们不能直接在 eltips 包中添加一个新的 `erdai-return-name’ 函数,这种偷偷摸摸的覆盖让遇到 问题的用户很难调试,我们需要使用 emacs 内置的 nadvice 功能:

    (defun eltips-erdai-return-name ()
      (let ((erdai-name "erdai2"))
        (funcall orig-func)))
    
    (defun eltips-erdai-enable ()
      (interactive)
      (advice-add 'erdai-return-name
                  :around #'eltips-erdai-return-name))
        

    这样做的话,用户在阅读 `erdai-return-name’ 的文档 时,就可以发现这个函数被哪个函数 advice 了,算是 一种知情同意,这种方式的另外一种好处是可以写出一个 比较靠谱的 disble 函数。

    (defun eltips-erdai-disable ()
      (interactive)
      (advice-remove 'erdai-return-name
                     #'eltips-erdai-return-name))
        

    不过即便如此,emacs 官方社区也是不建议使用这种机制的

    这里还是那句话,除非万般无奈,不建议使用。

养成使用代码检查工具的习惯

我们有许多 Emacs-lisp 代码检查工具可以用来检查代码中 存在的问题:

  1. checkdoc
  2. elint
  3. package-lint
  4. byte-compile-file (用于检查 Emacs-lisp 编译错误)

我的建议是:代码提交之前,都应该用这些工具检查一遍, 去除所有的警告和错误后再提交,如果检查的频率太低, 可能你就没有动力做这个事情了。

如何把自己的 package 提交到 Melpa

这里以 github 为例:

  1. 注册一个 github 帐号,比如:tumashu
  2. 用注册的帐号登录 github
  3. 进入 Melpa 的 github 页面
  4. 点击 Fork 按钮, 在 tumashu 的帐号下得到一个 melpa 代码仓库的镜像。
  5. 抓取 Melpa 官方仓库
    cd ~/projects/
    git clone https://github.com/melpa/melpa.git
        
  6. 添加 melpa 镜像仓库(Fork 得到的仓库)的地址
    git remote add my-melpa https://github.com/tumashu/melpa.git
        
  7. 更新工作目录(很重要的工作)
    git reset --hard origin
    git pull
        
  8. 在 recipes 目录下添加 recipe 文件,并 commit 你的更改, recipe 文件的格式请参考: Melpa README
  9. 将更改推送到镜像仓库,这里一定要使用 强制推送
    git push -f my-melpa master
        
  10. 然后给 melpa 官方仓库提交一个 pull request
  11. 等待这个 pull request 合并,一般需要1周时间,在这个过程中, melpa 维护者会对 package 的代码做出相应的评价,你需要:
    1. 按照要求更改
    2. 说明理由,如果你的理由合理充分,Melpa 维护者会同意的。
  12. Pull request 合并后大约 4 个小时,你的 package 就会出现在 melpa 中。
  13. 更新工作目录(很重要的工作)
    git reset --hard origin
    git pull
        

如何把自己的 package 提交到 Elpa

Elpa 是 Emacs 官方的 package 仓库,所以把一个 package 提交到 elpa, 相对来说复杂罗嗦一点:

  1. package 代码的主要供献者,需要签署 GNU 纸质协议,一般需要自己下载 并打印 pdf 格式的协议文件,然后签上自己的名字,并用邮件的方式邮寄到 指定的地址,这个工作大概需要20天左右,什么是 “主要供献者”,GNU 有具体 规定,一般是按代码的行数来确定。
  2. package 代码的质量要求稍微高一点,package 的代码会在 emacs-devel 上 经过 emacs 大牛们的评价,不过有时候简单的 package, 大牛们也懒得评价 :-)
  3. Elpa 是直接把 package 的代码提交到 elpa.git, 而不是和 Melpa 一样, 只提交一个 package 描述,所以操作过程稍微罗嗦一点。

但将 package 提交到 elpa 也有不可比拟的优势:elpa 是 emacs 的官方 package 仓库, 属于亲儿子,你懂得。。。。

将 package 提交到 elpa 之前,你首先需要规划 package 未来的开发流程, 你需要做下面几个决定:

  1. 怎么维护 package
    1. 使用 elpa.git 做为唯一的代码仓库。
    2. 使用独立 package 仓库,定期和 elpa.git 同步。
  2. 如何提交 package
    1. 自己提交:这个需要用户获取 elpa.git 的推送权限。
    2. 别人代劳:这个不需要 elpa.git 的推送权限,只需要将代码 patch 发送到 emacs-devel 上,请 elpa.git 的管理员代为推送就可以了。

注意:“别人代劳” 模式只适用于 “以 elpa.git 做为 package 的唯一维护仓库” 这种开发模式,这种开发模式最简单,但用的人不是很多,大多数 package 维护者 都会向 elpa 管理员申请 elpa.git 推送权限,自己推送提交。

我在下面只介绍:“package 使用独立仓库,定期和 elpa.git 合并,并自己 提交代码” 这个流程。

  1. http://savannah.gnu.org/ 上注册一个帐户,比如: tumashu
  2. 登录后,上传 ssh 公钥,(必须的,不然以后无法推送代码)
    登录 savannah.gnu.org -> 点击 [ My Account Conf ] 链接
                          -> 点击 [ Authentication Setup ] 链接
                          -> 点击 [ Edit the 2 SSH Public Keys registered ] 链接
                          -> 按页面上的具体说明操作
        

    注:公钥生效需要1个小时,请耐心等待。。。。

  3. 给 emacs-devel 发一份邮件,主题为:[ELPA] New package: <pkg-name>, 邮件正文中介绍一下你的 package,并说明 package 代码的位置,具体细节请 参考 elpa README 中的 “Notify emacs-devel@gnu.org” 章节。
  4. 接受大牛们的 review, 这个过程一般需要2-7天时间。
  5. 按照大牛们提出的意见和建议更改代码,一直更改到大牛们认为你的 package 适合 加入 elpa. 这个过程难度不大,就是比较繁琐,不过也有直接被 KO 的可能。。。
  6. 如果 Emacs 的开发者或者 elpa.git 的维护者认为你的 package 已经 OK 了, 你就可以申请 elpa.git 的推送权限了:
    登录 savannah.gnu.org -> 点击 [ My Groups ] 链接
                          -> 点击 [ Request for Inclusion ] 链接
                          -> 搜索 emacs 并钩选
                          -> 写 comment, 要说明你是哪个 package 的维护者
                          -> 点击 [ request inclusion ] 按钮
                          -> 等待 elpa.git 管理员授权 (Request for Inclusion Waiting For Approval)
        
  7. 抓取 elpa.git

    确定当前帐号已经加入 emacs 组后,

    登录 savannah.gnu.org -> 点击 [ My Groups ] 链接
                          -> [ Groups I'm Contributor of ] 包含 emacs
        

    你就可以抓取 elpa.git 了。

    cd ~/projects/
    git clone tumashu@git.sv.gnu.org:/srv/git/emacs/elpa.git
        

    注:可以访问 https://savannah.gnu.org/git/?group=emacs , 来获取 member 抓取地址,千万不能搞错这个地址,不然以后就没法 git push 操作了,切记切记。。。。

  8. 仔细阅读 elpa README , 然后按照上面描述的流程提交代码就可以了, 这里只做一些说明:
    1. 你现在已经拥有 elpa.git 的推送权限了,做事千万别随意马虎。
    2. 禁用 git rebase -i 之类的更改 git 历史的命令,否则,全世界的 emacser 都可能会唾弃你。。。。
    3. 虽然 README 推荐 subtree, 但有些大牛觉得 externals 更靠谱, 估计智能看自己的喜好了,但值得注意的是: 如果已经使用 subtree 或者 externals 来同步,就千万别在使用 git format-patch 了,会有意想不到 的后果。
    4. 编辑 elpa/externals-list 文件,添加自己 package 的信息,按字母顺序排序。
    5. 如果遇到问题,勤问 emacs-devel, 别自以为是。。。。

未完待续。。。

尾注