Emacs 折腾日记(八)——CONS CELL和列表

本篇我们来介绍emacs lisp中的第一种复核结构——列表类型。

cons cell

从概念上讲 cons cell 非常简单,就是两个有顺序的元素。第一个元素叫 CAR、第二个元素叫 CDRCARCDR 名字来自于 Lisp

根据 emacs lisp 简明教程 上的说法:它最初在IBM 704机器上的实现。在这种机器有一种取址模式,使人可以访问一个存储地址中的“地址(address)”部分和“减量(decrement)”部分。CAR 指令用于取出地址部分,表示(Contents of Address part of Register),CDR 指令用于取出地址的减量部分(Contents of the Decrement part of Register)。cons cell 也就是 construction of cells。

至于历史出处我们并不需要特别关心,也不用掌握,我们只需要掌握相关用法即可。

其实我们可以将它想象成一个有两个抽屉的柜子,有一个抽屉叫 car 另一个叫cdr 。具体里面放什么东西没有限制,可以放基本的数据类型,也可以同样的放入这么一个柜子。

首先使用 cons 来构建一个cons cell。例如

(setq my-cons (cons 1 "hello")) ;; ⇒ (1 . "hello")
(setq my-cons (cons 1 nil)) ;; ⇒ (1)

因为一个cons cell 包含 car 和 cdr 两个元素,所以一般我们传入的时候需要两个参数。但是第二个参数可以为nil。

根据emacs在mini-buffer上的输出,其实还可以使用另一种方式来构建一个cons cell

