


























最近在玩 Common Lisp ,由于中文博客几乎没有讨论,包括文档在内的英文资料都很零散——官网上只有简单的上手介绍,而社区维护的 文档 的某一页竟然写着 Please help us fill this page,我想这是因为 CL 的实现太多了,相关的网站很多,人力是分散的——看来得自力更生了,也借此写一篇博客,既用作信息整理,也试图提高一些 Lisp 的讨论度吧。
尽管 Common Lisp 的具体实现之间差异不会太大,但我想还是说明一下比较好:我使用的 Common Lisp 实现是
Steel Bank Common Lisp
,即 sbcl。
文章没有什么主题,可以当作一篇 Lisp 杂谈,还有一些我对编程语言的思考,希望读者可以在文中找到一些有启发的观点和设计吧,不过我不保证什么。
我的启蒙 Lisp 是
Clojure
,这门语言最明显的特征是使用了 ()(Parentheses)以外的其他括号,比如 [](brackets) 和 {}(braces)。其中,[] 表示向量,也被大量地用在函数签名、let 绑定等语法上;{} 表示映射,放在花括号里的奇数个元素是键,偶数个元素是值。这么做的好处是极大地缩短了代码,坏处是…… 不够纯粹?
据我所知,Common Lisp 和 Scheme 都只有一种括号,语法非常简单,所有程序结构都用圆括号包裹,不需要额外记忆别的语法。几乎没有语法也是 Lisp 的特点之一,而 Clojure 的做法无疑引入了不少新语法。
我目前还不能很好地回答这个问题,我只知道,在 Common Lisp 里不能像 Clojure 那样爽写映射,很不习惯。
要在 Clojure 里表示键值对,可以这样写:
{:key1 "val1"
:key2 "val2"
:key3 "val3"}
要在 Common Lisp 里表示键值对,需要用到 cons,这是我在 Clojure 里没仔细了解过的概念,不过据说是 Lisp 的基础概念,在包括 Clojure 在内的大多数 Lisp 方言中都有实现。这被称作「列表构造函数」,(cons x y) 的意思是「把 x 加入 y」。cons 的意思应该是 construct(构造)。
两个参数都是原子的 cons 调用构造有序对(ordered pair),例如 (cons 1 2) 的值被表示为 (1 . 2),其中的 . 表示这不是列表,而是一对值,这种类型的值被称作 cons pair 或者 cons cell。
一个有序对的前半部分被称作 car,后半部分被称作 cdr,(car (cons x y)) 返回 x,(cdr (cons x y)) 返回 y。这两个词的意思分别是寄存器编号的地址部分的内容(Contents of the Address part of the Register Number)和寄存器编号的减量部分的内容(Contents of the Decrement part of the Register Number)。之所以这么命名,貌似是出于一些历史原因,最早的 Lisp 是在上世纪的 IBM 704 计算机上实现的,和硬件强相关。不过具体的背景我就不打算深究了,这不是词源学系列。
当我们嵌套有序对,就构成了列表,例如 (cons 42 (cons 69 (cons 613 nil))) 构造以下的数据结构。最后一个 nil(空值)表示列表结束。

