eltips icon indicating copy to clipboard operation
eltips copied to clipboard

Emacs-lisp 奇淫异技

#+TITLE: Emacs-lisp 奇淫异技

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

#+BEGIN_EXAMPLE 50% 的决策都是拍脑门决定的。。。。 #+END_EXAMPLE

  • 谁应该读这个文档 符合下面特征的同学值得读一下这个文档:
  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 :-)

#+BEGIN_EXAMPLE ;; 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/. #+END_EXAMPLE

  • 选择合适的发布方式 到目前为止,最常见的发布途径有两种:
  1. [[https:/melpa.org][Melpa]]
  2. [[https://elpa.gnu.org/][Elpa]]

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

Melpa 的限制相对比较小了。

  • 创建 library 框架文件 方法非常多,比较简单方便的一种方式是:
  1. 新建并打开一个文件 eltips.el

  2. 运行 auto-insert 命令(建议临时关闭 ivy-mode)。

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

    #+BEGIN_SRC emacs-lisp ;;; eltips.el --- Emacs-lisp tips -- lexical-binding: t; --

    ;; Copyright (C) 2018 Feng Shu

    ;; Author: Feng Shu [email protected] ;; 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 #+END_SRC

  • 了解 Emacs-lisp Style 仔细阅读下面这个项目中的所有文档:

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

  • 定义变量的正确方式 Emacs-lisp 有许多定义变量的方法,但下面几种是最最常用的。
  1. 定义一个用户接口变量 #+BEGIN_SRC emacs-lisp (defcustom eltips-name "eltips" "The name of elptips.") #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (defvar eltips-name "eltips" "The name of elptips.") #+END_SRC

  2. 定义一个只读全局变量 #+BEGIN_SRC emacs-lisp (defconst eltips-name "eltips" "Eltips's name") #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (defvar eltips-name "eltips" "The name of elptips. Please note: this variable is used as const variable.") #+END_SRC

  3. 定义一个 library 内部使用 的全局变量 #+BEGIN_SRC emacs-lisp (defvar eltips--name "eltips" "The name of elptips.") #+END_SRC 注:Lisp 有一个惯例:使用前缀加 ~--~ 来表示这个全局变量是 library 内部使用的全局变量,用户不应该使用它,library 的维护者可以 随意添加,删除一个内部全局变量,可以对一个内部全局变量任意赋值, 更重要的是 library 维护者不需要维护内部全局变量的向后兼容性。

  4. 定义一个局部变量 #+BEGIN_SRC emacs-lisp (let ((a 1) (b 2) c) (+ a b)) #+END_SRC

    #+BEGIN_SRC emacs-lisp (let* ((a 1) (b 2) (c (+ a b))) c) #+END_SRC

  • 变量赋值的正确方式 简单来说,变量必须先被定义,才能对其赋值。

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

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

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

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

  1. 在 library 中对一个用户接口变量进行赋值

    #+BEGIN_SRC emacs-lisp (defcustom eltips-name "eltips" "The name of elptips.") (setq eltips-name "eltips2") #+END_SRC

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

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

    #+BEGIN_SRC emacs-lisp (setq eltips-name "eltips3") (require 'eltips) #+END_SRC

  2. 不能直接使用 setq 来定义变量

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

    #+BEGIN_SRC emacs-lisp (setq eltips-name "eltips") #+END_SRC

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

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

  3. 给一个没有定义的 局部变量 赋值

    #+BEGIN_SRC emacs-lisp (let ((a 1) (b 2)) (setq c (+ a b))) #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (let ((a 1) (b 2) c) ; 这个 c 绝对不能遗漏 (setq c (+ a b))) #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (let* ((a 1) (b 2) (c (+ a b))) c) #+END_SRC

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

#+BEGIN_SRC emacs-lisp (defvar eltips--name "eltips" "The name of elptips.") (setq eltips--name "eltips2") #+END_SRC

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

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

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

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

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

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

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

  2. 使用 let 表达式来 局部覆盖 一个用户接口变量

    #+BEGIN_SRC emacs-lisp (let ((erdai-name "erdai2")) (erdai-return-name)) #+END_SRC

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

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

    比如:

    #+BEGIN_SRC emacs-lisp (defun erdai-return-name () (message erdai-name))

    (defun erdai-return-fakename () (interactive) (let ((erdai-name "erdai2")) (erdai-return-name))) #+END_SRC

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

  3. 使用激活函数来覆盖用户接口变量

    #+BEGIN_SRC emacs-lisp (defun elptip-erdai-enable () (interactive) (setq erdai-name "erdai2") (message "eltips: `erdai-name' has been override.")) #+END_SRC

    这种方式要注意:

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

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

    #+BEGIN_SRC emacs-lisp (defun elptip-erdai-disable () (interactive) (setq pkgxx-name "erdai"))) #+END_SRC

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

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

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

    #+BEGIN_SRC emacs-lisp (defun erdai-return-name () (message erdai-name)) #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (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)) #+END_SRC

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

    #+BEGIN_SRC emacs-lisp (defun eltips-erdai-disable () (interactive) (advice-remove 'erdai-return-name #'eltips-erdai-return-name)) #+END_SRC

    不过即便如此,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. 进入 [[https://github.com/melpa/melpa][Melpa]] 的 github 页面
  4. 点击 Fork 按钮, 在 tumashu 的帐号下得到一个 melpa 代码仓库的镜像。
  5. 抓取 Melpa 官方仓库 #+BEGIN_EXAMPLE cd ~/projects/ git clone https://github.com/melpa/melpa.git #+END_EXAMPLE
  6. 添加 melpa 镜像仓库(Fork 得到的仓库)的地址 #+BEGIN_EXAMPLE git remote add my-melpa https://github.com/tumashu/melpa.git #+END_EXAMPLE
  7. 更新工作目录(很重要的工作) #+BEGIN_EXAMPLE git reset --hard origin git pull #+END_EXAMPLE
  8. 在 recipes 目录下添加 recipe 文件,并 commit 你的更改, recipe 文件的格式请参考: [[https://github.com/melpa/melpa/blob/master/README.md][Melpa README]]
  9. 将更改推送到镜像仓库,这里一定要使用 强制推送 #+BEGIN_EXAMPLE git push -f my-melpa master #+END_EXAMPLE
  10. 然后给 melpa 官方仓库提交一个 pull request
  11. 等待这个 pull request 合并,一般需要1周时间,在这个过程中, melpa 维护者会对 package 的代码做出相应的评价,你需要:
    1. 按照要求更改
    2. 说明理由,如果你的理由合理充分,Melpa 维护者会同意的。
  12. Pull request 合并后大约 4 个小时,你的 package 就会出现在 melpa 中。
  13. 更新工作目录(很重要的工作) #+BEGIN_EXAMPLE git reset --hard origin git pull #+END_EXAMPLE
  • 如何把自己的 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 公钥,(必须的,不然以后无法推送代码) #+BEGIN_EXAMPLE 登录 savannah.gnu.org -> 点击 [ My Account Conf ] 链接 -> 点击 [ Authentication Setup ] 链接 -> 点击 [ Edit the 2 SSH Public Keys registered ] 链接 -> 按页面上的具体说明操作 #+END_EXAMPLE

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

  3. 给 emacs-devel 发一份邮件,主题为:[ELPA] New package: , 邮件正文中介绍一下你的 package,并说明 package 代码的位置,具体细节请 参考 [[http://git.savannah.gnu.org/cgit/emacs/elpa.git/plain/README][elpa README]] 中的 "Notify [email protected]" 章节。

  4. 接受大牛们的 review, 这个过程一般需要2-7天时间。

  5. 按照大牛们提出的意见和建议更改代码,一直更改到大牛们认为你的 package 适合 加入 elpa. 这个过程难度不大,就是比较繁琐,不过也有直接被 KO 的可能。。。

  6. 如果 Emacs 的开发者或者 elpa.git 的维护者认为你的 package 已经 OK 了, 你就可以申请 elpa.git 的推送权限了: #+BEGIN_EXAMPLE 登录 savannah.gnu.org -> 点击 [ My Groups ] 链接 -> 点击 [ Request for Inclusion ] 链接 -> 搜索 emacs 并钩选 -> 写 comment, 要说明你是哪个 package 的维护者 -> 点击 [ request inclusion ] 按钮 -> 等待 elpa.git 管理员授权 (Request for Inclusion Waiting For Approval) #+END_EXAMPLE

  7. 抓取 elpa.git

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

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

    你就可以抓取 elpa.git 了。

    #+BEGIN_EXAMPLE cd ~/projects/ git clone [email protected]:/srv/git/emacs/elpa.git #+END_EXAMPLE

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

  8. 仔细阅读 [[http://git.savannah.gnu.org/cgit/emacs/elpa.git/plain/README][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, 别自以为是。。。。
  • 未完待续。。。
  • 尾注

Local Variables:

coding: utf-8-unix

End: