2026-10-02 01:27:29
请读者给我几段文本的时间平复心情,我方才忍受着飘散在房间里的烟味儿,下楼给我三个月前带回家的龟背竹换了水。想到刚回家看到它们的模样,我实在没办法平静下来。
因为没办法带上我养的龟背竹,所以放在家里,回家之后…… 它的叶子黄了,边缘已经要枯了,不知道家里人是用什么水养的它,水是褐黄色的,水已经要溢出水瓶了,我明明已经交代过不能超过根部…… 另一盆也半死不活了,因为之前它长得太好了,我给他换了大点的玻璃瓶,想着他能再抽一根叶,那个瓶子被打碎了,被放进了一个塑料桶里,叶子也黄了。你们他妈的对它们做了什么!
—— 我的联邦宇宙账号
清洗它们根部的时候,我感觉自己在看一个受了虐待的孩子。那些根部已经变色变形了,不再是之前漂亮整洁的白色,每次冲洗都会有好多碎屑跟着水流下来。清洗得我觉得能接受了,就给它们倒上了矿泉水。本想放在窗边,但担心被毫不在乎的家人再次打翻,最后还是放回了原位,在那胡乱堆放着食品、香烟和垃圾的茶几上为它腾了一个位子。
读者可能不能理解我为什么对两株植物这么上心。它们是我搬出乌烟瘴气的学生宿舍之后,在出租屋里养得最好的活物。我一直觉得人照料的生命会反映出照料者的生命状态,植物也是。有段时间,我的生活失序,感到十分苦闷,每天回到家就把自己关在房间了,不再活动。直到某天我突然发现我养的龟背竹看起来状态不太对,叶片摸起来不如之前坚挺,我这才意识到自己有多久没给它们换水了。照料它们其实很简单,要把原来的水倒掉,把根部清洗干净,再换上新的水,滴一两滴营养液,再放到能散射到阳光的地方。这些活动需要我走出房间,打开水龙头听见流水的声音,触摸它们的根须和茎叶。渐渐地,我感觉自己也好了起来。
在我对生活相对满意的阶段,被我放在卧室飘窗上的龟背竹突然抽了第三根叶,叶子是很新鲜的嫩绿色,卷卷的。过了不久,又趁我不注意,它的新叶舒展开了,上面的脉络看着很舒服。我记得我当时很开心,它们被我照顾得很好。我喜欢这样的关系,喜欢没有语言的交流。当我要前往另一个城市的时候,我从出租屋里带走了它们,但只能放在父母家。现在,我感到我把它们交给了非常不称职的照顾者。说真的,我怎么可以相信这群连我都没能养好的人呢?这指控主要是针对我爸。
飞机落地前,看到重庆的夜景时,我还有些触动,似乎第一次感到了故乡的熟悉感。我可能有些思乡,但我一点也不念家。上车的时候,我那父亲果不其然问起我有没有交女朋友。他叫我去“把妹”,遇到好看的就去“撩”,要不是我这次掌握了新的沟通技巧(就是在他说这话的时候不理他),他可能要再念叨些油腻的“泡妞技巧”,说起他以前被女生追到躲厕所这个已经被说过无数次且无从考究的辉煌故事。以前我还会写,我吐了,现在我已经没有任何反应。饭桌上,他也犯起爹味来。他从没觉得自己是个糟糕的父亲,从没意识到自己在我的童年和教育中是完全缺席的,看着他把一个个偶然事件包装成成功的教育故事(我大一的时候,他把我送到大学门口,那里车流量大,而且没地方停车,所以他就走了;现在,我是亲近的亲朋好友的孩子中,唯一一个有在上班的,他说起我一个人去外地实习的事情,把那件只把我送到校门口的经历包装成培养我独立性的教育选择,开始觉得自己 鸡巴大起来了 ),我无动于衷,毕竟这事儿他不是第一次干。饭厅的电视上在播放《疯狂动物城》,我就盯着电视看了一晚。我还没看过这部电影呢。
唯一让我不得不动起情绪来的,是他在饭桌上自豪地夸耀起自己把狗摔在地上砸来砸去,把猫的脸摁进尿里,又把它扔得老远的事迹来。他大概觉得欺负无辜(或者照它的说法,是声张正义,不是虐待,因为那狗太黏人,那猫乱尿尿,然而选择黏人品种的是他自己,不及时给猫绝育的也是他)的故事非常值得传诵。回家见到我的龟背竹变成那个样子,我觉得这个家对待生命毫无敬重,而我的父亲还时常跟人抱怨我不念家。我念你妈的家。
国庆前夕,我妈问我什么时候回来,我纠结过后,说我不想回去。我真的不想回这个家。我妈念起过节就是要回来看看家人,念叨着我更觉得烦人,便让她转告我父亲,把他那些乱七八糟的东西从我的书房里收拾掉。我每次回家,都要忍受满是怪味儿和杂物的书房,我爸在抖音直播间买各种“好东西”,但不想弄脏他的房间,于是全放在我那里了。我把房间收拾得尽可能整齐,一些东西实在处理不掉,只能藏起来(我爸说,就放那儿,我能说什么呢,这是他家,不是我家)。他俗不可耐,在书房里放的是很难搬动的中式大椅,和桌子的高度比例也不对,我在那房间里坐着,总是觉得背疼。忍受了这些之后,我离开几个月,再次回来,同样的戏码又要上演一遍,我又得在这个垃圾场里给自己腾地方了。这次回家,书房的确干净了,可我从我爸口中听到的是:你看你妈知道你要回来,把书房收拾得有多干净。我说:那是我要求的。我真想说脏字儿。我可怜我妈,我要的明明是我爸去收拾他自己的烂东西。
现在已经到了凌晨一点,我爸在我的房间外面打游戏,和某个女的语音通话,笑声很蠢。耳塞都隔不开他的愚蠢。他完全没有考虑过他的声音和游戏的声音会打扰到家里任何人休息,我能说什么呢,这是他家,不是我家。
这是他家,不是我家。他没有也没想过给我留位置,我有自知之明。
乡愁是驱使我找到工作,独立生活的唯一动力。我要逃离这个是非之地,我要有腰包和底气对这个男人说不,我可以说“老子就是不要回你的家”。我要有自己的房子,把我的龟背竹养好,等他的新芽长好长实,移植出好几盆,把它们交给可靠的人。那只被我爸摔过的狗今晚在我姑姑的怀里睡得很安心,她做这个男人的姐姐真的承受了很多。我会养一只狗狗,我永远也不要打它骂它,我要让他在我的怀里安安静静地睡去,我也安心入眠。第二天,阳光会洒在我们所有人身上。
2026-09-29 21:42:08
今天的文章衔接 上一期 有关集合(collection)和序列(sequence)的知识,来讨论一种特殊的集合,叫惰性序列(lazy seq)。我们还会接触到函数式编程(functional programing)。
其实在另一门名为 Haskell 的语言里,也能见到惰性,而 Haskell 是纯函数式的编程语言(Clojure 在很多方面都不如 Haskell 严格)。函数式和惰性非常契合,可以说没了惰性,函数式就不够务实,会消耗更多的计算资源。
编程范式(programming paradigm)可以理解为编程方法论。用我的方法解释的话,一套编程范式提供了一系列高度抽象的逻辑组件、通用的程序结构,比方说 for 循环结构就是
结构化编程
(structrual programing)这一范式提出的。
结构化编程的主要主张是 goto 语句有害论,由 Dijkstra 在 1968 年提出。这在当时颇有争议,不过如今已成公理,如今的新来者可能根本不会听说 goto 语句。我也从来没写过,我了解到 goto 的时候,我的 C 语言老师就严肃地告诉我们不该用 goto。
据 Robert C. Martin 在《
架构整洁之道
》里总结,结构化编程本质是上限制了程序控制权的直接转移,即不允许用 goto 语句直接变更程序的执行位置。要转移程序的执行位置,只能用块结构1表示条件判断、循环体等等。块结构使得程序可以被清晰地划分为可理解的部分,而不是一条一条分散的代码。
面向对象编程(object-oriented programing,后称 OOP)在块结构之上提出了新的组织程序的方式,也就是对象(之所以不说「类」,是因为也有无类 OOP,比如 基于原型的编程 )。程序员经常需要定义一种数据类型,然后定义对这种数据类型的操作(封装);对不同的类型和不同的输入而言,相同的操作可能有不同的行为(多态);数据类型的某些操作是共通的,可以复用(继承)。OOP 引入了更高层的概念和结构。据 Martin 所言,OOP 本质上是限制了对程序控制权的间接转移,因为 OOP 用方法(method)代替了函数指针(function pointer)。2
Martin 认为编程范式并没有引入新的东西,反而是提出了更多的限制,比如限制 goto 语句和限制函数指针。函数式编程(后称 FP)范式引入的限制是:禁止赋值语句。是的,不允许给变量赋值,所以在前四篇里我们一次都没有见到 Clojure 的赋值语句,只见到了定义符号的语句。
为什么?因为给变量赋值就意味着我们在维护一个可变的状态,而状态在 FP 看来是个坏东西:
这其实也在逐渐成为共识,我们看到很多编程语言(比如 Rust)的变量默认不可变(immutable)。状态更少会让复杂系统更容易维护,而 FP 则致力于消除可变状态。
不过整体而言,FP 并不主流。不少人对 Haskell 的第一印象是「学术语言」(一开始的确是)。Haskell 的标语是「Enjoy long-term maintainable software you can rely on」(享受你可以依靠的长期可维护的软件),这显然是出于工程考虑写下的标语。
在过去,FP 受到的不认可往往源自于它的性能问题。由于 FP 系统不维护状态,而是在函数的相互调用之间传递参数,循环结构也往往用递归(recursion)表示,这就导致处理大量数据时,函数栈帧会占用大量内存,而且可能出现栈溢出问题。不过这个问题现在已就有了应用广泛的解决方案,叫作尾部调用优化(Tail Call Optimization,TCO)——简单来说,只要递归函数调用发生在函数尾部,编译器或解释器就可以复用上一个递归调用的栈帧,把递归调用变成循环结构,但语言层面仍然是递归写法。
FP 不仅禁止赋值语句,还避免副作用(side effectse)。FP 把函数想象成和数学函数一样的东西,输入值,然后输出值,这其间不该发生别的事情,比如不该修改系统中其他的状态。
那 FP 程序怎么和外部设备交互呢?照其定义,I/O 操作也是副作用,我还不能把数据写入磁盘了吗?其实,一般只有纯函数式语言才会想要完全消除副作用,Haskell 是通过引入 IO 单子 的概念消除副作用的。Haskell 把对外部状态的访问和操作当作显式的作用,而非副作用。至于 Clojure,它并不是纯函数式语言。副作用是允许的,只是不鼓励。Scheme 也允许副作用,因此 Scheme 更喜欢用 procedure(过程)这个词表示 function,因为有副作用的不是纯函数。
与副作用相关的概念是引用透明性( referential transparency ),可以说避免副作用就是为了实现引用透明性。以下面这段 Clojure 代码为例:
(+ (- 3 1) 4)
;; => 6
;; 上述代码应该和下面这段代码等价
(+ 2 4)
;; => 6
;; 上述代码应该和下面这段代码等价
6
;; => 6
函数调用可以显式地替换为它的返回值,这就是引用透明性。对数学运算来说这理所应当,FP 要求的就是把这种「理所应当」也应用在其他计算机程序上。没有引用透明性的函数是什么样的?
var x = 0
function add(a, b) {
x = x + 1
return a + b
}
add(add(x, x), x)
// => 1
上述 add() 是个闭包函数(因为它访问了外部作用域的 x 变量),每执行一次 add() 都悄悄地给 x 加 1,然后再返回 a + b。
执行 add(add(x, x), x) 的结果是 1。调用最外层的 add() 之前,我们先要得到参数的值。第一个参数是函数调用,求出值为 0,同时 x 的状态变成了 14;然后将 0 作为第一个参数传入,此时求 x 的值,发现是 1,将 1 作为第二个参数传入,也就是 add(0, 1)。最后函数返回了 1,x 的值变成了 2。
假设现在 x 的值还是 0,我们把 add(x, x) 换成理应等价的 0,发现 add(0, x) 得到的是 0 而不是 1。这个程序没有引用透明性,而且 x 的状态不可控,add() 的结果难以预测。
不难意识到避免副作用对系统的长期可维护性的好处。如果我们非要一个 x “变量”作为计数器,记录调用 add() 函数的次数,不妨用递归实现。
const numbers = [2, 4, 3, 7, 2, 7, 5, 10, 3]
function addWithCounter(numbers, counter, total, index) {
if (numbers.length - 1 < index) {
return {counter: counter, total: total}
}
return addWithCounter(numbers, counter+1, total + numbers[index], index+1)
}
addWithCounter(numbers, 0, 0, 0)
// => {counter: 9, total: 43}
上面这段代码是函数式的。const 是常量声明,而不是赋值。在 addWithCounter() 中我们只是返回计算后的值,将某些“状态”作为函数参数递归地传递,没有可变状态。我们在不使用循环结构的情况下遍历累加了数组,并且维护了一个 counter(计数器)。
在 Clojure 中,可以用 loop 构造递归,无需声明函数:
(defn addWithCounter [numbers]
(loop [counter 0, total 0, index 0]
(if (< (count numbers) (inc index))
{:counter counter :total total}
(recur (inc counter) (+ total (numbers index)) (inc index)))))
(addWithCounter [1 2 3])
;; => {:counter 3, :total 6}
不过更好的写法是用利用惰性。
;; reductions 类似于 reduce
;; 但返回的是每个步骤的 lazy seq,而不是最终结果
;; (= (reduce f coll) (last (reductions f coll)))
(defn addWithCounter [numbers]
(let [steps (reductions + numbers)]
{:counter (count steps)
:total (last steps)}))
(addWithCounter [1 2 3])
;; => {:counter 3, :total 6}
reductions 函数返回的是惰性序列,其实上一篇提到的 map 返回的也是惰性序列。这究竟是什么东西?
lazy-seq 函数接收一系列表达式,这些表达式最终返回一个序列。不过,写在 lazy-seq 里面的表达式并不会被立即执行,而是会在 lazy-seq 被访问时执行。也就是说,lazy-seq 保存了一系列「如何求值」的步骤,仅在每个步骤被访问时才计算具体的数值。
还记得序列(sequence)的定义吗?仅支持线性访问的集合。还记得 Cons 吗?一个序列就是保存了第一个值(_first)和指向下一个 Cons 的“指针”(_next)的对象。假如序列的 next 并不指向下一个 Cons,调用它的 next() 方法时,它并不 return _next,而是调用函数计算下一个值再返回,调用几次 next() 就计算几次值。
听起来像个迭代器(Iterator),两者确实很像。惰性序列就是可以当作序列处理的迭代器,不过我们并不需要手动调用 next,只需要正常地把它当作序列处理,剩下的 Clojure 会帮我们解决。

