EMACS-DOCUMENT

=============>集思广益

为emacs-lisp创建的加强版defun语句

我一直在想以怎样的方式来写代码才能拥有漂亮的文档. 这些文档应该是随代码实时更新的,最好还能够做到自动校验. 有一种实现的方法就是将文档与代码紧密地结合起来. 越紧密越好. 我在 上一篇文章中 进行过一些有趣的尝试, 我通过宏来让我在定义函数参数的同时也指定参数的docstring. 这一次我扩展了这个思想,除了生成文档说明之外,还能提供参数的默认值以及要插入函数中的参数验证代码. 本文以Emacs-lisp为例,但仅仅是因为我对它比较熟悉而已. 这个思想应该对其他lisp系语言都是通用的.

考虑下面这个原型, 很普通的函数定义,用法和docstring.

(defun f1 (arg1 arg2)
  "Add two numbers."
  (+ arg1 arg2))

;; usage
(f1 3 4)

可以在Emacs中查看它的帮助信息.

(describe-function 'f1)

很明显,我懒得写docstring; 这条docstring中甚至没有关于参数的只言片语. 而且函数中也没有对参数进行校验,你可以传入一个字符串和一个数字,但是结果会报错. 也没有给参数提供默认值,这样你就必须同时提供两个参数的值才能调用该函数. 很明显这个函数还有很大的改进空间. 当然如果我不嫌麻烦,我也可以写出一个更好的函数,像这样:

(defun f1a (arg1 &optional arg2)
  "Add ARG1 and ARG2 together.
ARG1 and  ARG2 should both be numbers."
  (when (null arg2) (setq arg2 2))
  (unless (and (numberp arg1) (numberp arg2)) (error "arg1 and arg2 should both be numbers"))
  (+ arg1 arg2))

(list (f1a 3 4) (f1a 3))

这样子一次两次还可以,要是一直这样就太无聊了. 而且它对我来说也不是那么的完美. 比如,docstring中并没有提到参数默认值是什么,而且这个docstring是写死在代码里的,除非你查看代码否则你不知道这个docstring是什么. 下面让我们考虑一下另一种写函数的方法. 相比于上面的函数定义,用法和文档. 这次的函数定义有点罗嗦. Providing documentation, defaults and validation code in any form would make it that way no matter what.

(defn f2 ((arg1 "A number" :validate numberp)
          (arg2 "A number" :validate numberp :default 2))
  "Add the arguments."
  (+ arg1 arg2))

;; usage
(list (f2 3 4) (f2 3))
(describe-function 'f2)

使用这种方式定义函数时,会使用函数定义中的信息来组建函数的说明文档,而且生成的文档会遵循emacs-lisp的文档规范. defn 并不是普通的emacs-lisp函数; 这是我开发的用于生成函数代码的一个宏. 它的实现很长,但是它的实现思想是在开始定义函数之前先遍历各个函数,人啊后收集函数中的docstring,默认值以及对应校验函数. 然后我又生成函数的参数列表. 若设置了参数的默认值,那么我会生成一些代码来让函数在不带参数调用时设置这些参数的默认值. 类似的若这只了验证函数,那么也会生成对应的验证代码. 最后,我组成一条 defun 语句然后返回这条defun语句. 如果你想的话,可以从这里看到该宏的实现代码: https://github.com/jkitchin/scimax/blob/master/scimax-macros.el.

我们可以看看上面这段代码会被扩展成什么样子.

(macroexpand-1
 '(defn f2 ((arg1 "A number" :validate numberp)
            (arg2 "A number" :validate numberp :default 2))
    "Add the arguments."
    (+ arg1 arg2)))

你会发现它被扩展成了一个普通的 defun 语句, 而且自动生成了docstring,设置默认值的代码以及验证代码. 真不错.

让我们试着用破坏验证的参数来调用函数会发生什么. 运行会抛出一个错误. 现在我们来捕获这个错误来看看这个错误究竟是什么.

(condition-case err
    (f2 "oak")
  (error
   (error-message-string err)))

我们发现,当提供了错误类型的参数时,会报出一个很有意义的错误信息. 相比来说,原始版本的那个函数只能告诉我们有类型错误,但是不能告诉我们究竟是那个参数错了.

(condition-case err
    (f1 "oak" 4)
  (error
   (error-message-string err)))

最后一个例子,让我们来检查 &rest 参数,校验其中所有的参数都是数字.

(defn f4 ((rarg :rest
                :validate (lambda (x)
                            (-all-p 'identity (mapcar 'numberp x)))))
  "multiply all the arguments."
  (apply '* rarg))

(f4 1 2 3)
(condition-case err
    (f4 "oak" 4)
  (error
   (error-message-string err)))
(describe-function 'f4)

效果看起来也蛮不错的.