diff options
| -rw-r--r-- | content/article/common-lisp-start.md | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/content/article/common-lisp-start.md b/content/article/common-lisp-start.md new file mode 100644 index 00000000..6f868d71 --- /dev/null +++ b/content/article/common-lisp-start.md @@ -0,0 +1,164 @@ ++++ +title = 'Just A Common Lisper' +subtitle = '' +slug = '' +tags = ["Lisp"] +categories = '' +date = 2026-06-02T09:44:33+08:00 +draft = true +toc = true ++++ + +最近在玩 [Common Lisp](https://lisp-lang.org/),由于中文博客几乎没有讨论,包括文档在内的英文资料都很零散——官网上只有简单的人上手介绍,而社区维护的[文档](https://lisp-docs.github.io/docs/getting-started)的某一页竟然写着 *Please help us fill this page*,我想这是因为 CL 的实现太多了,相关的网站而也很多——看来得自力更生了,也借此写一篇博客,既用作信息整理,也试图提高一些 Lisp 的讨论度吧。<!--more--> + +尽管 Common Lisp 的具体实现之间差异不会太大,但我想还是说明一下比较好:我使用的 Common Lisp 实现是 [Steel Bank Common Lisp](https://www.sbcl.org/),即 `sbcl`。 + +## Enter: Common Lisp (He says in parentheses) + +我的启蒙 Lisp 是 [Clojure](https://clojure.org/),这门语言最明显的特征是使用了 `()`(Parentheses)以外的其他括号,比如 `[]`(brackets) 和 `{}`(braces)。其中,`[]` 表示向量,也被大量地用在函数签名、`let` 绑定等语法上;`{}` 表示映射,放在花括号里的奇数个元素是键,偶数个元素是值。这么做的好处是极大地缩短了代码,坏处是…… 不够纯粹? + +据我所知,Common Lisp 和 Scheme 都只有一种括号,语法非常简单,所有程序结构都用圆括号包裹,不需要额外记忆别的语法。几乎没有语法也是 Lisp 的特点之一,而 Clojure 的做法无疑引入了不少新语法。 + +我目前还不能很好地回答这个问题,我只知道,在 Common Lisp 里不能像 Clojure 那样爽写隐射,很不习惯。 + +要在 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)和*寄存器编号的减量部分的内容*(Contents of the Decrement part of the Register)。之所以这么命名,貌似是出于一些历史原因,最早的 Lisp 是在上世纪的 IBM 704 计算机上实现的,和硬件强相关。不过具体的背景我就不打算深究了,这不是词源学系列。 + +当我们嵌套有序对,就构成了列表,例如 `(cons 42 (cons 69 (cons 613 nil)))` 构造以下的数据结构。最后一个 `nil`(空值)表示列表结束。 + +") + +一般而言,上述结构用 `(list 42 69 613)` 简单表示。 + +显然,有序对除了不断嵌套表示列表结构,其本身就可表示一个键值对,即用 `cons` 表示键和值的关系(association)。把一系列扁平的关系都放进列表里,就成了关系列表(association list),也就是 `alist`。 + +```lisp +(defparameter *alist* (list (cons 'a 1) + (cons 'b 2) + (cons 'c 3))) +``` + +这段示例代码来自 [Marin Atanasov Nikolov](https://dnaeon.github.io/common-lisp-lookup-tables-alists-and-plists/),其中 `'a` 表示对符号 `a` 的「引用」,以 `'` 开头的符号不会被求值(evaluate),而是会原样保留,可以用来表示键。 + +要获取 `alist` 当中的某个有序对,可以用 `assoc` 函数。`(assoc 'a *alist*)` 会返回 `(A . 1)`[^1]。如果只想要键或者值,用 `cdr` 或者 `car` 就好了。此外,还可以用逆向关联 `rassoc`(reverse association),通过值来查找有序对,`(rassoc 1 *alist*)` 也会返回 `(A . 1)`。 + +这个数据结构非常简单易懂。请思考一下,如果要给这个隐射添加新的关系,应该怎么做? + +用 `cons` 把新的对加入到原来的列表中就行了,就像这样:`(cons (cons 'd 4) *alist*)`。还有另一个很好用的函数叫作 `acons`,它可以从第一和第二个参数创建新的有序对,然后再执行 `cons`,这样就可以少一层嵌套了:`(acons 'd 4 *alist*)`。不过这两个函数都不会修改原来的 `*alist*`,而是返回新的数据结构,编写函数式程序很合适;如果想要改变原来的变量,可以用 `push`,就像这样:`(push (cons 'd 4) *alist*)` + +此外还有一个和 Clojure 的 [zipmap](https://clojuredocs.org/clojure.core/zipmap) 很像的 [pairlis](http://www.ai.mit.edu/projects/iiip/doc/CommonLISP/HyperSpec/Body/fun_pairlis.html)[^2],接收两个列表,然后按照他们的索引(index),把他们组合成 `alist`。 + +```lisp +(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`。 + +```lisp +(let ((plist (list :a 1 :b 2))) + (getf plist :a)) +;; => 1 +``` + +其中 `:` 开头的值表示关键词(keyword),和 Clojure 等 Lisp 方言一样,类似无需提前定义的枚举类型,一般表示键值对的键。尽管这样一来和 Clojure 的差别就小了些,但还是要写个 `list` 函数。 + +这么看来,Clojure 作为一门目前看来比 Common Lisp 抽象层次更高的语言,帮我隐藏了不少底层细节,用起来令人愉悦。Common Lisp 尽管在大多数时候也不需要接触这些细节,但在学习时会自然地接触到相关概念,体感上是更适合用来研究(满足好奇心)的语言。 + +## Not all are functions and not all functional + +本节要来谈函数和函数式编程,按理说这两个东西不应该放在一起,是两个话题,~~但我想写这样一个好玩的标题~~。 + +### Not all are functions + +Lisp 的基石当然是列表,一般的列表都表示函数调用(至少看起来是函数引用),第一个列表元素是函数名,之后的元素都是参数。如果关注语言底下是什么,这么理解 Lisp 代码没有问题,加法 `(+ 1 2)` 是函数,逻辑运算 `(or t nil)` 是函数,赋值 `(setq *foo* "bar")` 也是函数——语法统一,非常美好。 + +但事实**并非如此**,不是所有列表都是函数调用,有些东西无法通过函数实现。比方说 `if`,它接收三个参数:条件、条件为真时求值的表达式和条件为假时求值的表达式。 + +```lisp +(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` 函数怎么实现,这种调用都没有意义。 + +`or` 也不是函数。或运算只要有一个真值,整个表达式的值都为真,那么把所有参数都求值过后再计算就很低效,在求值到第一个真值时就可以返回 `t` 了。 + +这些不是函数的“函数”,要么是宏,要么有着特殊的解释(或编译)规则。 + +之所以想谈这个话题,是因为我在某个课程设计项目中嵌入了一门脚本语言。什么样的语言最容易编写解释器呢?当然是 Lisp。只需要根据括号和原子进行分词,根据词元构造出抽象语法树,再递归地求值就好了。(其实我也可以把 Lua 之类的轻量脚本语言嵌入进去,但为什么不用 Lisp?) + +其中有相当一部分“函数”会直接绑定到某个 Go 语言函数上,另外一些被定义为「特殊形式」,会做特殊处理。不过我在这里做了一个不太明智的决策:由于这是个多维表格应用,所以我内置了一些快速获取表格数据的「特殊形式」,比如 `(where this (= id 1))` 能够获取 `id` 为 `1` 的行,其中 `(= id 1)` 创建了新的上下文,`id` 被绑定到表格的列名上,并且每次遍历都会执行一遍,这显然不是个函数,因为 `(= id 1)` 若是直接求值就会报错。如果要用函数实现,就要传入一个用于筛选的匿名函数,比如这样 `(where this #(= (get % "id") 1))`[^4]——这更符合直觉,但语法又变得复杂了。 + +然而编写特例又会让语言本身变得臃肿,所以另一种做法是编写宏(macro),也就是能够写代码的代码。既然 `(where this #(= (get % "id") 1))` 可以用函数实现,不必编写特例,而 `(where this (= id 1))` 更简洁,那我能不能编写宏,让 `(where* this (= id 1))`[^5] 在解释(或编译)之前就被展开为 `(where this #(= (get % "id") 1))` 呢? + +显然是可以的,或者至少可以做到类似的形式,并且宏定义是可以写在标准库和第三方库里的,这样一来,就无须在解释器(或编译器)中编写特例。 + +例如 `when` 和 `unless` 宏,会被展开为 `(if ... do nil)` 和 `(if ... nil do)`,而 Common Lisp(当然 Clojure 和不少 Lisp 方言都有这两个宏)的解释器和编译器不需要单独实现 `when` 或 `less`,只需要实现 `if`,剩下的只要交给宏就好了。 + +当然,除了保持语言简洁,宏还能方便程序员拓展语言能力,用自己喜欢的方式编写代码而不必忍受语言限制。 + +### Not all functional + +可能是因为我一开始读过的不少 Lisp 相关文章和书籍,都把 Lisp 和函数式编程联系起来,我一开始以为 Lisp 都是纯函数式的(思来想去,应该是鲍勃大叔的《整洁架构之道》在讲函数式编程范式是,把它称作「不少 Lisp 方言实现的范式」,误解就由此产生了),我还因为 Haskell 是纯函数式的编程语言,把它误以为是 Lisp 方言,闹过一些笑话。 + +Clojure 的确是一门整体上更偏好函数式编程的语言(关于什么是函数式,可以读[这篇文章](/library/2026/functional-design/#什么是函数式编程)),比较直观的体现是,几乎所有的 Clojure 教程和示例代码都没有定义变量然后再赋值的教程。默认情况下,几乎所有变量都是不可变的,包括用 `let` 局部绑定的变量。 + +而 Common Lisp 似乎不强调这点,尽管有些麻烦,但 Common Lisp 可以轻易地给变量赋值,不过这种变量被称作动态变量(dynamic variable)。 + +```lisp +(defparameter *dynvar* 1) +(setq *dynvar* 2) +*dynvar* +;; => 2 +``` + +不过我还是更偏好无状态的程序,就算要用全局动态变量,也应该控制数量。只要不会太繁琐,用参数传递状态就足够了。如果要定义局部变量,就用 `let`。 + +```lisp +(let ((a 1) + (b 2)) + (+ a b)) +;; => 3 +``` + +`let` 显然不是函数,而是特殊形式,因为它创建了一个变量作用域,还包含未声明的符号,这些符号若是被当作函数参数求值就会报错。一般情况下变量是不可变的,就像没有声明 `mut` 的 Rust `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` + +## Footnotes + +[^1]: Common Lisp **不**大小写敏感,所有代码最终都会被转换为大写字母。 + +[^2]: 我没拼错,就是 `pairlis`,没有 `t` + +[^3]: Common Lisp 用 `t` 表示逻辑真,用 `nil` 表示空和逻辑假,没有 `f` 或者 `false`。 + +[^4]: 这里借用了 Clojure 的语法,其中 `#()` 表示匿名函数,`%` 指代函数的参数。值得一提的是,Common Lisp 中的 `#()` 表示数组。 + +[^5]: 这里之所以写的是 `where*` 而非 `where`,是因为宏和函数共享命名空间,不可能有一样的名字。 + |