作为演示,我们先定义一个指向无穷的符号。
(def ∞ (range))
;; => #'user/∞
(type ∞)
;; => clojure.lang.Iterate
range 可以返回指定范围的整数序列,如果不给参数的话,就会返回 Iterate 对象。可以把 Iterate 理解为做了特殊优化的 LazySeq,使用起来和惰性序列区别不大。
我们试试从「无穷」中取出前五个数:
(take 5 ∞)
;; => (0 1 2 3 4)
只想要正整数?没问题。
(def ℤ (map inc ∞))
;; => #'user/ℤ
(take 5 ℤ)
;; => (1 2 3 4 5)
只想要偶数?当然!
;; 其实我本来想用 {2k|k∈ℤ} 做符号名,可惜不合法
(def even-numbers (filter even? ℤ))
;; => #'user/even-numbers
;; or
(def even-numbers (map #(* 2 %) ℤ))
;; => #'user/even-numbers
(take 5 even-numbers)
;; => (2 4 6 8 10)
但要注意,有些操作不能应用在无穷的惰性序列上,比如 shuffle,这个函数的作用是打乱一个序列,但你怎么打乱「无穷」呢?想想就需要无穷的计算时间。此外还有 sort、group-by 和 count。

等等,count?难道之前不是说 count 的时间复杂度为 O(1),只需要 return _count 就好了吗?对惰性序列来说不是这样,因为在把所有可能性都实现之前,惰性序列自己也不知道自己有多长。
// package clojure.lang
// LazySeq 的 count 方法
public int count(){
int c = 0;
for(ISeq s = seq(); s != null; s = s.next())
++c;
return c;
}
惰性序列和迭代器的另一个区别是,惰性序列会缓存已经计算的值,再次访问时会使用已有的值,不会重复计算。可以用 realized? 函数检查序列有没有被计算。
(realized? (range))
;; => true
(realized? (rest (range)))
;; => false
(range) 本身作为一个 Cons,包含 0 的初始值,但它之后的 Cons 都还没有被计算,所以返回 false。注意 (realized? (first (range))) 实际上是 (realized? 0),会报错,因为 Long 类型没有是否被实现之分。
接下来我会提出一些需求,读者可以在自己的 REPL 里编写 Clojure 代码实现,我会把参考答案附在题目后面。题目中还会包含一些新的 Clojure 函数的用法介绍,读者可以一边练习一边学习新函数。
凯撒密码是一种替换加密,即简单地把明文中的每个字母替换为字母表往后偏移 N 个字母之后的字母。比如,如果偏移量是 3,A 就会被换成 D。

我们知道 ASCII 编码中,65 对应的是大写字母 A,90 对应 Y。如果要将 A 变成 D,只需要对字符的十进制数表示加 3,然后再转换成字符。
在 Clojure 中,可以调用 Java 方法把数字转换为 ASCII 字符串:
(Character/toString 65)
;; => "A"
(Character/toString (+ 65 3))
;; => "D"
可以用 zipmap 函数把两个序列封装成映射,第一个序列的元素作为键,第二序列的元素作为值。
(zipmap ["A" "B" "C"] ["D" "E" "F"])
;; => {"A" "D", "B" "E", "C" "F"}
range 可以指定序列的范围:
(range 65 91)
;; => (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)
使用 range、map 和 zipmap 构造出偏移量为 5 的凯撒密码对照表。
(defn caesar-map [offset]
(let [alphabet (map Character/toString (range 65 91))
ciphered (concat (drop offset alphabet)
(take offset alphabet))]
(zipmap alphabet ciphered)))
(caesar-map 5)
;; => {"T" "Y", "K" "P", "Q" "V", "L" "Q", "G" "L", "J" "O", "M" "R", "S" "X", "Y" "D", "Z" "E", "H" "M", "E" "J", "R" "W", "C" "H", "F" "K", "B" "G", "P" "U", "V" "A", "U" "Z", "O" "T", "X" "C", "N" "S", "A" "F", "I" "N", "W" "B", "D" "I"}
(get (caesar-map 5) "A")
;; => "F"
系统的业务逻辑里有几个关键节点设置了钩子,在特定事件发生时可以执行用户自定义的函数,像这样:
(defn process-large-data [hooks data]
(let [todo (map process-data (filter valid? data))]
(if (seq todo)
;; todo 非空的时候,执行 todo
;; 然后调用 hooks 里的 :on-finish 函数
(do (doall todo)
((:on-finish hooks)))
;; todo 为空,即没有要处理的数据
;; 调用 hooks 里的 :on-empty 函数
((:on-empty hooks)))))
todo 是 filter 和 map 返回的惰性序列,如果没有被访问就不会执行 process-data。之后通过 seq 检查 todo 是否非空,如果是的话就 doall,这个函数可以执行惰性序列中所有的元素。外层的 do 用来把多个表达式放在一个形式(form)里,不然会被 if 当作多个参数处理。
hooks 是长这样的映射。
{:on-finish (fn [] (println "Finished processing"))
:on-empty (fn [] (println "Todo is empty"))}
(:on-finish hooks) 把函数从映射里取出来,((:on-finish hooks)) 先把函数取出来,然后执行这个函数。
可以使用 merge 函数将两个映射合并,如果有相同的键,用后一个映射覆盖:
(merge {:a 1 :b 2 :c 3} {:a 2 :b 1})
;; => {:a 2, :b 1, :c 3}
repeat 函数返回重复同一个值的序列,如果不指定重复的次数,就会返回无限长的惰性序列。
(repeat 5 "hello")
;; => ("hello" "hello" "hello" "hello" "hello")
(take 5 (repeat "hello"))
;; => ("hello" "hello" "hello" "hello" "hello")
(repeat "hello")
;; 如果直接访问这个惰性序列
;;(比如:在 REPL 里输入,REPL 会 print 这个序列)
;; 就会造成死循环,(range) 也是同理
使用 merge、repeat 和 zipmap 编写 make-hooks 函数,接收 user-hooks 参数,user-hooks 和上文的 hooks 结构相同,但键可能不全。默认情况下,hooks 中的钩子函数不做任何操作(println 也不能有),除非用户传入了钩子函数。
(def hook-keys [:on-finish :on-empty])
(def default-hooks
(zipmap hook-keys (repeat (fn []))))
(defn make-hooks
([] default-hooks)
([user-hooks]
(if (seq user-hooks)
(select-keys
(merge default-hooks user-hooks)
hook-keys))
default-hooks))
select-keys 只选择映射中指定的键,如果用户提供了不存在的键,可以用这个函数筛掉。不过不筛问题也不大,反正多余的钩子也不会被调用。
repeat 函数重复值,repeatedly 函数可以重复执行函数,返回惰性序列。(rand-int n) 返回 [0, n) 区间内的随机整数。5
(take 5 (repeatedly #(rand-int 100)))
;; => (72 56 54 21 89)
loop 和 recur 刚才我们已经见过了。可以把 loop 里类似 let 绑定的向量当作函数的参数和初始值之间的绑定,而 recur 则是递归地调用 loop,recur 的参数数量应该和 loop 的绑定数量相同。
(loop [arg1 0, arg2 "any value" arg3 :not-over]
(if (> arg1 10)
{:arg1 arg1 :arg2 arg2 :arg3 :over}
(recur (inc arg1) "any value" :not-over)))
;; => {:arg1 11, :arg2 "any value", :arg3 :over}
使用任何你能想到的函数,编写 uniquely-rand-int 函数,接收参数 n 和 t,返回由 n 个小于 t 的不重复的随机整数序列。
第一种解法,使用 loop 并拒绝已经存在的元素。
(defn uniquely-rand-int [n t]
(loop [result '()]
(if (>= (count result) n)
result
(let [randint (rand-int t)]
(if (some #{randint} result)
(recur result)
(recur (conj result randint)))))))
第二种解法,使用 set,利用集的特性去重。
(defn uniquely-rand-int [n t]
(loop [result #{}]
(if (>= (count result) n)
(seq result)
(recur (conj result (rand-int t))))))
第三种解法:
(defn uniquely-rand-int [n t]
(->> (range t) shuffle (take n)))
第三种解法不仅更简洁,而且不会出现死循环。试一试,如果是前两种解法,在 t 比 n 小的情况下就会出现死循环,但第三种解法只会返回数量不足的序列。
(uniquely-rand-int 5 10)
;; => (6 8 1 3 9)
(uniquely-rand-int 5 1)
;; => (0)
而且第三种解法的时间复杂度是 O(n),瓶颈主要在 shuffle,这个函数底下用的是 Java 的 Collections.shuffle,
时间复杂度为 O(n)
。
2026-09-28 08:44:16
第一百期!我明明把正刊改成每月更新了,却还是每周更新小报,就是为了撑到这一天。这样我就可以酷酷地告诉别人,我有件事情坚持了一百周,也就是将近两年的时间。
这期是小游戏特辑。节前无心工作的话,就摸鱼玩玩吧。我们国庆十月正刊再见。
🎮
从 Taxodium
Zine
上看到的。我衣柜里的颜色都能拼出彩虹,当然要参战了。绞尽脑汁地想了十分钟,最后的成绩是 50 个。为了向你证明我没有作弊(谁在乎啦!),也让下面这个报菜名不会太无聊,我想讲讲我是在什么地方学到这些词的。最后发现很多 Light 和 Dark 凑数。
如果你想玩这个游戏的话,就自己先试一试再回来看吧。不过我写出来的也不算多,有不少你可能已经知道了。
#FF0000 Red#C32148 Maroon
#FF2400 Scarlet
#FA8072 Salmon
#FFC0CB Pink#FF0080 Rose#FF66CC Rose pink
#8B0000 Dark red
#E0115F Ruby
#FFFF00 Yellow#D4AF37 Gold (metallic)#FF8000 Orange#FFFFE0 Light yellow#8B8000 Dark Yellow#FED8B1 Light orange#FF8C00 Dark orange#FFD700 Gold (web) (Golden)
#F28500 Tangerine
#00FF00 Green#006400 Dark green (X11)#90EE90 Light green#50C878 Emerald
#7FFFD4 Aquamarine
#4CBB17 Kelly green
#0000FF Blue#ADD8E6 Light blue#00008B Dark Blue#6A5DFF Indigo
#6A0DAD Purple#8000FF Violet#D8BFD8 Light purple#301934 Dark purple#FFFFFF White#808080 Gray (web)#000000 Black#363737 Dark Gray#D3D3D3 Light gray#6F2DA8 Grape
#FEBAAD Melon#000080 Navy blue
#6F4E37 Coffee#964B00 Brown#7B3F00 Chocolate (traditional)#FFFFF0 Ivory
#FFB6C1 Light pink#B2FFFF Celeste
#4B5320 Army Green
#C4A484 Light Brown#654321 Dark brown#B76E79 Rose Gold事后才想起,我竟然忘记了 Azure。不过说实话,谁用微软的云计算啦,就算要用美国的企业级服务,大多数也是买 AWS 吧。
🎮
Nordle 这个游戏可以让你一次玩 N 个 Wordle1,并且这 N 个 Wordle 共享输入框,玩家只有 5 + N 词机会猜词。
下面是我玩 Wrodle 3 次方的游戏战果:

说实话很糟糕,我输入我的常用开场词 WEARY LOTUS 之后就陷入了沉思。同时玩 1 个 Wordle 就够烧脑了,竟然要同时玩 3 个吗?最后只能逐个击破,试出 INERT 这个答案的时候我无语了。这是什么词啊!还好剩下的两个比较人类。
关于我的开场词,我记得我是从
@3Blue1Brown
的频道上看到的。虽然他在
这个视频
里得出结论,说最好的开场词是 CRANE,但我还是喜欢用 WEARY LOTUS 两个词开场,可以试出 A E O U 四个元音,以及不少常见的辅音。记得有个朋友喜欢用 ADIEU(法语的「拜拜」),可以直接试出全部五个元音,然后再用 NYMPH(仙女)试辅音,很别致。
另外,最近发现 Matt Damon
也玩 Wordle
,Daddy 怎么人又帅又聪明呢,不带 Ben 哥哥玩的话可以叫我噢——就喜欢这样突然惊吓我的读者。
🎮
黑白像素风格的音游,键盘上方的数字键就是钢琴键,玩家要在雨中弹钢琴,击败心魔。每个恶魔都有随机但每轮游戏固定的数字序列,不同难度的恶魔数字数量会有区别。玩家需要在恶魔出现时,依照顺序按下按键,才能让恶魔消失。

如果恶魔太多,游戏就会失败。这个时候游戏会提醒玩家 Freestyle to restart,只要胡乱按一通琴键就能重新开始——我很喜欢这个设计!
不过我玩音游太菜了,最多打到三位数字的恶魔。
Wordle 是个英文猜词游戏,玩家要猜出一个五字单词,有五次机会(也有不同长度的变体)。输入一个单词后,不存于正确答案中的字母会变灰,存在、但不在正确位置上的会变黄,在正确位置上的会变绿。 ↩︎
2026-09-27 15:08:35
第一次听闻波拉尼奥是某个下午在西西弗书店闲逛,买了一本很厚的《在地狱阅览室里》,散文集,一百多块钱,是我买过最贵的书。那个时候我不认识他,没读下去,后来把书在多抓鱼上卖掉了。我有段时间想要多读些拉美文学,对波拉尼奥本人以及和他同时代或对他产生过影响的作者都建立一些了解,再去读那本书。然后我读了点 马尔克斯 ,还有略撒的《 城市与狗 》,就把拉美文学抛之脑后了。
读完加缪的 最后一本小说 之后,我决定从法国文学里退一步,然后就想到了波拉尼奥,便买了这本《地球上最后的夜晚》,因为《2666》太长了。
然后我发现法国文学还在追我,波拉尼奥几次提到 司汤达 ,在《毛毛虫》这篇里还提到加缪的《 堕落 》。法国文学在世界的影响力确实不小,印象里,捷克作家昆德拉也在《 不能承受的生命之轻 》里提过司汤达,不过他其实也算法国作家。
和昆德拉1很像的是,波拉尼奥也也背井离乡。至于他为什么离开智利,我对这些历史背景并不了解。所以,在这本《地球上最后的夜晚》和昆德拉的《不能承受的生命之轻》里,都能见到有关身处异乡的描述。
波拉尼奥的短篇短促有力。借他在《牙科医生》一篇中对故事中一位小说家的描述,读完他的短篇像是「读了一篇长篇」。我很喜欢他的语言风格,他很少用描述情绪的词,也就是不直接描述情绪,也几乎不直接写人物动机,读者要从情节的推进、插入段落中的几句回忆把这些东西读出来。他的文字和情感保持着微妙又恰到好处的距离,叙述者看似冷淡,时常还插入一些理性的观点,但读完很难不被触动。
我比较喜欢的几篇是《圣西尼》《一件文学奇事》《毛毛虫》《戈麦斯帕拉西奥》和《安妮·穆尔的生平》。其中安妮的故事就很典型,是长篇一样的短篇。波拉尼奥写人哭,写他们哭之前和之后发生了什么,然后简单地陈述,他们哭了,或许是放声大哭,或许是差点放声大哭了,但不写他们哭得有多痛心,读完之后,读者自然会感到痛心。
波拉尼奥短篇的结尾说是戛然而止或恰到好处,都完全不能把握其精髓,他总是把故事停在快要结束却好像不该结束的地方,任由读者的想象飞驰。《圣西尼》的结尾,主人公和已故笔友的女儿喝酒,女人说她二十二岁,主人公说他可能有三十岁了,他写:甚至连男人的声音都听起来有些怪异。然后故事就停在了这里。《一件文学奇事》的结尾,B 因为写了一篇小说讽刺 A,害怕被 A 识破,神经紧张,稀里糊涂做了不少傻事,最后 B 去 A 家吃饭,A 笑脸相迎,故事结束。《毛毛虫》的结尾,“毛毛虫”被问到喜欢什么样的女人,他思索之后说喜欢安静的,可又补充到只有死人才安静,又深思熟虑地说,死了也不安静,之后他送给主人公一把漂亮的刀,然后主人公再也没见到过他。
最后一篇《邀舞卡》似乎是波拉尼奥的自述,但没有什么人讨论,我不能确定,就连英文维基百科的这个 条目 都是错的,对情节的概述和故事内容对不上。故事的形式就是一个人在卡片上一条条列举有关自己的事情,最后写成了回忆录一样的散文。这篇里还提到叙述者患了抑郁症,最严重的时候,产生幻觉看到希特勒在他旁边走来走去,后来又梦到他母亲喜欢的诗人聂鲁达在旁边看着他,还说些听不懂的话。波拉尼奥的母亲的确读畅销书,所以这叙述者兴许就是他自己吧。
顺带一提,故事叙述者在一个日本人那里上禅宗课,在第 12 条里他提到那个叫高田的日本人拿棒子要打他,中译本的译者赵德明是这么译的:
- 那鬼子喊叫着朝我扑过来的时候,可能并不想伤害我,也没有料到我会主动起来自卫。他用木棍敲打通常是为了解除弟子们麻木的神经。可是,我没有麻木的神经啊,我只不过是一走了之。
西语里会有「鬼子」这个词吗?我觉着不对劲,去找了原文:
- Cuando el maestro Ejo Takata se abalanzó gritando sobre mí probablemente no pretendía hacerme daño, tampoco esperaba que yo automáticamente me defendiera. Los planazos de su bastón servían generalmente para desentumecer los nervios agarrotados de sus discípulos. Pero yo no tenía los nervios agarrotados, yo sólo quería largarme de allí de una vez por todas.2
maestro Ejo Takata 应该是「高田慧穣大师」,这里用的甚至是带称号的全名。赵教授还是北大的,怎么把人名译成「鬼子」呢?我读的《城市与狗》的译本也是他译的。搜赵德明的时候,我发现他去年(2025)离世了,罢了,不说逝者的坏话。他的中文的确很好,没什么翻译腔。
《邀舞卡》里的第五六条是这么写的:
我想念贝尔特兰·莫拉雷斯,想念罗德里格·里拉,想念马里奥·圣地亚哥,想念雷伊纳多·阿雷纳斯。想念那些死于刑讯台上的诗人们,想念那些死于艾滋病、吸毒过量的人们,想念一切相信拉美有天堂而死于拉美地狱的人们。我在想念他们的作品,可以让左派脱离耻辱和徒劳泥坑的作品。
拉美如何曾经被视作天堂又如何成为地狱,我不清楚。我能感受到波拉尼奥的作品里的确有一种贯穿始终的阴郁气质,有些人似乎还将其称作「恐怖」。他的作品还有种神秘感,一种我难以说清楚的热情,或许是政治性的热情。总之,我了解得还不够多。
据说波拉尼奥小时候很瘦弱(年老时也是病逝的),是个书呆子,经常被欺负。果然健康阳光的人很难成为伟大的作家。波拉尼奥如果笑笑的话,其实长得很好看,穿衣也不错,要是不抽烟就更好了。

总之我很喜欢波拉尼奥的文字,我还会继续读的。
昆德拉是流亡者,流亡后成了法国公民。我去查他的生平才发现,他竟然 2023 年才离世。 ↩︎
原文来源: Narrativa Breve ↩︎
2026-09-27 01:18:41
这几天试了试不同的搜索引擎,其实也没有厌倦 Kagi,只是他们似乎在往邮件、新闻这些业务上拓展了,让我有点担心某天会发病,所以在物色替代品。搜索关键词很自负地搜了自己博客的名字,结果发现两三个镜像站聚合了我的 RSS,然后用语言模型生成了非常诡异的总结,还改了我的标题,机翻了我的英文文章。罢了,不去想那些烦心事。
我还找到了 松易涅的写作分享站 上一篇关于我博客的文章,题为《 左右脑:读极客死亡计划的博客有感 》。他的博客挺好的,我禁用了 JavaScript 还能正常用搜索功能1,不过有一点待改进,就是文章页竟然没放发布日期。看内容应该是去年八九月,因为他是读了我「与某位男生的情感纠葛」才有感而发的。
最近收到一封读者来信,也提起那几篇文章。之前莫比乌斯也在他博客 谈起此事 。我不禁好奇究竟有多少人见证了我那几个月发疯想死的心路历程。
我倒是不觉得羞耻,但有些诧异。我那段时间真的非常痛苦,时常以泪洗面,还有轻生想法,我现在根本不敢打开那几篇文章阅读。奇妙的是,这样的文字竟然让人感到我的心思细腻。最近看《斯图尔特未能拯救宇宙》,是《生活大爆炸》的衍生剧,主创在片头放了很多文字,最后一集他们留的文字中有一句话是:精神正常的人写不出喜剧。我想了想,觉得精神正常的人也写不出悲剧,或许健康的人就没办法创作出深刻的作品2。这不是我得出的结论,仅仅是猜测。我当然也有偏见,因为我一直觉得自己的创作是由痛苦驱动的。
去年夏天的痛苦,以及再前一年夏天的痛苦,沉淀下来之后,在今年初秋变成了一篇小说,也就是我这几天发布的《 怯犬 》。不难想到,根本没人读啦。中文博客圈读者很少,虚构作品的受众更是少得可怜,更何况是一篇扰人心智、尺度很大的同志文学。哈哈,我竟然用文学这个词形容自己的作品,但也大差不差吧,至少我是以看待文学的方式看待这篇小说的。
小说里的人物其实都有明确的原型,除了那位女性狗主人。她养的狗的名字「饺子」倒是我从某家异宠咖的边牧借来的,那只狗狗非常喜欢人,当时我被舔了一手的口水。至于那些穿高跟鞋踩在男人背上走啦、蒙着眼睛做狗啦、开放关系里的妙妙协议啦,说真的,我不知道我怎么写出来的,每天敲完一大段文字再回看都会被自己吓到。之前在后室写作的时候,有位读者评价我,说我的创作不会被尺度影响。我写虚构作品的时候,的确不会考虑这些,我只考虑我的主题、内核和某些感性的无法描述清楚的、驱使我去创作的东西,偶尔,这些东西刚好会让我写出尺度很大的文字。希望读者不要被我吓到。
主角叫铭锐、杉松,挺好的名字,我也忘记自己怎么想出来的了,只记得想要起一个全是金字旁的名字,再对上一个全是木字旁的名字。金克木,但木或许是崇拜金的,崇拜金子不惧烈火、闪闪发光,而金子呢,或许渴望和别的物质发生化学反应,渴望堕落,享受失控。
我已经有一阵子没投简历了,忘了,也可能是下意识回避。陆陆续续收到三家公司的拒绝信,其他的没动静,可能是邮件也懒得发吧。我真的想在互联网行业里待着吗? 软件令人失智 ,但我的确热爱编程,热爱创造软件。不过我热爱的可能并不是创造出好的产品,造出爆款的感觉,或者干脆说我对造出能跑的软件已经失去了兴趣。在 LLM 之前,那样的软件就已经不稀奇了。尽管我的确享受有用户的感觉,但我很少为别人的需求写代码。而且我写代码很慢,我要时不时停下来重构,我还要写单元测试保证覆盖率,没有验证之前我是不敢发布的,我不理解为什么有人能心安理得地把自己不理解的代码推送到生产环境。我也不在乎了,为了生计,我也得那样写代码。也因为工作时写了太多不喜欢的代码,在自己的时间里只想要慢慢打磨,哪怕是打磨注定不会有太多人用的软件。
或许我可以做个作家,写小说,我其实还有一两个小说点子在酝酿着。不过,想必那是更难的事情,现在没几个人读书了,读书的人里也有超过半数在读电子书,出版业赚不到什么钱(钱都让微信读书赚去了)。而且,我要自己联系出版社吗?找谁呢?我又有几篇作品打磨到了我敢拿出去给人看的水准?展示自己不是我的强项,而且我真的没有太多能给人看的。
其实我还有看国外企业的远程工作,但想想也觉得不可能,没有人会要不能在场办公的应届生的。我记得我看了 Kagi 和 Nubank 的岗位,还抱着试一试的心态搜索了 Clojure 的岗位,我的工作经历都是不合要求的。
我好像没有几个月前那样强烈的生存焦虑了,未来的事情谁说得清楚呢?我发现自己用越来越悲观的视角看待世界,把包括自己在内的所有人都当作车轮下的蚂蚁,反而不害怕了。没人知道轮子什么时候压下来,也没人能控制,那就过好自己的生活吧。
北方的虫子似乎要比南方的生猛,让我感觉要是没有纱窗,是活不下去的。我经常站在窗前喝水,看见一只大虫笔直地往我的窗户上撞。我有好几次打开马桶的时候,看见有黑色的长条虫子在水里扭动,只能把它冲走。我不知道他是怎么进来的。我买了很多杀虫剂,还有专门喷射到水管里的杀虫气雾剂。我的精力似乎越来越低了,我做家务没之前起劲儿了,只有写文和写代码的时候感到自己活着,要么就是和朋友打游戏的时候。我写的不是什么长篇巨作,我写的代码也都是简单的逻辑,有不少只是单元测试,偶尔折腾 WebAssembly 这样的技术。我没有要做什么「大事」的计划,也没有那种欲望。我只想找一份不需要消耗掉我所有精力的工作,用剩下的时间和精力慢慢地做好自己关注的小事。
感到头脑混乱,浑身不舒服的时候,我总是会收拾桌上的东西,擦干净台面,吸尘,拖地,然后再把自己洗干净。换上衣服之后,出门吃点东西。我最近很喜欢汉堡王的大皇堡,要是有地方能同时吃到大皇堡和麦当劳的细薯就好了,不能蘸番茄酱。吃不到也没关系,鱼和熊掌不可兼得。如果可以兼得的话,或许人就不想要鱼,也不想要熊掌了。
我承认《怯犬》多少有点儿模仿波拉尼奥的意思,我现在在构思一篇关于召唤恶魔的小说,想到的是乙一的风格。小恶魔只是想要和自己的朋友一起玩,你们不要再召唤它了。因为物质丰富,召唤恶魔比之前容易了很多,所以恶魔整天不得休息,错过了自己的生日派对,没能吻到心上人,他的冰淇淋只吃了一口就掉地上了。小恶魔的业绩排第一,干成了一件大事,但他很不开心,很不开心。
这几天 Taylor Swift 发了《The Life of A Showgirl》的加曲版本《The Encore》,听完之后,我打开国内社交平台,抱着猎奇心理看看人们会怎么骂她。之前她在《reputation》的里写了首《Call It What You Want》,大家觉得这种不管别人怎么想,就要和自己喜欢的人在一起的心态很酷。如今她先是写了一首《Wood》 赞美未婚夫的阴茎 ,婚后发了一首《Cleveland!》,写的也是随你们讨厌吧,这就是我爱的人,人们就说这是恋爱脑,「我就喜欢这根」,而不是什么酷酷的态度了。
我也不太喜欢《舞女》这张专辑,但我觉得她还在写歌也挺好。她能找到不觉得自己难以忍受的人,能找到值得关注的生活中的小事,而不是写什么对抗父权的豪迈战歌(比如《The Man》),或者让文艺青年自我陶醉的民谣和颂苦诗,挺好的。有人会说,这和我有什么关系?她的生活是否幸福我为什么要关心?的确,和我没关系,我也只关心自己生活里的小事。所以我说,挺好的。如果我对此真有很多话要说,我就不会只说挺好的。
我要睡了。
不过这似乎应该感谢 WordPress。 ↩︎
关联阅读:《 健康的灵魂是世上最无趣之物 》 ↩︎
2026-09-26 18:20:05
上一篇
我们讨论了不同类型的形式,了解了函数、Lambda、宏和特殊形式的皮毛,也学会了用一些基本的运算符、let 和 if 等等。今天的文章要讨论集合数据类型。不过在此之前,我们还是先看看普通的数据类型有哪些。
今天的文章有点长,所以读的时候,呃…… 准备一杯大号的咖啡?
和大部分编程语言一样,Clojure 支持数字、浮点数、字符串和布尔值。
由于 Clojure 是运行在 JVM 上的,所以这些数据类型都对应一个 Java 类。数字是 Long 不是 Int,前者能表示的最大数是 2 的 63 次方减 1,也就是有 64 位,第一位是符号位,后者是 32 位。
和 Java 一样,Clojure 没有无符号数(unsigned int)。
Clojure 还能在这些类型上调用 Java 的方法,比如 String.getBytes()。
(seq (.getBytes "Hello"))
;; => (72 101 108 108 111)
(type (first (seq (.getBytes "Hello"))))
;; => java.lang.Byte
Clojure 也没有用更小的 Float,而是用了双精度浮点数 Double。
(type 1.1)
;; => java.lang.Double
Clojure 是门动态类型语言,一般不需要考虑太多类型问题,不过知道这些类型底层是什么样的也挺好。
值得注意的是 Clojure 还有个很常用的自定义类型,叫作关键词(keyword)。
(type :keyword)
;; => clojure.lang.Keyword
关键词类似字符串,不过是以 : 开头,中间不能有空格。其实他更像其他语言里的枚举类型(enums),但无需提前定义。关键词不能像字符串一样做裁剪、找子串、当作字符数组遍历之类的操作,就是一个整体。
必要的时候,可以用 name 函数将它转换为字符串。
(name :keyword)
;; => "keyword"
关键词可以用在大部分需要枚举的场合,也用来表示映射里的键(key)。
其他的非集合类型还有我们上一篇讨论过的符号(Symbol)。
集合数据类型统称 Collections,一般缩写做 coll,你会在 Clojure 的文档和各种函数里见到这个缩写。要判断一个数据是否是集合,可以用谓词函数 coll?:
(coll? 1) ;; => false
(coll? "string") ;; => false
(coll? '(1 2 3)) ;; => true
(coll? [1 2 3]) ;; => true
(coll? {:key "value"}) ;; => true
(coll? #{1 2 3}) ;; => true
;; 当然,代码即数据
(coll? '(println "Hello World")) ;; => true
你之后还会见到很多 ? 结尾的谓词函数。谓词函数(predicate)是指返回布尔值的函数,在编写自己的函数时,最好也按照这种规则命名。这就像…… Java 的 IsXXX() 方法一样。
上面见到的四种有不同括号标记的集合数据类型就是今天的主角。在正式介绍他们之前,我还想讲讲序列(sequence)。序列和集合分别对应两个 Java 接口:ISeq 和 IPersistentCollection。下面是 ISeq 的定义:
public interface ISeq extends IPersistentCollection {
Object first();
ISeq next();
ISeq more();
ISeq cons(Object o);
}
ISeq 继承了 IPersistentCollection,可以理解为:序列是集合的子集。可以看到这个接口里的访问方法只有 first()、next() 和 more(),也就是说序列只支持线性访问,不能随机访问,也就是没有索引(index)和键(key)。
可以用 seq? 判断一个集合数据是不是序列。
(seq? '(1 2 3)) ;; => true
(seq? [1 2 3]) ;; => false
(seq? {:key "value"}) ;; => false
(seq? #{1 2 3}) ;; => false
只有列表是序列,不过其他数据类型都能转换为序列。
;; 没有 ? 的 seq 用于把集合转换为序列
(seq [1 2 3]) ;; => (1 2 3)
(seq {:key "value"}) ;; => ([:key "value"])
(seq #{1 2 3}) ;; => (1 3 2)
;; 字符串可以变成字符序列
(seq "hello") ;; => (\h \e \l \l \o)
只有序列才能够遍历,Clojure 中许多集合相关的操作都会自动把集合数据转换为序列。注意看,映射被转换为序列后,每个元素都是一个包含键和值的向量,所以之后我们遍历映射的时候,其实是在遍历向量序列。
序列并不是具体的数据类型,集合(collection)也不是,它们是对列表、向量、映射、集(set)以及惰性序列(lazy seq)的抽象。只不过许多操作都基于抽象的类型进行,这样很多函数就能通用。
我们在之前就见过列表(list),最普通的集合数据类型。
(list "string" 1 :item true)
;; or
'("string" 1 :item true)
和许多动态类型语言一样,列表可以包含不同类型的数据。
注意,上面这个例子中两种创建列表的方式有区别,我在 第零篇 留了个习题,现在公布答案:
(list 1 (+ 1 1) 3)
;; => (1 2 3)
'(1 (+ 1 1) 3)
;; 猜猜这段代码会得到什么结果?
;; (1 (+ 1 1) 3)
quote 会「冻结」列表内的一切,嵌套的形式也不会被求值。我们在上一篇讲到,调用函数前需要把所有参数都求值。list 就是个函数,(+ 1 1) 求值得到 2 才会被传入 list。
在更「原教旨」的 Lisp 中,list 是 cons 的一种简便写法。cons 的意思是 construct,被称作「列表构造函数」。一个 cons 只能造一个有序对,(cons 1 2) 会返回 (1 . 2),中间的点标记表示这是个 cons 对(叫 cons pair 或者 cons cell)。
cons 构造列表的方式有点像个链表,cons 左边存放的是列表的一个元素,右边存放的是下一个 cons 对,如果列表结束了,右边存的就是 NIL。
(cons 42 (cons 69 (cons 613 nil)))
;; 等价于
(list 42 69 613)

虽然 Clojure 的 cons 也可以用相同的方式构造列表,但 cons 对却不存在。cons 的第二个函数只能是序列或者 nil。
(cons 1 2)
;; Execution error (IllegalArgumentException) at user/eval30 (REPL:1).
;; Don't know how to create ISeq from: java.lang.Long
可以说 Clojure 弱化了 cons 作为列表构造函数的身份,cons 只被用来「把数据放进列表的前面」。
(cons 1 '(2 3))
;; => (1 2 3)
Clojure 的列表叫作持久化列表(PersistentList),Clojure 的所有集合数据类型都是持久化(Persistent)且不可变(Immutable)的。
(type '(1))
;; => clojure.lang.PersistentList
不可变很好理解,对数据做操作只会产生新的数据,而不会修改原来的数据,原来的数据还保存在内存的同一个地方。那什么是 持久化 呢?意思是数据被修改后,历史的版本还保留着并且还能被访问。
不妨看看 Clojure 是怎么实现 PersistentList 的:
public class PersistentList extends ASeq implements IPersistentList, IReduce, List, Counted {
private final Object _first;
private final IPersistentList _rest;
private final int _count;
注意看 _first 和 _rest 这两个私有变量。_first 的类型是 Object,在 Java 中,这是所有类的基类,作用类似于 Go 的 any,任何类型传入都能通过类型检查。_rest 的类型是 IPersistentList,这是持久化列表的接口,PersistentList 就实现了这个接口。
不难看出这和 cons 非常类似:_first 存的是列表节点的值,_rest 存的是下一个值的「指针」。在原教旨 Lisp 里,_first 和 _rest 分别对应 car 和 cdr(读作 coulder)1。
我们来看看 PersistentList 是怎么实现 add() 的:
public void add(int index, Object element){
throw new UnsupportedOperationException();
}
嗯?直接抛出异常了?
令人费解,而且违反了里氏替换原则。仔细想想,Clojure 的确没有给列表直接添加元素的方法,因为列表不可变,我们用的是 cons 和 conj(后者与 cons 相反,是在序列的尾部添加元素)。那 cons 是怎么实现的?
public Cons(Object first, ISeq _more){
this._first = first;
this._more = _more;
}
这下就清晰了,求值 (cons 1 '(2 3)) 得到 (1 2 3),原来的 (2 3) 留在原地,我们只是创建了一个 Cons 存放 1,指向 (2 3) 这个序列,而 Cons 本身也是个序列,可以再用它创建新的 Cons,新的 Cons 也会就会指向它。
这种构造持久化列表的方式叫作结构共享(Structural Sharing),既做到了不可变,又没有在内存中管理大量的数据副本。本质上,一切都是指针。
由于不可变,所以列表的大小可以作为不变的属性保存起来,回去看 PersistentList 的 Java 代码,_first 和 _rest 下面就是 _count。Clojure 的所有集合数据类型都能用 count 函数获取其长度,并且这个算法的时间复杂度是 O(1),因为只需要 return _count。此外,有很多操作都因为结构共享变得非常高效,Clojure 的性能在 Lisp 中算很好的那一类。
至于不可变还有什么好处,我们之后谈函数式编程再提。
理解一门 Lisp 中的列表如何构造,可以说就理解了它的核心。我们知道列表是持久化且不可变的,由 Cons 和指向其他 Cons 的「指针」构成。
向量基本上和列表一致,但向量中所有元素都有索引(index),这点和 JavaScript 的数组很像,写起来也和 JavaScript 数组很像,也可以存放不同类型的数据。
[1 "I'm a vector" :vector true]
向量和列表最大的不同点在于,可以用索引访问向量的元素,但列表没有索引。
(get [1 2 3 4] 2) ;; => 3
(get '(1 2 3 4) 2) ;; => nil
向量和列表的另一个区别,尽管他们都是连续的(sequential),但只有列表是序列(sequence)。用 seq? 检查数据是不是序列的时候,向量会返回 false。
(seq? '(1 2)) ;; => true
(seq? [1 2]) ;;=> false
如果希望向量也返回 true,就要检查一个集合有没有实现 Sequential 接口。
(sequential? '(1 2)) ;; => true
(sequential? [1 2]) ;; => true
有意思的是,把 seq? 的问号去掉,你也能让向量返回一个真值。
(if (seq [1 2])
"I can be a seq"
"I can not be a seq")
;; => "I can be a seq"
seq 的作用是把可以转换为序列的数据结构转换为序列,集合只要不是空的,都会返回一个正常的序列,会被条件判断当作真值。因此 seq 经常被用来检查一个集合是否不为空;相反,emtpy? 用来检查一个集合是否为空。
要注意的是,如果对向量做了序列操作,比如用 rest 函数获取向量的第一个元素之后的所有元素,就会得到一个序列,向量的索引就不可用了:
(rest [1 2 3])
;; => (2 3)
(get (rest [1 2 3]) 1)
;; => nil
可以用 vec 把序列变成向量
(get (vec (rest [1 2 3])) 1)
;; => 3
可以用 vector? 检查数据是不是向量。
(vector? [1 2 3])
;; => true
(vector? (seq [1 2 3]))
;; => false
最后,向量其实还继承了 AFn 类,可以当成函数使用。
([1 2 3] 0) ;; => 1
;; 等价于:
(get [1 2 3] 0) ;; => 1
这样就省掉了写 get 的工夫。
Clojure 的映射(map)写起来和 JSON 很像。
{:key1 "val1"
:key2 {:subkey1 2
:subkey2 [1 2 3]}
:key3 nil}
映射和向量也很像,映射不过是可以自定义索引的向量。向量只能用从 0 开始自增的整数索引,但映射可以自定义键。键一般是关键词类型,但也可以是别的类型,比如这是 HTTP 响应头的映射表示:
{"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-cache"
"Date" "Sat, 26 Sep 2026 07:06:35 GMT"}
一般而言,推荐使用关键词表示键,一方面是看起来更清晰,另一方面是,关键词和向量一样,可以当作函数使用,从映射里取出值:
(:key1 {:key1 "val1"})
;; => "val1"
;; 等价于
(get {:key1 "val1"} :key1)
;; => "val1"
其他类型却不行:
(let [data {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-cache"
"Date" "Sat, 26 Sep 2026 07:06:35 GMT"}]
("Content-Type" data))
;; Execution error (ClassCastException) at user/eval194 (REPL:4).
;; 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')
;; 只能写作:
(get data "Content-Type")
可以引入 clojure.walk 标准库,用这个库里的 keywordize-keys 方法,把不是关键词类型的键全部转换为关键词。
(require '[clojure.walk :as walk])
(walk/keywordize-keys {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-cache"
"Date" "Sat, 26 Sep 2026 07:06:35 GMT"})
;;=> {:Content-Type "text/html; charset=utf-8",
;; :Cache-Control "no-cache",
;; :Date "Sat, 26 Sep 2026 07:06:35 GMT"}
其实把关键词和映射反过来也能达到一样的效果,因为映射和向量一样,也继承了 AFn 类,可以当作函数用。
({:key1 1 :key2 2} :key2)
;; => 2
(:key2 {:key1 1 :key2 2})
;; => 2
由于映射可以嵌套,你可能会遇到需要获取一连串键值对的情况,这时候就用 get-in。比方说,我们可能要处理结构非常反人类的
microformats
。
{:webmention {:source "https://alice.blog"
:target "https://bob.net"
:microformats [{:type ["h-card"]
:properties {:photo [{:alt "Photo of Alice"
:value "https://alice.blog/photo.avif"}]
:name "alice"
:url "https://alice.blog"}}]}}
(get-in data [:webmention :microformats 0 :properties :photo 0 :value])
;; => "https://alice.blog/photo.avif"
和其他集合类型一样,映射不需要写逗号分隔元素,但写了也不会报错。如果你觉得这样更清晰,可以在每个键值对后面写上逗号。不过无论有没有逗号,奇数个元素都是键,偶数个元素都是值。如果映射里的元素没有成对,就会报错。
{:key nil :key2}
;; Syntax error reading source at (REPL:51:17).
;; Map literal must contain an even number of forms
键也不能重复:
{:key nil :key nil}
;; Syntax error reading source at (REPL:52:20).
;; Duplicate key: :key
试图用 cons 或 conj 把一个键值对放进映射里,就算键重复也不会报错,因为 cons 返回的是个序列,并非映射。
(cons [:key1 nil] {:key1 nil})
;; => ([:key1 nil] [:key1 nil])
要往映射里添加键值对,或者覆盖某个键的值,要用 assoc(associate)。
(assoc {:key1 nil} :key2 nil)
;; => {:key1 nil, :key2 nil}
(assoc {:key1 nil} :key1 1)
;; => {:key1 1}
如果要移除某个映射,就用 dissoc(dissociate)。
(dissoc {:key1 nil} :key1)
;; => {}
在 {} 前面加上 #,就会得到集(set)。Set 其实应该叫集合,但我担心和 Collections 混淆,所以称作「集」。顾名思义,集就是不能有重复数据的集合。
#{1 1 2 3}
;; Syntax error reading source at (REPL:61:11).
;; Duplicate key: 1
注意看,这里的报错是 Duplicate key(重复的键)。实际上,集就是把键和值合二为一的映射,不妨试试用关键词作为函数,取出集合里的关键词。
(:key1 #{:key1 :key2 :key3})
;; => :key1
向量和列表都不能这么干,因为向量的键是整数,而列表没有键。
(:key1 [:key1 :key2])
;; => nil
(:key1 '(:key1 :key2))
;; => nil
向量和映射都可以作为函数,集合也可以。
(#{1 2 3} 2)
;; => 2
(#{1 2 3} 100)
;; => nil
如果参数存在于集合中,就会返回集合中的这个元素;如果不存在,就会返回 nil。由于 nil 是假值,所以可以用这种方式判断某个元素是否存在于集合中。
(let [allowed-domains #{"kagi.com" "duckduckgo.com"}]
(if (allowed-domains "google.com")
"Domain allowed"
"Domain not allowed"))
;; => "Domain not allowed"
我们已经知道,可以用 seq 检查集合是否非空,用 empty? 检查集合是否为空;可以用 seq?、vector?、list?、sequential?、map? 和 set? 检查数据是否为序列、向量、列表、连续的、映射或集合。
除了对集合数据本身做检查,我们可能还想要对集合里面的元素做检查。
用 every? 检查集合里的每个元素是否都满足条件。
(every? even? [2 4 6])
;; => true
(every? odd? [1 2 4])
;; => false
every? 接收两个参数,第一个参数 pred 是直接接收一个变量的谓词函数,第二个参数 coll 是要检查的集合。这个函数对每个元素求值一次 (pred element),只要有一个是 false,就返回 false,否则返回 true。
even? 和 odd? 分别检查数字是否为奇数和是否为偶数。
第二个参数可以是匿名函数 fn:
(every? (fn [x] (= x 1)) [1 1 1])
;; => true
匿名函数可以简写为 #(),用 % 表示参数:
(every? #(= % 1) [1 1 1])
;; => true
如果不要求所有元素,只要求某一些元素符合要求,就用 some。
(some #(= % 1) [1 2 3])
;; => true
(some #(= % 1) [2 3 4])
;; => false
为什么 some 没有 ? 呢?那是因为 some? 是另一个函数,用来判断某个值是否不是 nil。
(some? 0)
;; => true
(some? [1 2 3])
;; => true
(some? nil)
;; => false
用来判断某个值是 nil 的函数是 nil?,其实 some? 就是 (not (nil? x)),
源代码
就是这么写的。
有点绕,对吧?还有更绕的。
Clojure 新手常见的问题是搞不清楚 contains? 的作用。一般人会下意识地以为这个函数用来检查集合是否包含某个元素,但很快发现并非如此。
(contains? [5 7 8] 5)
;; => false
(contains? [5 7 8] 0)
;; => true
contains? 用于检查集合中是否包含某个键(key)。我们知道向量就是键为自增整数的映射,[5 7 8] 中各个元素的键或者说索引分别是 0 1 2,所以用 contains? 查找 5 会返回 false,但查找 0 会返回 true。
contains? 可以用来测试映射中是否包含某个键,集是否包含某个元素:
(contains? {:key1 "value"} :key1)
;; => true
(contains? #{1 4 2} 4)
;; => true
如果要检查列表和向量中是否包含某个值(而非键),应该用什么呢?其实你已经会了。仔细想想,用上面提到过的哪些函数可以做到这点?
如果你的答案是:
(some #(= % :want) [:some :element :here])
;; => false
(some #(= % :want) [:i :want :to :learn :clojure])
;; => true
恭喜你对了一半!更 Idiomatic 的写法是:
(some #{:want} [:some :element :here])
;; => nil
(some #{:want} [:i :want :to :learn :clojure])
;; => :want
还记得吗?集也可以当作函数用。
如果你第一时间想到的是用 for 循环遍历和操作集合,那你可得好好反思一下。别担心,我刚开始也一直试图弄懂怎么在 Clojure 里写循环结构(也就是 for、while、do ... while),后来发现,我根本不需要。至少在我目前写 Clojure 的一百多个小时里,我一次也没有用过 for 和 while。
filter 和 remove
假设我们有这样一个列表。
(def numbers
'(1 43 231 64 23 99))
我希望删除里面的偶数,我该怎么办?这样吗?
;; (take n coll) 返回由 coll 前 n 个元素组成的序列
;; (drop n coll) 返回丢弃 coll 前 n 个元素后的序列
;; (concat coll coll) 把两个集合拼接起来
;; (inc n) 相当于 (+ 1 n)
(for [n numbers]
(if (even? n)
(let [i (.indexOf numbers n)]
(concat (take i numbers) (drop (inc i) numbers)))
numbers))
;; => ((1 43 231 64 23 99)
;; (1 43 231 64 23 99)
;; (1 43 231 64 23 99)
;; (1 43 231 23 99)
;; (1 43 231 64 23 99)
;; (1 43 231 64 23 99))
欸?不对,Clojure 数据是不可变的,我只是返回了很多个新的列表,其中有一些是去除了一个偶数的。照理来说,我应该声明一个空列表,然后把不是偶数的元素放进去…… 欸?Clojure 的赋值语句是什么?嗯?Clojure 是函数式语言,那是什么意思?啊?没有赋值语句,绑定了符号就不能改了?
我的朋友,你的问题,只需要一行代码解决:
(remove even? numbers)
;; => (1 43 231 23 99)
或者这样:
(filter odd? numbers)
;; => (1 43 231 23 99)
和刚才的 every? 和 some 一样,remove 和 filter 接收一个谓词函数和一个集合,分别用来移除符合条件的元素,或保留符合条件的元素。
reduce现在我又有新的需求了,我要把这些数字累加起来,应该怎么做?
(reduce + numbers)
;; => 461
reduce 接收函数 f 和集合 coll,其中 f 应该要能接收两个参数。它先对前两个元素求值 (f x y),然后对下一个元素求值 (f (f x y) z),以此类推。这里我们用的是 +,所以就实现了累加。
还可以传入初始值,这样 reduce 就不会从 (f x y) 开始求值了。我们能用它来拼接集合。
(reduce conj [1 2] [3 4])
;; => [1 2 3 4]
;; 直接用 conj 的话,第二个参数会被当作一整个元素放进去
(conj [1 2] [3 4])
;; => [1 2 [3 4]]
(reduce conj a b) 的简便写法是 into:
(into [1 2] [3 4])
;; => [1 2 3 4]
;; concat 也能拼接集合,但返回的是序列
(concat [1 2] [3 4])
;; => (1 2 3 4)
;; 操作向量时,最好用 into
练习一下,如果要把下面这个向量打平(flatten)然后去重,应该怎么做?
(def flatten-me
[[1 2] [53 13 5] [2] [12 53 55]])
(reduce into [] flatten-me)
;; => [1 2 53 13 5 2 12 53 55]
去重只需要把向量变成集合,然后再变回来。
(vec (set (reduce into [] flatten-me)))
;; => [1 55 13 12 2 5 53]
map如果我想要把列表里的数字全部翻倍,应该怎么做?
map 函数接收一个函数 f 和一个集合 coll,把 f 作用在每个元素上之后,返回新的集合。
(map #(* % 2) numbers)
;; => (2 86 462 128 46 198)
要注意的是,map 返回的是个序列(准确来说是惰性序列,后面再讲)。如果想要返回向量,就换成 mapv。
(map #(* % 2) (vec numbers))
;; => (2 86 462 128 46 198)
(mapv #(* % 2) (vec numbers))
;; => [2 86 462 128 46 198]
单用上面这些函数中的任何一个是无法实现复杂需求的。假设我想要把列表中的偶数删掉,然后对剩下的奇数求平方,最后过滤掉超过 1000 的数,再累加起来。你可能就能感受到什么是 Lost In Stupid Parentheses 了:
(reduce + (filter #(<= % 1000) (map #(* % %) (remove even? numbers))))
;; => 530
可以发现这些函数全都是类似的形式:(func f coll)。函数放在中间,集合放在最后面。有一个宏,可以自动把他的参数嵌套在一起,把上一个参数放进下一个参数的末尾。
(->> numbers
(remove even?)
(map #(* % %))
(filter #(<= % 1000))
(reduce +))
;; => 530
->> 叫作 thread-last,似乎也有翻译成「线程宏」的,但 thread 只是把层层嵌套的函数调用比喻成一条线而已,或许可以叫「线宏」,但我更喜欢叫它「箭头宏」。
thread-last 是说,把上一个函数形式放进下一个函数形式的最后一个参数的位置。自然还有 thread-first,写作 ->。如果要传递和操作的数据都在第一个参数的位置,或者这条线上的函数都只接收一个参数,就可以用 ->。
学会使用箭头宏,在 Clojure 里你经常需要把数据在函数之间传来传去,它能帮你少写很多括号,而且代码会更简单易读。
我已经写了五千多字,还没写到惰性。我也犯懒了,读者如果着急,可以先阅读我今年年初写的《 计算无穷 》来了解惰性是什么。
另外,最近有不少读者表达了对这个系列的喜爱,很高兴看到有人对 Clojure 感兴趣!希望我突然写这么长一篇文章没有把你们吓到。没掌握也没关系,可以随时会来查阅。之后需要用到集合的地方很多,有很多机会练习。
回见!
可参考:《 Just A Common Lisper 》 ↩︎