(setq my-cons '(1 . "hello"))

我们看到在前面的代码中我们带了一个单引号,这个单引号用于表示符号(symbol)或字面量(literal)。具体来说,它的作用是防止后面的表达式被求值。

Lisp的语句是一个S-表达式,在解释器读到到一个S-表达式的时候会尝试对这个S-表达式求值。在出现括号的表达式的时候,会将括号内第一个元素作为函数进行调用,而将其他元素作为参数。如果不加引号,那么上面的代码就变成了

(setq my-cons (1 . "hello"))

这个表达式的含义就是调用1 这个函数,传入 "hello" 参数,并将函数的返回值设置成变量 my-cons 的值。因为没有这么一个函数,所以它执行会报错。

这里的引号就是 quote 函数,它用来表示对后面的内容不求值,仅仅作为一个符号传入。上面的代码也可以改成

(setq my-cons (quote (1 . "hello")))

再举一个例子

(setq my-cons '(a . b));; error

上述的代码会报错,虽然我们指定了 (a . b) 是一个符号,是一个cons cell,但是对于里面的 ab 却没有指定,因此解释器会尝试解释 a和b,然后发现a和b未定义,所以也会报错,我们可以使用单引号单独的指定a、b都是符号,或者给a、b变量设定值。虽然都不报错,但是它们的含义却是不同的。

(setq my-cons ('a . 'b))

(let ((a 1)
      (b 2))
  (setq my-cons '(a . b)))

cons cell还有一个特殊的值,那就是 nil 它表示一个空的 cons cell。它可以使用如下形式来给出

nil
'()

空表并不是一个真正的 cons cell , 但是为了编程方便,还是可以通过 carcdr 来取值,结果都是空。

(car '()) ;; ⇒ nil
(cdr '()) ;; ⇒ nil
(car nil) ;; ⇒ nil

列表

lisp的全程是 List Processing ,列表处理,从这点上看列表在lisp中的比重非常重,非常重要。

列表可以看作一个特殊形式的cons cell。在上面的介绍中,cons cell有两个元素,car和cdr,列表第一个元素是car,其余的是cdr。以此规律往下递归。

我们可以使用 list 函数来构建

(list 1 2 3) ;; ⇒ (1 2 3)

也可以使用上面的 quote 来构造

'(1 2 3) ;; ⇒ (1 2 3)

二者定义的时候有什么区别呢?quote 方式是直接将内容作为一个列表,而list 函数则是先解释执行后面的代码,再将结果构建成列表,下面是二者不同的一个例子

(list (+ 1 2 ) 3) ;; ⇒ (3 3)
'((+ 1 2) 3) ;; ⇒ '((+ 1 2) 3)

再来看一个例子

'(a b c) ;; ⇒ (a b c)
(list a b c) ;; ⇒ error, 因为a b c都未定义,无法解释执行

如果要使用 list 来生成类似于 (a b c) 这样的列表,关键点在于要告诉解释器a b c 它们不需要解释执行,可以使用 quote 来做到这点

(list 'a 'b 'c) ;; ⇒ (a b c)

测试函数

可以使用 consp 来判断一个对象是否是cons cell。使用 listp 来判断对象是否是列表,但是我们说列表是特殊的 cons cell 所以使用 consp 来检测列表,也会返回真

(consp '(1 2 3)) ⇒ t

除此之外,elisp 将cons cell也视为一种特殊的列表,因此下面的代码也返回t

(listp (cons 1 2)) ;; ⇒ t

但是nil 或者 '() 它们不是cons cell 也不是 list,所以判断它们都会返回 nil

(consp nil)
(consp '())

深入理解 cons cell 和列表

上面提到我们可以使用 conslist 来分别构造一个 cons cell 和列表,但是它们构造一个新的,不影响之前的,例如

(setq my-cons (cons 1 2))
(cons my-cons my-cons)
my-cons ;; ⇒ (1 . 2)

同时 cons 也可以在列表前增加一个元素,例如

(setq foo '(a b))
(cons 'x foo) ;; ⇒ (x a b)
foo ;; ⇒ (a b)

从上面返回的结果来看,cons 会创建一个新的列表,并且在新列表的最前面加上指定元素,但是它不会修改原有的列表。

cons 会返回新元素,不修改老元素还可以理解,因为它本来就是用来构建新的 cons cell 的。那么还有一个问题需要解释,为什么这样一个用来构建cons cell的函数会用来添加列表元素呢?

要回答这个问题,我们可以需要回归到列表的本质了。先看这么一个例子

'(1 . (2 . (3 . nil))) ;; ⇒ (1 2 3)

我们执行它,发现它会返回一个列表,从这个例子上看,列表本身就是一个cons cell。它是一个特殊的cons cell

按照列表最后一个 cdr 来区分的话,可以分成三类:

  • 第一类就是上述例子这样的,它的最后一个cdr是nil,它也被叫做真列表
  • 第二类,既不是cons cell也不是nil,这种被称之为点列表
  • 第三类,最后一个cdr 指向之前一个cons cell
'(1 . #1=(2 3 . #1#))                     ; => (1 2 3 . #1)

这个是教程中给出的环形列表的表示形式,它比较复杂。但是它的结构与当初学过的数据结构中的环形链表类似。

'(1 . (2 . (3 . 4))) ;; ⇒ (1 2 3 . 4)

上述代码是第二类列表的形式,它的最后一个cdr 是 4,既不是cons cell 也不是nil。

上述的代码中也可以看出来,并不是说有 . 的都是 cons cell,没有. 的就是列表。还是以前面的抽屉来类比,第二个抽屉里放的是nil或者其他基本数据类型,那么它就是一个 cons cell。如果放的是另外一个同样类型的抽屉,那么它就是一个列表。

用数据结构中的概念来类比的话,cons cell是一个不带指针的结构体,而列表就是一个带有指向自身结构体类型的指针域。即使它只有一个这种结构的对象也是一个列表的节点。

(cons 1 nil) ;; ⇒ (1)

上述代码就是这样的,第二节点域指向空,没有指向下一个节点,虽然只有一个节点,但它也是一个列表。

我个人的理解是,不应该严格区分cons cell 和列表,就像C/C++中的struct 和list,struct是组成list的基础,而list中每个节点又都是一个struct, 所以前面使用 consplistp 无法区分cons cell 和 lisp。而. 则可以看作是分隔符,分隔数据域和指针域的数据,指针域同样可以放入其他类型的数据,也可以放入 cons cell

列表的操作函数

添加列表元素

如果希望修改原始列表可以使用 push ,与栈操作类似,它是将当前值添加到列表头,例如

(setq foo '(a b))
(push 'x foo)
foo ;; ⇒ (x a b)

在列表前面添加元素使用 cons ,在列表后面添加元素可以使用 append

(setq foo '(a b))
(append foo '(x)) ;; ⇒ (a b x)
foo ;; ⇒ (a b)
(setq foo '(a b))
(append foo 'x) ;; ⇒ (a b . x)
foo ;; ⇒ (a b)
(setq foo '(a . b))
(append foo 'x) ;; error
foo

cons 类似,它同样不修改原始列表的值。 用上面C/C++结构体和链表的类比话术来说的话,它的作用是将第一个参数的最后一个节点的指针域的空指针替换成第二个参数。

上面的第一个例子,原本列表应该是 (a . (b . nil)) 它的最后一节点的指针域就是 nil,它被替换成了 (x), 可以写成 (x . nil) 。最后的结果就是 (a . (b . (x . nil))) 它是一个真列表,(a b x)

第二个例子,还是先将列表展开 (a . (b . nil)) ,将nil替换成 x ,最后的结果就是 (a . (b . x))

第三个例子,使用cdr 取出来的最后一个例子并不是空,所以它会报错

与C中链表类似,采用头插法的速度要比使用尾插法快得多。即使用 cons 速度要比使用 append 快

获取列表元素

列表就是一个个cons cell 串起来组成的,可以使用 car 和 cdr 来获取元素,我们可以自己尝试仿照着C中对链表的操作来写一个函数获取列表中任意位置的元素

(defun my-get-list-item(lst index)
  (let ((i 0))
    (while (and (cdr lst)
                (< i index))
      (setq lst (cdr lst))
      (setq i (+ i 1)))
    (if (<= index i)
        (car lst)
      nil)))

(my-get-list-item '(0 1 2 3 4 5) 2) ;; ⇒ 2

当然也可以使用递归来完成

(defun my-get-list-item(lst index)
  (if (or (not lst) (= 0 index))
      (car lst)
    (my-get-list-item (cdr lst) (1- index))))

(my-get-list-item '(0 1 2 3 4 5) 2)

递归版本相对于上面的循环来说要简单的多,代码量也少。递归版本中当列表为空或者当前索引为0时,停止递归并返回。利用空列表表的car 和 cdr 都是空这个特性,来将两种不同的情况使用同一操作进行处理。条件不满足时对cdr进行递归处理。

虽然可以自己写这样的算法来取列表的第n个元素,但是elisp中也提供的对应的操作函数。

使用 nth 来获取第n个元素,使用 nthcdr 来获取第n次调用cdr 的结果,也就是获取包含第n个元素的子列表

(nth 2 '(0 1 2 3 4 5)) ;; ⇒ 2
(nthcdr 2 '(0 1 2 3 4 5)) ;; ⇒ (2 3 4 5)

同时还提供了 last 来返回从右往左数第n个元素的子列表。和 butlast 来返回last之外的其它列表元素。

(last '(0 1 2 3 4 5) 3) ;; ⇒ (3 4 5)
(butlast '(0 1 2 3 4 5) 3) ;; ⇒ (0 1 2)

利用这些函数可以实现取某一范围的子列表

(defun my-get-sub-items (lst start end)
  (if (nthcdr start lst)
      (butlast (nthcdr start lst) (- (length lst) end))))

(my-get-sub-items '(0 1 2 3 4 5) 2 5) ;; ⇒ (2 3 4)

上面的代码比较简单,首先使用 nthcdr 来取start后面的内容,然后使用 butlast 来去掉 end 后面的内容。不知道各位读者还记不记得 length 这个函数,前面我们用它来获取字符串的长度,这里我们用它来获取列表的长度。

设置列表元素

一般情况下,我们可以放心的递归和对列表进行操作,因为上述的一些函数都不会修改原列表的值,在递归或者循环的过程中我们使用的是产生的临时列表。但是有时候会希望修改列表的值,例如在将列表作为栈来使用的时候,就需要出栈和压栈的操作。

设置元素的值,可以使用 setcar 和 setcdr 这两个函数。如果我想设置任意索引位置的值该怎么办呢?可以配合使用 nthcdrsetcar

(setq foo '(a b c))
(setcar foo 'x)
foo  ;; ⇒ (x b c)

(setq foo '(a b c))
(setcdr foo '(x y))
foo ;; ⇒ (a x y)

(setq foo '(a b c))
(setcdr foo 'x)
foo ;; ⇒ (a . x)

(setq foo '(a b c))
(setcar (nthcdr 1 foo) 'x) ;; ⇒ x
foo ;; ⇒ (a x c)

前面提到使用 push 在表头添加元素,这里再介绍一个 pop 函数,它用来删除表头元素,它们两个配合使用就能组成一个栈的数据结构

(setq foo '(a b c))
(push 'x foo) ;; ⇒ 
foo
(pop foo)

列表排序

将列表从尾到头进行反转可以使用 reverse ,例如

(setq foo '(a b c))
(reverse foo) ;; ⇒ (c b a)
foo ;; ⇒ (a b c)

我们可以看到,reverse也是不修改原始的列表,而是返回一个新的列表。如果想要修改原始列表可以使用 nreverse

(setq foo '(a b c))
(reverse foo) ;; ⇒ (c b a)
foo ;; ⇒ (a)

为什么这里foo 指向了列表的最后一个元素呢?使用当初学习C/C++链表操作时掌握的知识很好解释,原本foo指向的是列表头,但是反转之后,原来的链表头就变成最后一个元素,而没有修改foo指针指向的情况下,它就是指向链表的最后一个元素(这个原因是我猜的,不知道对不对)。

我们还可以对列表进行排序,可以使用sort 函数进行排序,它接收一个列表,并且接收一个排序方式的函数。例如

(setq foo '(3 4 5 1 2 0))
(sort foo '<) ;; ⇒ (0 1 2 3 4 5)
foo ;; ⇒ (0 1 2 3 4 5)

这里的 '< 是一个排序函数,有点像C++ 11 标准里面的 sort 函数,它可以传入一个函数用来表示排序时比较大小的一个过程。而且这里我们并不需要在这个时候调用 < 这个函数,所以先使用 quote 。在后续真正执行排序要比较大小的时候会调用它。

这里我们可以自己定义比较函数,比如这里我们按照字符串长度进行排序

(defun strlen-cmp (str1 str2)
  (< (length str1) (length str2)))

(setq foo '("hello" "emacs" "aaa" "bbbbbb"))
(sort foo 'strlen-cmp) ;; ⇒ ("aaa" "hello" "emacs" "bbbbbb")
foo ;; ⇒ ("aaa" "hello" "emacs" "bbbbbb")

;; 这里也可以使用lambda表达式
(setq foo '("hello" "emacs" "aaa" "bbbbbb"))
(sort foo (lambda (str1 str2)
            (< (length str1) (length str2)))) ;; ⇒ ("aaa" "hello" "emacs" "bbbbbb")
foo ;; ⇒ ("aaa" "hello" "emacs" "bbbbbb")

这里我们发现sort 已经将修改了原始列表,如果想要保留原始列表,可以使用 copy-sequence

(setq foo '(3 4 5 1 2 0))
(let ((temp (copy-sequence foo)))
  (sort temp '<)) ;; ⇒ (0 1 2 3 4 5)

foo ;; ⇒ (3 4 5 1 2 0)

还有像 nconcappend 功能相似,但是它会修改除最后一个参数以外的所有的参数,nbutlastbutlast 功能相似,也会修改参数。这些函数都是在效率优先时才使用。总而言之,以 n 开头的函数都要慎用

遍历列表

前面我们已经使用 car 和 cdr 能做到遍历列表,这里再介绍一下专门用来遍历的函数 mapcmapcar 。它们都可以遍历列表中的所有元素,它们的第一个参数是一个函数,每次遍历到一个元素的时候会调用这个函数并将元素作为参数传入这个函数。C++中没有提供这样的函数,但是也有类似的操作。例如使用 foreach 获取每个元素,然后根据元素来执行操作。

(setq foo '(0 1 2 3 4))
(mapc '1+ foo) ;; ⇒ (0 1 2 3 4)
foo ;; ⇒ (0 1 2 3 4)

(setq foo '(0 1 2 3 4))
(mapcar '1+ foo) ;; ⇒ (1 2 3 4 5)
foo ;; ⇒ (0 1 2 3 4)

这两个遍历函数的区别就是,是否使用返回值来构建新的列表,其中 mapcar 会根据返回值构建新的列表,而 mapc 则返回原列表。我们发现无论是哪个函数都无法修改原始列表,要修改原始列表当然也有方法,我能想到的一个方法就是循环,然后配合 setcarntdcdr 根据索引来设置。

好了,本篇的内容就到此为止了。本篇按照 emacs lisp 简明教程 的内容修改而来的。原教程还有好多其他数据结构的操作,但是我作为初学者还是希望本篇内容专注在列表上,至于教程中涉及的其他操作或者数据结构,等后面学到了再了解也不迟。

posted @   masimaro  阅读(14)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」
历史上的今天:
2024-01-13 2023 年度回顾与2024 年展望
点击右上角即可分享
微信分享提示