1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
|
+++
title = 'Coffee Break Clojure, Code as Data'
subtitle = '初识 Clojure 与 Lisp'
slug = 'clj0'
aliases = '/article/tutorial-clj-0/'
tags = ["Lisp","Clojure","编程"]
categories = '茶歇蔻芥'
date = 2026-09-15 22:58:43
draft = false
toc = true
endnotes = [
"我只是受《A Scheme Primer》的语言风格启发,并不是妄想写一篇同等量级和地位的文本。",
"若有**事实**问题和**技术性错误**,还请指出。"
]
fedi = 'https://akk.eltr.ac/objects/e5a14eb5-f7e6-41f3-834a-d3b741fa857f'
+++
如果有人问我最喜欢的编程语言是什么,我可能会在 Clojure 和 Go 之间纠结一番,但无疑我更*享受*手写 Clojure 的体验。可就像大部分 Lisp 一样,Clojure 的讨论度并不高,中文内容更是少得可怜。所以我为什么不能贡献一些内容呢?
我前段时间听过一档名为《Coffee Break French》的播客,内容是可以在午休时喝着咖啡听完的法语对话。学习一门编程语言怎么不可以这样轻松呢?我最近也有在帮忙翻译《[A Scheme Primer](https://files.spritely.institute/papers/scheme-primer.html)》,这篇教程的语言相当通俗。受其启发,再加上我一直想要练习节制地写作,而不是滔滔不绝地写上好几千字,所以我决定开启新的博客系列:《Coffee Break Clojure》。
希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。
---
## Show Me The Code!
我在了解一门编程语言之前,总想要先有些视觉印象,所以会找些代码片段来看。我说的不是 Hello World,而是真实的代码。
下面是一段从我的项目里摘取出来的[代码片段](https://codeberg.org/eltrac/weepinbell/src/commit/2ddf4e3fe155b1e4ff5dde8a587758d37d665ed4/src/weepinbell/webhook.clj#L16-L30)。
```clojure
(defn trigger
"Send requests to a list of webhooks.
Given a vector/list of URLs, it sends plain POST request to each URL.
Given a keyword and a map of URL vectors/list, it sends request to each URL in (:keyword map)."
([urls]
(trigger urls nil))
([keyw-or-urls webhooks]
(when-let [hooks (if (nil? webhooks)
keyw-or-urls
(->> (get webhooks keyw-or-urls)
(filter string?)
(filter valid-http-url?)))]
(when (keyword? keyw-or-urls)
(timbre/info "Webhook" (name keyw-or-urls) "triggered."))
(->> hooks (map send-webhook) doall))))
```
如果你第一次接触 Lisp,那么你可能注意到这段代码不仅有奇怪的缩进,还有很多、很多的*括号*。
不过,初学者也能发现一些熟悉的东西,比如字符串 `"Webhook"` `"triggeerd."`,还有条件语句 `if`。看样子,`defn` 和 Python 的 `def`、Go 的 `func` 一样,用来定义函数。
其他的也不难理解,比如第 10 行的 `(get webhooks keyw-or-urls)`,如果换个写法,读者可能就能看懂一些了:
```
get(webhooks, keyw-or-urls)
```
这段代码中的 `webhooks` 是映射(map),里面存的是键值对;`keyw-or-urls` 在这里是 `keyw(ord)`(关键词),也就是键值对的「键」;`get` 函数从 `webhooks` 中取出指定键的值。(之所以有 `-or-urls`,是因为这是门动态类型语言,这个值也可能本身就是我们要的 URL,不必从 `webhooks` 数组中取出)
没看懂也没关系,上面的代码不重要。
Clojure(以及所有的 Lisp)中的函数调用都写作 `(函数名 参数1 参数2 ...)` 的形式,符号之间一般用空格分隔。
这看起来可能不像函数调用,倒像个数组,或者说列表(list)。不仅是函数调用,函数定义、宏、条件语句、循环结构,以及包括列表本身在内的数据,都是列表。Lisp 的意思是 *LISt Processing*(列表处理)。
函数定义是这么写的:
```clojure
(defn function-name [arg1 arg2]
(println "I'm a function!")
(str arg1 arg2))
```
这也是个列表,列表的元素分别是 `defn`、`function-name`、`[arg1 arg2]` 以及后面的函数体。
如果要表示列表数据呢?可以这么写:
```clojure
(list "a" "b" "c")
;; => ("a" "b" "c")
```
函数调用和函数定义的写法一样,代码和数据的写法一样,除了个别语法糖,Clojure 几乎没有别的语法了,一切都可以用列表表示,Clojure 程序就是一个个相互嵌套的列表。
## To eval, or not to eval
回看列表:
```clojure
(list "a" "b" "c")
;; => ("a" "b" "c")
```
它返回的值是 `("a" "b" "c")`。为什么 `"a"` 作为列表的第一个元素,没有被当作操作的名字?为什么 `"b"` 和 `"c"` 没有被当作参数?
带着这个问题,先来理解 Eval 这个概念。你可能在 Python 和 JavaScript 里见过 `eval()` 函数,这个函数把字符串(也就是数据)当作代码来执行,像这样:
```js
// 你现在就可以按下 Ctrl/Command + Shift + I 打开 Console
// 在你的浏览器里输入这段 JavaScript 代码:
eval("1 + 1")
// => 2
```
JavaScript 也可以用函数构造数组:
```js
eval('Array("a", "b", "c")')
// => ['a', 'b', 'c']
```
上面这段代码和下面这段是等价的:
```js
Array("a", "b", "c")
// => ['a', 'b', 'c']
```
让我们回到 Clojure,下面这两段代码也是等价的:
```clojure
(list "a" "b" "c")
;; => ("a" "b" "c")
(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")
```
等等,单独出现的那个 `'` 是什么?为什么不写成字符串?
不妨试试求值字符串会发生什么:
```clojure
(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"
```
得到的是字符串本身!为什么会这样?
在 Clojure(以及所有的 Lisp)里,数据可以大致理解为两类:原子(atom)和列表(list)。括号里不能再分的东西就是原子,比如 `list` 和 `"a"`,而 `(list "a" "b" "c")` 是列表。
对原子执行 `eval` 操作,或者说求值(evaluate),得到的是原子本身,比如 `(eval 1)` 得到的就是 `1`(因此原子更专业的名字是 *[Self-evaluating objects](https://www.lispworks.com/documentation/HyperSpec/Body/26_glo_s.htm#self-evaluating_object)*,自求值对象);对列表求值,意思就是把列表当作形式(form)而非数据对象(object),形式的第一个元素是操作符(operator),剩下的是参数(arguments)——这就是我们在前一节建立的对 Clojure 的理解。
1. 原子(atom):不是列表、不可再分的数据对象(object)。
2. 列表(list):包裹在括号内的一个或多个原子。[^2]
3. 对象(object):不被求值的原子或列表。
4. 形式(form):被求值的列表,第一个元素是操作符,剩下的元素是参数(被求值的原子是自求值对象,求值的结果是它本身)
其实我们一直预设我们写的所有 Clojure 代码最终都会被求值。如果把一整个 Clojure 源代码文件当作形式(form)的集合,我们执行源代码的过程其实就是在对其中的形式求值(eval)。
理解 Eval 对理解 Lisp 非常重要,如果没理解,可以多读几遍。
回看这段代码:
```clojure
(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"
```
`eval` 求值的是一个字符串值,字符串是原子,是自求值对象,所以求值的结果是它本身。
```clojure
(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")
```
这段代码才是把列表当作形式来求值。是时候解释 `'` 是什么了。这个符号其实是个语法糖,上面的代码等价于:
```clojure
(eval (quote (list "a" "b" "c")))
;; => ("a" "b" "c")
```
我们不妨试试,没有这个 `quote`,最终会得到什么结果:
```clojure
(eval (list "a" "b" "c"))
;; Execution error (ClassCastException) at user/eval19 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')
```
报错了!捋一下发生了什么:一般来说,执行函数之前,要先对函数的参数求值。`eval` 函数的参数求值后得到 `("a" "b" "c")`,于是上面的代码就变成了:
```clojure
(eval ("a" "b" "c"))
;; Execution error (ClassCastException) at user/eval19 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')
```
于是 `("a" "b" "c")` 这个列表被当作形式,`"a"` 被当作操作符,而 Clojure 没办法把字符串 `"a"` 转换为操作符,所以报错了。
要保证 Clojure 不把这个列表误以为是函数调用,就要用到 `quote`。给形式打上引号,让 Clojure 把 `quote` 里面的东西当作对象而不是形式来处理。
写在源代码文件里的形式最终都会在运行时被求值(eval),如果想要一部分形式不被求值、被当作数据处理,就要打引号,用 `quote` 或者 `'` 标记。`quote` 看起来像函数,实际上是个特殊形式(special form),里面的参数不会被求值。
Clojure 代码在运行时默认被求值**一次**,若是多次求值,含义就会不同。
```clojure
(eval (eval '(list "a" "b" "c")))
;; Execution error (ClassCastException) at user/eval27 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')
```
不妨练习一下,搞清楚上面这段代码被求值时发生了什么。
还有一些别的小练习:
```clojure
;; 1. 为什么下面这两段代码是等价的?
(eval (quote (println "Hello World")))
(println "Hello World")
```
```clojure
;; 2. list 和 quote 的区别是什么?
(list "a" "b" "c")
;; => ("a" "b" "c")
'("a" "b" "c")
;; => ("a" "b" "c")
(list 1 (+ 1 1) 3)
;; => (1 2 3)
'(1 (+ 1 1) 3)
;; 猜猜这段代码会得到什么结果?
```
## 代码即数据
不难发现,在 Clojure 中:代码即数据。代码不过是被 Eval 的数据,而数据不过是没有被 Eval 的代码,归根结底都是被括号包起来的形式,区别仅仅在于有没有被 Eval。
这么看来,我们可以对代码做非常有趣的事情:
```clojure
(cons '+ (list 1 2 3))
;; => (+ 1 2 3)
```
如你所见,我们用 `cons` 把 `+` 放在了数字列表的最前面。这个形式被 Eval 了一次得到 `(+ 1 2 3)`,不难想象再 Eval 一次会发生什么。
```clojure
(eval (cons '+ (list 1 2 3)))
;; => 6
```
上述代码有个简便写法[^1]:
```clojure
(apply + (list 1 2 3))
;; => 6
```
往列表里添加元素不过是冰山一角,我们能对数据做什么样的变换,就能对代码做什么样的变换。我们还能借助宏(macro)在求值前变换代码,来构造某些很方便的写法,按自己的想法拓展这门语言。具体的内容会在之后谈到,今天的内容已经够多了。
回见!
[^1]: 虽然大体一致,底层的逻辑其实是不同的,`apply` 并不是 `(eval (cons func args))` 的语法糖。不过,`eval` 和 `apply` 都调用了 `IFn` 接口的 `applyTo` 方法,详见:[clojure.lang.AFn](https://github.com/clojure/clojure/blob/b4ea0f824b2eea039dfc06b796ed601e35cbeab6/src/jvm/clojure/lang/AFn.java#L143-L425)、[clojure.lang.Compiler](https://github.com/clojure/clojure/blob/b4ea0f824b2eea039dfc06b796ed601e35cbeab6/src/jvm/clojure/lang/Compiler.java#L4215-L4231) 和 [clojure.core/apply](https://github.com/clojure/clojure/blob/b4ea0f824b2eea039dfc06b796ed601e35cbeab6/src/clj/clojure/core.clj#L662-L675)。Clojure 虽然是 Java 写的,但代码很易读。
[^2]: 之后会讲到,列表实际上是用 cons 对构造的。
|