关于 EmacsLisp 中结构化数据的一些看法
那么,你的 Emacs 包已经超过了十几行代码,并且它使用了结构化和异构的数据。 简单的列表,莫名其妙的lisp代码,已经不能再这样继续下去了。你真的需要清晰对结构进行抽象,这既为了组织,也为了阅读代码的人。使用列表作为结构时,您可能会经常问这样的问题,name 槽是存储在第三个列表元素中,还是存储在第四个元素中?
plist 和 alist 可以帮助解决这个问题,但它们更适合不规范的,外部环境提供的数据,而不适合具有固定插槽的内部结构。偶尔有人建议使用散列表作为结构,但是 Emacs Lisp 的散列表太重了。散列表更适合于当键本身是数据时使用。
从零开始定义数据结构
想象一个冰箱,在冰箱中装着食物。食物可以被构造成一个普通列表,在特定的位置放特定的东西。
(defun fridge-item-create (name expiry weight)
(list name expiry weight))
计算食品平均重量的函数可能是这样的:
(defun fridge-mean-weight (items)
(if (null items)
0.0
(let ((sum 0.0)
(count 0))
(dolist (item items (/ sum count))
(setf count (1+ count)
sum (+ sum (nth 2 item)))))))
注意最后使用了 (nth 2 item)
来获得该物品的重量。 这个神奇的数字2很容易让人困惑。更糟糕的是,如果大量代码以这种方式访问“重量”,那么未来的扩展将受到限制。定义一些访问函数可以解决这个问题。
(defsubst fridge-item-name (item)
(nth 0 item))
(defsubst fridge-item-expiry (item)
(nth 1 item))
(defsubst fridge-item-weight (item)
(nth 2 item))
defsubst
定义了一个内联函数,因此与直接 nth
相比,这些访问函数实际上没有额外的运行时成本。
由于这些函数只用来获取属性值,因此我们还应该使用内置的gv(通用变量)包定义一些setter。
(require 'gv)
(gv-define-setter fridge-item-name (value item)
`(setf (nth 0 ,item) ,value))
(gv-define-setter fridge-item-expiry (value item)
`(setf (nth 1 ,item) ,value))
(gv-define-setter fridge-item-weight (value item)
`(setf (nth 2 ,item) ,value))
这使每个属性可通过 setf 进行赋值。通用变量对于简化api非常有用,因为不如不这样就需要定义相同数量的 setter 函数了(fridg-item-set-name
等等)。
通用变量能够提供相同的入口点:
(setf (fridge-item-name item) "Eggs")
这里有两个更重要的改进。
就 Emacs Lisp 而言,这些 list 不是一个真正的类型。它的类型只是由包的约定虚构的。很容易错将其他列表传递给这些
frige-item
函数,且只要该 list 至少有三个项目,就不会发现错误。一种常见的解决方案是添加类型标记:在结构的开头添加标识它的符号。它仍然是一个链表,并且
nth
必须遍历该链表(即O(n)
)来检索项。使用向量会更有效,把它变成一个有效的O(1)
运算。
下面代码同时解决这两个问题:
(defun fridge-item-create (name expiry weight)
(vector 'fridge-item name expiry weight))
(defsubst fridge-item-p (object)
(and (vectorp object)
(= (length object) 4)
(eq 'fridge-item (aref object 0))))
(defsubst fridge-item-name (item)
(unless (fridge-item-p item)
(signal 'wrong-type-argument (list 'fridge-item item)))
(aref item 1))
(defsubst fridge-item-name--set (item value)
(unless (fridge-item-p item)
(signal 'wrong-type-argument (list 'fridge-item item)))
(setf (aref item 1) value))
(gv-define-setter fridge-item-name (value item)
`(fridge-item-name--set ,item ,value))
;; And so on for expiry and weight...
只要 fridg-mean-weight
使用 fridg-item-weight
访问器,它就可以在数据结构改变时本身也无需修改。
但是,唷,这要为包中的每个数据结构编写和维护大量的样板! 用宏能够完美解决样板代码生成的问题。幸运的是,Emacs 已经定义了一个宏来生成所有这些代码: cl-defstruct
。
(require 'cl)
(cl-defstruct fridge-item
name expiry weight)
在 Emacs 25 和更早的版本中,这个看起来很简单的定义会扩展为上面所列的所有代码。
它生成的代码以对应版本 Emacs 的 最优形式 表达,并通过使用 side-effect-free
和 error-free
等函数声明来进行多重优化。
它也是可配置的,允许去除类型标记 :named
——丢弃所有类型检查——或者使用列表而不是向量作为底层结构 :type
。
它甚至支持简单的结构继承,允许直接嵌入其他结构 :include
。
两个陷阱
不过,这里有几个陷阱。首先,由于历史原因, 宏会定义两个没有名称空间的函数: make-NAME
和 copy-NAME
。
我总是重载这些函数,更倾向于对构造函数在结尾加 -create
的约定,且不定义copy函数,因为它要么毫无用处,要么在语义上是错误的。
(cl-defstruct (fridge-item (:constructor fridge-item-create)
(:copier nil))
name expiry weight)
如果构造函数不仅仅只是设置初值,通常会定义一个私有的构造函数(名称带双破折号),并用一个具有附加行为的公有构造函数包装它。
(cl-defstruct (fridge-item (:constructor fridge-item--create)
(:copier nil))
name expiry weight entry-time)
(cl-defun fridge-item-create (&rest args)
(apply #'fridge-item--create :entry-time (float-time) args))
另一个陷阱与打印有关。在 Emacs 25 和更早的版本中,由 cl-defstruct
定义的类型仍然只是约定的虚拟类型。
就 Emacs Lisp 而言,它们实际上只是向量。这样做的一个好处是 print和read 这些结构是无需定义的,因为向量本身是可以打印的。
序列化 cl-defstruct
结构到文件也很简单。参见 Elfeed数据库是如何工作的。
问题是,一旦结构被序列化后,就不会再修改 cl-defstruct
的定义了,它现在是一个文件格式定义,所以属性位置被锁定了,直到永远。
Emacs 26 给这一切带来了麻烦,尽管从长远来看是值得的。
Emacs 26 中有一个新的基本类型,它有自己的 reader 语法:recorder。
它类似于散列表 在Emacs 23.2 中有了自己的 reader。在 Emacs 26 中, cl-defstruct
使用 recorder 而不是向量。
;; Emacs 25:
(fridge-item-create :name "Eggs" :weight 11.1)
;; => [cl-struct-fridge-item "Eggs" nil 11.1]
;; Emacs 26:
(fridge-item-create :name "Eggs" :weight 11.1)
;; => #s(fridge-item "Eggs" nil 11.1)
到目前为止,属性仍然使用 aref
访问,所有类型检查仍然在 Emacs Lisp 中进行。惟一实际的更改是在分配结构时使用 record
函数代替 vector
函数.但它确实为未来更有趣的事情的出现铺平了道路。
主要的短期缺点是它破坏了 Emacs 25/26 之间打印的兼容性,cl-old-struct-compat-mode
函数可以实现某种程度的向后兼容性,但不能用于向前兼容性。
Emacs 26 可以读取和使用 Emacs 25 及更早版本打印的结构,但是反过来就不行了。
这个问题最初是 影响到了Emacs的内置包,当 Emacs 26 发布时,我们将在外部包中看到更多这样的问题。
动态分派
在Emacs 25之前,主要实现动态分派的内置包(专门针对其参数的运行时类型的函数)是EIEIO,尽管它只支持单分派(只针对某个参数进行分派)。EIEIO 将许多公共 Lisp 对象系统(CLOS)的功能引入了 Emacs Lisp,包括类和方法。
Emacs 25 引入了一个更复杂的动态分派包,称为 cl-generic。
它只关注动态分派,支持多分派,完全替代了 EIEIO 的动态分派功能。
由于 cl-defstruct
实现继承,而 cl-generic 实现动态分派,所以 EIEIO 就没有什么可做的了——除了像多重继承和方法组合这样的坏主意。
除了这两个包,在 cl-defstruct
上构建单分派的最直接方法是 将一个函数放到某个属性中。那么“方法”就是调用这个函数的包装器。
;; Base "class"
(cl-defstruct greeter
greeting)
(defun greet (thing)
(funcall (greeter-greeting thing) thing))
;; Cow "class"
(cl-defstruct (cow (:include greeter)
(:constructor cow--create)))
(defun cow-create ()
(cow--create :greeting (lambda (_) "Moo!")))
;; Bird "class"
(cl-defstruct (bird (:include greeter)
(:constructor bird--create)))
(defun bird-create ()
(bird--create :greeting (lambda (_) "Chirp!")))
;; Usage:
(greet (cow-create))
;; => "Moo!"
(greet (bird-create))
;; => "Chirp!"
因为 cl-generic 知道由 cl-defstruct
创建的类型,所以函数可以对它们进行定制化,就像它们是原生类型一样。
让 cl-generic 来完成所有的工作要简单得多。读你代码的人也会喜欢:
(require 'cl-generic)
(cl-defgeneric greet (greeter))
(cl-defstruct cow)
(cl-defmethod greet ((_ cow))
"Moo!")
(cl-defstruct bird)
(cl-defmethod greet ((_ bird))
"Chirp!")
(greet (make-cow))
;; => "Moo!"
(greet (make-bird))
;; => "Chirp!"
大多数情况下,简单的 cl-defstruct
就能满足你的需要,只要记住构造函数和复制器名称的问题,它的使用就应该和定义函数一样自然。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论