一般而言,上述结构用 (list 42 69 613) 简单表示。
显然,有序对除了不断嵌套表示列表结构,其本身就可表示一个键值对,即用 cons 表示键和值的关系(association)。把一系列扁平的关系都放进列表里,就成了关系列表(association list),也就是 alist。
(defparameter *alist* (list (cons 'a 1)
(cons 'b 2)
(cons 'c 3)))
这段示例代码来自
Marin Atanasov Nikolov
,其中 'a 表示对符号 a 的「引用」,以 ' 开头的符号不会被求值,而是会原样保留,可以用来表示键。
要获取 alist 当中的某个有序对,可以用 assoc 函数。(assoc 'a *alist*) 会返回 (A . 1)1。如果只想要键或者值,用 cdr 或者 car 就好了。此外,还可以用逆向关联 rassoc(reverse association),通过值来查找有序对,(rassoc 1 *alist*) 也会返回 (A . 1)。
1.
Common Lisp 不大小写敏感,所有代码最终都会被转换为大写字母。 ↩︎
这个数据结构没有引入新的语言机制,非常简单。请思考一下,如果要给这个映射添加新的关系,应该怎么做?
用 cons 把新的对加入到原来的列表中就行了,就像这样:(cons (cons 'd 4) *alist*)。还有另一个很好用的函数叫作 acons,它可以从第一和第二个参数创建新的有序对,然后再执行 cons,这样就可以少一层嵌套了:(acons 'd 4 *alist*)。不过这两个函数都不会修改原来的 *alist*,而是返回新的数据结构,编写函数式程序很合适;如果想要改变原来的变量,可以用 push,就像这样:(push (cons 'd 4) *alist*)
此外还有一个和 Clojure 的
zipmap
很像的
pairlis
2,接收两个列表,然后按照他们的索引(index),把他们组合成 alist。
2.
我没拼错,就是 pairlis,没有 t ↩︎
(pairlis '(a b) '(1 2))
;; => ((B . 2) (A . 1))
有序对组成的列表看起来很符合直觉,不过从结构上看,((A . 1) (B . 2)) 为什么不能变成扁平的 (A 1 B 2) 呢?我联想到数据库设计的 Schema-On-Write 和 Schema-On-Read,前者坚持写入提前定义好的结构化数据,数据的结构本身就应该表达明确的语义,后者则在读数据时才解析数据结构(比如可以把 JSON 字符串写进数据表里,读取之后再解析 JSON),更加灵活也降低了数据库设计的复杂度。
Clojure 的 {} 语法就是这样的,Common Lisp 也可以做到,可以直接把键值对按照奇偶顺序写进列表里,这种列表叫作属性列表(Property List),即 plist。从结构上看,plist 只是列表,不能表示映射关系,但在读取数据时,可以按照约定俗成的方式解析,此时它就有了映射关系。用 getf 就可以从 plist 中获取键对应的值。
(let ((plist (list :a 1 :b 2)))
(getf plist :a))
;; => 1
其中 : 开头的值表示关键词(keyword),和 Clojure 等 Lisp 方言一样,类似无需提前定义的枚举类型,一般表示键值对的键。尽管这样一来和 Clojure 的差别就小了些,但还是要写个 list 函数。
这么看来,Clojure 作为一门目前看来比 Common Lisp 抽象层次更高的语言,帮我隐藏了不少底层细节,用起来令人愉悦(比如 Clojure 中 car 对应 first,cdr 对应 rest)。Common Lisp 尽管在大多数时候也不需要接触这些细节,但在学习时会自然地接触到相关概念,体感上是更适合用来研究(满足好奇心)的语言。
Lisp 的基石当然是列表,一般的列表都表示函数调用(至少看起来是这样),第一个列表元素是函数名,之后的元素都是参数。如果不关注语言底下是什么,这么理解 Lisp 代码没有问题,加法 (+ 1 2) 是函数,逻辑运算 (or t nil) 是函数,赋值 (setq *foo* "bar") 也是函数——语法统一,非常美好。
但事实并非如此,不是所有列表都是函数调用,有些东西无法通过函数实现。比方说 if,它接收三个参数:条件、条件为真时求值的表达式和条件为假时求值的表达式。
(if (= a 1)
(format f "A equals 1")
(format f "A does not equal 1"))
如果 if 是函数,那么这个函数是没有意义的,因为在调用函数时,程序会先求值所有的参数,然后把求值过后的参数传递给函数体。然而,if 存在的目的不就是不求值某一条分支语句吗?
况且 format 函数的返回值是 nil,上述代码如果是函数,只会得到这样的表达式:(if t nil nil)3——无论 if 函数怎么实现,这种调用都没有意义。
3.
Common Lisp 用 t 表示逻辑真,用 nil 表示空和逻辑假,没有 f 或者 false。 ↩︎
or 也不是函数。或运算只要有一个真值,整个表达式的值都为真,那么把所有参数都求值过后再计算就很低效,在求值到第一个真值时就可以返回 t 了。
这些不是函数的“函数”,要么是宏,要么有着特殊的解释(或编译)规则。
之所以想谈这个话题,是因为我在某个课程设计项目中嵌入了一门脚本语言。什么样的语言最容易编写解释器呢?当然是 Lisp。只需要根据括号和原子进行分词,根据词元构造出抽象语法树,再递归地求值就好了。(其实我也可以把 Lua 之类的轻量脚本语言嵌入进去,但为什么不用 Lisp?)
其中有相当一部分“函数”会直接绑定到某个 Go 语言函数上,另外一些被定义为「特殊形式」,会做特殊处理。不过我在这里做了一个不太明智的决策:由于这是个多维表格应用,所以我内置了一些快速获取表格数据的「特殊形式」,比如 (where this (= id 1)) 能够获取 id 为 1 的行,其中 (= id 1) 创建了新的上下文,id 被绑定到表格的列名上,并且每次遍历都会执行一遍,这显然不是个函数,因为 (= id 1) 若是直接求值就会报错。如果要用函数实现,就要传入一个用于筛选的匿名函数,比如这样 (where this #(= (get % "id") 1))4——这更符合直觉,但语法又变得复杂了。
4.
这里借用了 Clojure 的语法,其中 #() 表示匿名函数,% 指代函数的参数。值得一提的是,Common Lisp 中的 #() 表示数组。 ↩︎
然而编写特例又会让语言本身变得臃肿,所以另一种做法是编写宏(macro),也就是能够写代码的代码。既然 (where this #(= (get % "id") 1)) 可以用函数实现,不必编写特例,而 (where this (= id 1)) 更简洁,那我能不能编写宏,让 (where* this (= id 1))5 在解释(或编译)之前就被展开为 (where this #(= (get % "id") 1)) 呢?
5.
这里之所以写的是 where* 而非 where,是因为宏和函数共享命名空间,不可能有一样的名字。 ↩︎
显然是可以的,或者至少可以做到类似的形式,并且宏定义是可以写在标准库和第三方库里的,这样一来,就无须在解释器(或编译器)中编写特例。
回到 Common Lisp,when 和 unless 宏会被展开为 (if ... do nil) 和 (if ... nil do),而 Common Lisp(当然 Clojure 和不少 Lisp 方言都有这两个宏)的解释器和编译器不需要单独实现 when 或 less,只需要实现 if,剩下的只要交给宏就好了。
当然,除了保持语言简洁,宏还能方便程序员拓展语言能力,用自己喜欢的方式编写代码而不必忍受语言限制。
可能是因为我一开始读过的不少 Lisp 相关文章和书籍,都把 Lisp 和函数式编程联系起来,我一开始以为 Lisp 都是纯函数式的(思来想去,应该是鲍勃大叔的《整洁架构之道》把函数式编程范式称作「不少 Lisp 方言实现的范式」,误解就由此产生了),我还因为 Haskell 是纯函数式的编程语言,把它误以为是 Lisp 方言,闹过一些笑话。
Clojure 的确是一门整体上更偏好函数式编程的语言(关于什么是函数式,可以读
这篇文章
),比较直观的体现是,几乎所有的 Clojure 教程和示例代码都没有定义变量然后再赋值的教程。默认情况下,几乎所有变量都是不可变的,包括用 let 局部绑定的变量。
而 Common Lisp 似乎不强调这点,Common Lisp 可以轻易地给变量赋值,不过这种变量被称作动态变量(dynamic variable)。
(defparameter *dynvar* 1)
(setq *dynvar* 2)
*dynvar*
;; => 2
不过我还是更偏好无状态的程序,就算要用全局动态变量,也应该控制数量。只要不会太繁琐,用参数传递状态就足够了。如果要定义局部变量,就用 let。
(let ((a 1)
(b 2))
(+ a b))
;; => 3
let 显然不是函数,而是特殊形式,因为它创建了一个变量作用域,还包含未声明的符号,这些符号若是被当作函数参数求值就会报错。不过,它底下到底是怎么实现的呢?和其他语言的 let 变量声明一样吗?
在《Let Over Lambda》一书中,作者这样写道:
Although what let does is unambiguous, how it does it is deliberately left unspecified. What let does is separated from how it does it. Somehow, let needs to provide a data structure for storing pointers to values.
尽管 let 的作用并不模糊,它发挥作用的方式却被刻意地留白了。let 的作用与它的作用方式分离。通过某种方式,let 需要提供用来储存指向值的指针的数据结构。
据后文所言,Common Lisp 编译器会根据情况处理 let,选取最好的「存储」方案。程序员也可以在代码中提供更多的信息,这样 Lisp 编译器就能编译出效率更好的代码。
(defun register-allocated-fixnum ()
(declare (optimize (speed 3) (safety 0)))
(let ((acc 0))
(loop for i from 1 to 100 do
(incf (the fixnum acc)
(the fixnum i)))
acc))
这段代码中的 declare 就是给编译器的优化「建议」,编译时,这段函数会把 let 里的数据加载到 CPU 寄存器中,效率会很高。
书中还提到了 lambda(毕竟书名就是《Let Over Lambda》,书名其实是书中一种重要的 Lisp 编程模式,也缩写成 LOL),这里的 lambda 指代的是 λ 演算(λ-calculus),现代的非 Lisp 编程语言也把这个特性拿取用了,成了所有程序员都熟悉的「匿名函数」。
书中提到,let 的一种实现方式就是 lambda,但并没有展开,此时我们可以把目标转向《计算机程序的构造与解释》(SICP)一书。
The first part of the let expression is a list of name-expression pairs. When the let is evaluated, each name is associated with the value of the corresponding expression. The body of the let is evaluated with these names bound as local variables. The way this happens is that the let expression is interpreted as an alternate syntax for
((lambda (<var1> ...<varn>) <body>) <exp1> ... <expn>)No new mechanism is required in the interpreter in order to provide local variables. A let expression is simply syntactic sugar for the underlying lambda application.
这段代码首先使用 lambda 创造了一个匿名函数,这个函数有 n 个形参,同时,这段代码直接调用了这个函数,传入了 n 个参数。对 Lisp 不熟悉的读者(真的会有那样的读者读到这里吗?)可以参考 JavaScript 的立即执行函数。
(function(a, b) {
return a + b
})(1, 2);
// => 3
这相当于:
((lambda (a b) (+ a b)) 1 2)
;; 相当于
(let ((a 1) (b 2))
(+ a b))
要注意的是,SICP 使用的是 Scheme 而非 Common Lisp,不过你也看到了,这两门语言其实有不少相似之处。区别之一当然就是,Common Lisp 不指定 let 的实现方式,编译器可以做各种优化;而 Scheme 中,let 就是 lambda 的语法糖。
后者当然是更函数式的——有什么比创建一个立即执行的匿名函数以实现局部变量更函数式的?从效率方面考虑的话,调用函数就要在内存中创建函数栈帧,而经过优化的 Common Lisp 程序可以把数据直接存放在 CPU 寄存器里,这在执行效率上是更好的。
此外,Common Lisp 还能够实现 OOP——用 defgeneric 和 defmethod 实现多态,用 defclass 定义类,用 make-instance 实例化类——尽管函数式和面向对象并不冲突,但这也说明 Common Lisp 不那么注重函数式风格,它更像是一门什么都能干的语言。
后面的知识,之后再来探索吧。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。