关于 EmacsLisp 中结构化数据的一些看法

发布于 2023-05-23 12:58:30 字数 7022 浏览 56 评论 0

那么,你的 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")

这里有两个更重要的改进。

  1. 就 Emacs Lisp 而言,这些 list 不是一个真正的类型。它的类型只是由包的约定虚构的。很容易错将其他列表传递给这些 frige-item 函数,且只要该 list 至少有三个项目,就不会发现错误。一种常见的解决方案是添加类型标记:在结构的开头添加标识它的符号。

  2. 它仍然是一个链表,并且 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-freeerror-free 等函数声明来进行多重优化。

它也是可配置的,允许去除类型标记 :named ——丢弃所有类型检查——或者使用列表而不是向量作为底层结构 :type

它甚至支持简单的结构继承,允许直接嵌入其他结构 :include

两个陷阱

不过,这里有几个陷阱。首先,由于历史原因, 宏会定义两个没有名称空间的函数: make-NAMEcopy-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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

要走干脆点

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

金兰素衣

文章 0 评论 0

ゃ人海孤独症

文章 0 评论 0

一枫情书

文章 0 评论 0

清晰传感

文章 0 评论 0

mb_XvqQsWhl

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文