MoreRSS

site iconEltrac | 極客死亡計劃修改

常讨论心理学和社会观察相关内容,不敢说很懂哲学;也会写笔记和生产力工具相关内容;还是学生。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Eltrac | 極客死亡計劃的 RSS 预览

地球上最后的夜晚

2026-09-27 15:08:35

第一次听闻波拉尼奥是某个下午在西西弗书店闲逛,买了一本很厚的《在地狱阅览室里》,散文集,一百多块钱,是我买过最贵的书。那个时候我不认识他,没读下去,后来把书在多抓鱼上卖掉了。我有段时间想要多读些拉美文学,对波拉尼奥本人以及和他同时代或对他产生过影响的作者都建立一些了解,再去读那本书。然后我读了点 马尔克斯 ,还有略撒的《 城市与狗 》,就把拉美文学抛之脑后了。

读完加缪的 最后一本小说 之后,我决定从法国文学里退一步,然后就想到了波拉尼奥,便买了这本《地球上最后的夜晚》,因为《2666》太长了。

然后我发现法国文学还在追我,波拉尼奥几次提到 司汤达 ,在《毛毛虫》这篇里还提到加缪的《 堕落 》。法国文学在世界的影响力确实不小,印象里,捷克作家昆德拉也在《 不能承受的生命之轻 》里提过司汤达,不过他其实也算法国作家。

和昆德拉1很像的是,波拉尼奥也也背井离乡。至于他为什么离开智利,我对这些历史背景并不了解。所以,在这本《地球上最后的夜晚》和昆德拉的《不能承受的生命之轻》里,都能见到有关身处异乡的描述。

波拉尼奥的短篇短促有力。借他在《牙科医生》一篇中对故事中一位小说家的描述,读完他的短篇像是「读了一篇长篇」。我很喜欢他的语言风格,他很少用描述情绪的词,也就是不直接描述情绪,也几乎不直接写人物动机,读者要从情节的推进、插入段落中的几句回忆把这些东西读出来。他的文字和情感保持着微妙又恰到好处的距离,叙述者看似冷淡,时常还插入一些理性的观点,但读完很难不被触动。

我比较喜欢的几篇是《圣西尼》《一件文学奇事》《毛毛虫》《戈麦斯帕拉西奥》和《安妮·穆尔的生平》。其中安妮的故事就很典型,是长篇一样的短篇。波拉尼奥写人哭,写他们哭之前和之后发生了什么,然后简单地陈述,他们哭了,或许是放声大哭,或许是差点放声大哭了,但不写他们哭得有多痛心,读完之后,读者自然会感到痛心。

波拉尼奥短篇的结尾说是戛然而止或恰到好处,都完全不能把握其精髓,他总是把故事停在快要结束却好像不该结束的地方,任由读者的想象飞驰。《圣西尼》的结尾,主人公和已故笔友的女儿喝酒,女人说她二十二岁,主人公说他可能有三十岁了,他写:甚至连男人的声音都听起来有些怪异。然后故事就停在了这里。《一件文学奇事》的结尾,B 因为写了一篇小说讽刺 A,害怕被 A 识破,神经紧张,稀里糊涂做了不少傻事,最后 B 去 A 家吃饭,A 笑脸相迎,故事结束。《毛毛虫》的结尾,“毛毛虫”被问到喜欢什么样的女人,他思索之后说喜欢安静的,可又补充到只有死人才安静,又深思熟虑地说,死了也不安静,之后他送给主人公一把漂亮的刀,然后主人公再也没见到过他。

最后一篇《邀舞卡》似乎是波拉尼奥的自述,但没有什么人讨论,我不能确定,就连英文维基百科的这个 条目 都是错的,对情节的概述和故事内容对不上。故事的形式就是一个人在卡片上一条条列举有关自己的事情,最后写成了回忆录一样的散文。这篇里还提到叙述者患了抑郁症,最严重的时候,产生幻觉看到希特勒在他旁边走来走去,后来又梦到他母亲喜欢的诗人聂鲁达在旁边看着他,还说些听不懂的话。波拉尼奥的母亲的确读畅销书,所以这叙述者兴许就是他自己吧。

顺带一提,故事叙述者在一个日本人那里上禅宗课,在第 12 条里他提到那个叫高田的日本人拿棒子要打他,中译本的译者赵德明是这么译的:

  1. 那鬼子喊叫着朝我扑过来的时候,可能并不想伤害我,也没有料到我会主动起来自卫。他用木棍敲打通常是为了解除弟子们麻木的神经。可是,我没有麻木的神经啊,我只不过是一走了之。

西语里会有「鬼子」这个词吗?我觉着不对劲,去找了原文:

  1. 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)离世了,罢了,不说逝者的坏话。他的中文的确很好,没什么翻译腔。

《邀舞卡》里的第五六条是这么写的:

我想念贝尔特兰·莫拉雷斯,想念罗德里格·里拉,想念马里奥·圣地亚哥,想念雷伊纳多·阿雷纳斯。想念那些死于刑讯台上的诗人们,想念那些死于艾滋病、吸毒过量的人们,想念一切相信拉美有天堂而死于拉美地狱的人们。我在想念他们的作品,可以让左派脱离耻辱和徒劳泥坑的作品。

拉美如何曾经被视作天堂又如何成为地狱,我不清楚。我能感受到波拉尼奥的作品里的确有一种贯穿始终的阴郁气质,有些人似乎还将其称作「恐怖」。他的作品还有种神秘感,一种我难以说清楚的热情,或许是政治性的热情。总之,我了解得还不够多。

据说波拉尼奥小时候很瘦弱(年老时也是病逝的),是个书呆子,经常被欺负。果然健康阳光的人很难成为伟大的作家。波拉尼奥如果笑笑的话,其实长得很好看,穿衣也不错,要是不抽烟就更好了。

波拉尼奥的照片,夹克下穿着格子衬衫,带着圆框眼镜,卷发,眉毛厚重,手里拿着根烟,站在地图面前

总之我很喜欢波拉尼奥的文字,我还会继续读的。


  1. 昆德拉是流亡者,流亡后成了法国公民。我去查他的生平才发现,他竟然 2023 年才离世。 ↩︎

  2. 原文来源: 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》),或者让文艺青年自我陶醉的民谣和颂苦诗,挺好的。有人会说,这和我有什么关系?她的生活是否幸福我为什么要关心?的确,和我没关系,我也只关心自己生活里的小事。所以我说,挺好的。如果我对此真有很多话要说,我就不会只说挺好的。

我要睡了。


  1. 不过这似乎应该感谢 WordPress。 ↩︎

  2. 关联阅读:《 健康的灵魂是世上最无趣之物 》 ↩︎

Coffee Break Clojure, Vol.3

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 感兴趣!希望我突然写这么长一篇文章没有把你们吓到。没掌握也没关系,可以随时会来查阅。之后需要用到集合的地方很多,有很多机会练习。

回见!


  1. 可参考:《 Just A Common Lisper 》 ↩︎

Coffee Break Clojure, Vol.2

2026-09-26 10:06:41

上一篇 我们配置好了 REPL,可以随时开始编写 Clojure 代码并执行。 第零篇 提到,被求值(Eval)的列表叫作形式(form),形式的第一个元素是操作符,剩余的是参数。根据操作符的不同,形式被分为函数形式、Lambda 形式、宏形式和特殊形式。今天的文章就来介绍这几种形式。

函数形式

不难理解,函数形式就是操作符为函数的形式,比如这个 println 形式:

(println "Hello World")

我们知道 "Hello World" 是字符串,那 println 是什么类型?在其他语言里我们可能很少考虑函数名本身的类型,但别忘了 Lisp 的「代码即数据」本质。要把待求值的形式当作数据而非形式处理,只需要把它 quote 起来。

这里要引入 Clojure 的 type 函数,它返回输入值的类型。我们不妨把刚才的形式 quote 起来,然后获取他的第一个元素 println,看看它是什么类型。

(type (first '(println "Hello World")))
;; => clojure.lang.Symbol

类型是 Symbol(符号)!这是 Lisp 中的一种特殊数据类型,和其他的原子不同,符号不是自求值(self-evaluating)的。意思是说,对 "string" 求值得到的是 "string" 本身,但对符号求值得到的是符号指向的其他值。

要定义一个符号很简单,只需要用到 def。

(def my-symbol 123)
;; => #'user/my-symbol

123
;; => 123

my-symbol 
;; => 123 

可见符号和它指向的数据本身是等价的,对 123 求值和对 my-symbol 求值得到的结果都是 123。

接下来试试让符号指向函数:

(def my-fn 
 (fn [a b] (+ a b)))
;; => #'user/my-fn

(fn [a b] (+ a b))
;; => #object[user$eval170$fn__171 0x6f8e0cee "user$eval170$fn__171@6f8e0cee"]

my-fn
;; => #object[user$my_fn 0x3df978b9 "user$my_fn@3df978b9"]

(my-fn 1 2)
;; => 3

fn 是个宏(macro),稍后我会讲到它为什么必须是个宏而不能是函数。你可以把它理解为其他语言里的匿名函数,或者 Lambda 表达式。实际上 fn 就是 Common Lisp 和 Scheme 里的 lambda,不过换了个名字。

直接对 fn 形式求值得到的是形如 #object[...] 的字符串,这个字符串是 JVM 上某个数据的文字表示。像函数这种数据没办法像字符串和数字那样直接表示出来,于是就用这种标记表示。

输出这种标记对程序而言没有意义,使用函数的正确方式当然是调用它。上述例子中的 (my-fn 1 2) 就是个函数形式,第一个元素是指向函数的符号,剩下的数据作为参数传递给符号指向的函数。

既然符号本身也是数据,那么如果形式的第一个元素不是符号,而是返回符号的函数调用,能否正常调用函数呢?还记得 eval 本身也是个函数吗?

((eval 'my-fn) 1 2)
;; => 3

之前我们提到对 (eval (quote x)) 求值和对 x 本身求值是等价的,这里也成立。对 my-fn 求值返回的是个符号,对 (eval 'my-fn) 求值得到的也是个符号。只要形式的第一个元素是指向函数的符号,就能完成函数调用。

根据条件返回不同类型的符号也是可以的:

(def a 1)
;; => #'user/a
((if (> a 1) + -) 2 1)
;; => 1

好了好了,没人会这么写程序的。你现在知道,函数形式就是操作符为指向函数的符号的形式,对函数形式求值就是调用函数。还记得刚才让一个符号指向一个值的 def 吗?如果要用来声明函数,还有更简便的写法:

(def my-fn 
 (fn [a b] (+ a b)))

;; 等价于 
(defn my-fn [a b]
 (+ a b))

写 Clojure 函数的时候你多半只会用到 defn,但不要忘了函数名和变量名一样,都只是符号而已。

记住这个原理你能做到很多有趣的事情。比方说,在其他语言里我们经常对值做一些操作,然后赋值给变量,就像这样:

(def a (reduce + '(1 2 3)))
;; => #'user/a 

a 
;; => 6

我们对列表 (1 2 3) 做了累加操作,变成了 6,然后赋值给 a。函数也可以这么干,比如 partial 就是对函数的操作,可以让我们提前填充函数的某些参数,然后返回新的函数:

(def plus-one (partial + 1))
;; => #'user/plus-one 

(plus-one 3)
;; => 4

((partial + 1) 3)
;; => 4

我们对函数 + 做了 partial 操作,给它提前填上了一个参数 1。调用得到的新函数就相当于调用 (+ 1 ...)。在其他语言里,要做到类似的事情,你可能要这么写:

(defn plus-one [num]
 (+ 1 num))

你可能注意到了 Clojure 函数和非 Lisp 语言的另一个区别:没有 return 语句。函数如果有多个并列的形式,就会以最后一个形式的值作为函数的返回值。

做个习题,下面这段函数调用为什么返回了 nil?

(defn chatty-add [a b]
 (let [result (+ a b)]
 result 
 (println result)))

(chatty-add 1 2) ;; => nil

Lambda 形式

Lambda 形式类似 JavaScript 里的立即执行函数,也就是定义一个匿名函数,然后立马执行它,这样就可以限定变量的作用域。

(function (a, b) {
 let c = a + b 
 return c
})(1, 2)
// => 3

如果是 Clojure,就要这么写:

((fn [a b] (+ a b)) 1 2)
;; => 3

Lambda 形式的操作符不是指向函数的符号,就是函数本身。

在 Scheme(另一门 Lisp 方言)里要这么写:

((lambda (a b) (+ a b)) 1 2)
;; => 3 

在 Scheme 里,这种写法有个语法糖,叫 let:

(let ((a 1) (b 2)) 
 (+ a b))

没错,这和 JavaScript 和 Rust 等语言用于声明变量的 let 很类似,但 Scheme 中 let 仅仅是立即执行函数的语法糖。不过,在 Clojure 中,let 是个特殊形式,有专门的 解析器 ,我会在后文展开。

宏形式

前面我一直在用加减法做例子,现在不妨来看看逻辑运算。Clojure 中表示与、或、非运算的操作符分别是 and、or 和 not。其中 not 是个非常简单的函数:

(defn not [x] 
 (if x false true))
;; 如果 x 为真,返回 false
;; 如果 x 为假,返回 true

但 and 和 or 不可以是函数,必须是宏。为什么?我们不妨假设他们是函数,看看会发生什么。

(let [a 1 b 2]
 (or (> b 1)
 (> a 1)))

首先要对 or 函数的所有参数求值,得到值之后才能调用函数。(> b 1) 为 true,(> a 1) 为 false,然后得到 (or true false),最后算出值为 true。

你发现了吗?我们做了一步完全多余的运算。根本不需要计算 (> a 1) 为 false,只要算出 (> b 1) 为 true,就可以算出整个 or 形式的值为 true,因为逻辑运算或只要求其中一个值为真。

如果 or 是函数,就必须做那一步多余的运算,否而得不到值,无法调用函数。然而,有些运算是有副作用的,比如从消息队列中取出一条消息,会导致其他人无法再次从队列中取到它,若 or 运算丢弃了这条消息,数据就丢失了。

那怎么办呢?有什么办法可以让参数不被预先求值吗?

答案就是宏(macro)。宏这个机制被不少语言借鉴,比如 Rust 和 Haskell(在 Haskell 里叫 Template,模板),其实本质就是对还没求值的列表做预处理。

定义宏就是定义一系列对列表做变换的规则,宏作为形式的操作符时,会先进行宏展开(macroexpand),然后对展开的结果求值。宏展开之前,所有的列表都不会被求值。

我们来看看 or 这个宏的定义:

(defmacro or
 ([] nil)
 ([x] x)
 ([x & next]
 `(let [or# ~x]
 (if or# or# (or ~@next)))))

没有参数时n展开为 nil,只有一个参数时展开为这个参数本身,有多个参数时会被展开为嵌套在 let 里的 if,我们看到宏还能像函数一样递归,因为 or 的定义包含了 or 本身。

这看起来有点难以理解,我们不妨手动展开一下:

(macroexpand '(or (> b 1) (> a 1)))
;; => (let* [or__5600__auto__ (> b 1)] (if or__5600__auto__ or__5600__auto__ (clojure.core/or (> a 1))))

;; 整理得到:
(let* [or__5600__auto__ (> b 1)] 
 (if or__5600__auto__ 
 or__5600__auto__ 
 (clojure.core/or (> a 1))))

;; clojure.core/or 就是包含命名空间的 or,指向同一个宏 
;; 由于单参数的 or 展开为参数本身,所以可以得到: 
(let* [or__5600__auto__ (> b 1)] 
 (if or__5600__auto__ 
 or__5600__auto__ 
 (> a 1)))

;; 为了方便理解,我们再把 let 删掉: 
(if (> b 1) (> b 1) (> a 1))

if 是个特殊形式,和 let 一样有自己的解析器。当 if 计算 (> b 1) 得到 true 时,就会选择第二个参数求值,第二参数是 (> b 1),得到 true,于是返回 true;第三个参数 (> a 1) 没有被求值,我们不知道他是 true 还是 false。

还记得一开始的 fn 吗?如果 fn 是个函数,下面这段表达式会变成什么样子?

(fn [a b c]
 (println a b c)
 (+ a b c))
如果 fn 是函数的解释

首先对 [a b c] 求值,解释器会发现 a 这个符号不存在,然后报错。

关于怎么自定义宏,就放在之后的文章再讲了。

特殊形式

前面提到 if 和 let 都是特殊形式,可以理解为其他语言里的保留字。这些符号有特殊的解释规则,或者编译规则。特殊形式就是由编译器和解释器实现的形式。函数和宏都是用户定义的,只不过有的定义在标准库里。

有趣的是,Clojure 的 let 最外层其实是个宏,而 let* 才是特殊形式,只不过 let 最终会被展开为 let*。

(macroexpand '(let [a 1 b 1] (+ a b)))
;; => (let* [a 1 b 1] (+ a b))

看起来 let 被展开之后仅仅是把 let 替换成了 let*,为什么要套一层宏呢?

这次我们故意把 let 写错,看看会发生什么。

(macroexpand '(let [a 1 b] (+ a b)))
;; Syntax error macroexpanding clojure.core/let at (REPL:1:1).
;; [a 1 b] - failed: even-number-of-forms? at: [:bindings] 
;; spec: :clojure.core.specs.alpha/bindings

报错了,错误在 [a 1 b],失败原因是 let 里写的符号绑定应该是偶数个。这个语法检查并不是编译器实现的,而是宏定义的:

(defmacro let
 [bindings & body]
 (assert-args
 (vector? bindings) "a vector for its binding"
 (even? (count bindings)) "an even number of forms in binding vector")
 `(let* ~(destructure bindings) ~@body))

assert-args 是标准库内部的宏,用来断言(assert)参数是否符合规范(spec)。在 let 的宏定义中,它要求 bindings(符号绑定)是向量(也就是包裹在 [] 里的数据结构),并且是偶数个。断言失败就不会继续展开宏,而是会报错。

如果没有问题,let 就会被展开为 let*,展开之前还对 bindings 执行了 destructure 操作。这是解构,解构是 Clojure 的语法特性,Python 和 JavaScript 也有解构:

let anObject = { a: 1, b: 2, c: 3 }
const {a, b, c} = anObject 
console.log(a, b, c) // 1 2 3

上述代码用 {a, b, c} 代替了变量名,用来从 anObject 这个对象里提取和变量名同名的属性。这就是解构操作。

Clojure 没有用编译器实现解构,而是用 destructure 函数实现的,这个解构函数在 let 宏展开时调用。以下是用 let 解构的例子:

(def a-map {:a 1 :b 2 :c 3})
(let [{:keys [a b c]} a-map]
 (println a b c))
;; (out) 1 2 3 
;; => nil

如果对上面的 let 形式做宏展开,就会得到:

(let* [map__147 a-map 
 map__147 (if (seq? map__147) (if (next map__147) (clojure.lang.PersistentArrayMap/createAsIfByAssoc (to-array map__147)) (if (seq map__147) (first map__147) clojure.lang.PersistentArrayMap/EMPTY)) map__147) 
 a (get map__147 :a) 
 b (get map__147 :b) 
 c (get map__147 :c)] 
 (println a b c))

正因为 let 本身是宏,而非单独的编译器实现,Clojure 的 let 和其他 Lisp 的 let 比起来才好用不少。在 Common Lisp 和 Scheme 中,let 和 let* 的作用不同,比方说下面这段代码是有问题的:

(let ((a 1) (b 2) (c (+ a b))) 
 (+ a b c))
; in: LET ((A 1) (B 2) (C (+ A B)))
; (+ A B)
;
; caught WARNING:
; undefined variable: COMMON-LISP-USER::A
;
; caught WARNING:
; undefined variable: COMMON-LISP-USER::B
; ...

求值 (+ a b) 的时候,Common Lisp 解释器会认为 a 和 b 没有被绑定,因为 (c (+ a b)) 和 a、b 的绑定在同一个 let 里。如果要使用在同一个 let 定义的其他符号,就要用 let*:

(let* ((a 1) (b 2) (c (+ a b))) 
 (+ a b c))
; 6

《 A Scheme Primer 》里写到,如果 Scheme 的规范要从头开始指定,最好要把 let、let* 还有 letrec(允许递归引用符号自己的 let)都合成同一个。Clojure 作为较新的一门 Lisp 方言,少了很多历史遗留问题。


最后

现在你应该知道求值 Clojure 形式时,解释器底下大概都发生了什么,你应该也熟悉了一些基本的函数、宏和特殊形式,比如 let if 和 fn,可以写一些简单的 Clojure 程序了。

其实除了本文提到的四种形式,Clojure 还可以用特定类型的数据作为操作符(比如用一个集合做操作符,用于查看参数是否存在于集合内),也可以当作一种类型的形式。这要留到下篇讲了。

下一篇我们要了解 Clojure 里的集合数据类型,分别是我们已经见过的列表(list)和向量(vector),此外还有集合(set)和映射(map)。我们还会讲到如何操作这些集合,包括遍历和筛选等等。我们还会了解到惰性序列(Lazy sequence),然后你就可以写程序处理相对复杂的数据,并且能够到同时做到语法简洁和高性能。这些都是后话了。

回见!

怯犬

2026-09-25 15:56:01

一

今晚没有月亮,窗户外面其他人家的灯火也灭了,屋子里很黑,杉松迷迷糊糊地醒了。耳塞不能阻断嗅觉,好几个晚上,他都被香水、汗液和石楠花的味道弄醒,醒来时,他还紧紧地抱着一只玩偶,脸埋在沙发的靠背上。他感到背后有风,一阵远远的响动,随后皮革的气味取代了那些扰人心智的东西。他翻了个身,刚好碰到男友的手臂。

几秒的沉默过后,铭锐开口了:“你要进屋睡吗?”“她走了?”“嗯。”杉松裹着毯子,从沙发上爬起来,怀里还揣着玩偶。他刚才就已经失去了睡意,但还是半眯着眼睛,装成半梦不醒的样子往卧室走去。两个人都很有默契地没开灯,杉松已经适应了黑暗,他转头看见月光洒在家具上,映出冷白色的轮廓。他回过头,那轮廓也出现在了铭锐身上。他一直觉得感受是有形状的,他现在的感受就像是一个卡在胸腔的、半固体半液体的球,薄壁和内脏接触的表面渗出酸液,他竟不觉得疼痛,只是难受。他紧紧地跟在这个男人身后,生怕自己被他弄丢了。卧室门前,铭锐停下来想让杉松先进屋,手掌放在他背上时,杉松觉得他的手像火一样烫。

杉松在床前站定好一会儿,铭锐也只是在一旁看着他,两个人都没有上床。“没事……”,铭锐突然发出声音,“我收拾好了。”杉松又站了一会儿,把床上的被子全部推到另一边,他还裹着从沙发一路拖过来的小毯子,严严实实地上了床。“挺脏的”,铭锐仍站在原地。“我知道。”

铭锐从床脚走到侧边,整理好被子,坐上了床。他低头看着眼睛闭得紧紧的杉松,不知道说些什么好。他觉得愧疚吗?似乎有一点儿,似乎又没有。他头一次觉得自己的想法这么难以琢磨。他爱他吗?这个问题他想也不用想就能回答。他伸手想要去抚摸杉松的脸,想让那个紧紧抱着玩偶的小家伙舒展一些。“你手上有味儿。”铭锐还没有碰到他。“有味儿?”“你的手,有女人下体的味道。”

“哦…… 对不起。”铭锐从床上下去。杉松感到自己的重心平衡了一些,随后他听到门被打开又关上的嘎吱声。门似乎还半掩着,传来微微的响动,他关门很轻。随后是热水器被打燃和水打在瓷砖地板上的声音。杉松突然想到《不能承受的生命之轻》里的特蕾莎,因为托马斯每次和别的女人做爱之后,洗澡都忘了洗头,所以她不得不在晚上忍受他头发散发出的下体味儿。特蕾莎后来陷入绝望,梦到托马斯要她自杀。他今晚会做梦吗?他也是被人放进一只小船里,顺着河流漂到铭锐身边的吗?水声停了,几分钟之后他听见吹风机响起的声音。门又响了,被关上时,杉松感到一阵微弱的风。铭锐爬上床的动作很轻,但他本人是有重量的,杉松的重心又偏移了,他的身子倾斜,鼻子碰到了铭锐的肩。他没有穿上衣,身子还散发着潮湿的热气。他一直很喜欢他洗过澡后身上散发的味道,但现在他说服不了自己停止想象他和那个女人在这张床上时,他的身子散发热气的场面。杉松没有动,铭锐以为他睡着了,便为他盖上没有洗过的被子。杉松本以为自己会因此在梦里被男友送到山上赴死,但气味扰得他无法入睡。天亮了。

杉松没有吃早饭,先出了门。自从他们的那次谈话过后,他就不让铭锐开车送他了。杉松说他更喜欢走路,而且公司也不远。这是真话,他喜欢走路,在场所与场所之间,在事件与事件之间,在没有人认识他的地方,一切都很简单美好。只不过,以前他还想要和铭锐待在一起,为此他愿意忍受车载香薰令人头晕目眩的气味。现在不行了,即便铭锐告诉他香薰已经被丢掉了也无济于事。路边有一家动物救助站,杉松每次经过都会放慢脚步,偶尔驻足观看。今天他远远地瞧见了一只侧躺在地板上的棕红色大犬,尾巴拖在门栏上,时不时晃几下,像是生锈的钟摆。杉松对狗的尾巴印象深刻,它没有欣喜地摇晃,运动极其不规律,每次晃动的时候都往左边抬起,悬在空中时抽搐几下然后放下,似乎间隔的时间越长,每次抽搐地就更厉害。有时近似癫痫发作,像是卡壳的钟摆积攒了太大的压力,终于挣脱阻力动了起来。杉松不自觉地靠近它,踏上门栏时那尾巴刚好立起来,突然的响动让它颤得的更厉害,整个身子都抖了起来。狗扯到伤口,痛得呜呼大叫。杉松和它的眼睛对上,他才意识到,这本是一只白色的萨摩耶。狗似乎也平静了下来,杉松蹲下来抚摸大狗,凑得近了些,他才发现这狗竟然是没有味道的。

接下来的路程里,杉松忍不住想象与这只狗的生活会是什么样子的。它会怕生吗?一定会的,它身上的血迹和土灰,一定是人干的,而他能照顾好它。他能看到狗狗好起来之后,见到他手上拿着零食时吐舌头和摇尾巴的样子,还有看到主人坐在书桌前,做着它不理解的事情时,它困惑又想要接近的样子。这只狗会很爱他,绝对不会索求他不能给予的东西。想到这里,杉松决定一定要领养那条狗。下班回家后,他就这么说了。铭锐看着饭桌上突然发出声音的杉松,愣了一愣,答道:“我们之前不是谈过吗?我不喜欢狗。”“我之前还以为你不喜欢女人呢。”——这是假话,杉松只是想要铭锐住嘴。铭锐不说话了,两人又继续一言不发地吃饭。没过一会儿杉松站了起来,径直走向门厅换鞋。“你……”,铭锐在他开门之前叫住他,“注意安全。”杉松走出了家门,他关门的时候没有声音。

那晚杉松在外面睡了,梦里他坐在长椅上,后来,他又意识到自己在室内,旁边有人在跟他讲话。旁边的男人问他,他是怎么认识新郎新娘的,他问谁是新郎新娘,男人疑惑,回过头看着前方,还激动地鼓掌。杉松抬头看到了铭锐和一个女人站在台上宣誓,刺眼的日光照得他看不清女人的样子,他也闻不到味道。他转过告诉旁边的男人,他以前上过台上那男的。男人的表情转为惊恐,接着惊恐便传染了礼堂里的所有人,大家议论纷纷,盯着他看。杉松冲上台拉住铭锐的小臂,盯着他的眼睛,觉得自己的眼神就能说服他。但铭锐一脸平静,他不说话,杉松也拉不动他。后来,他只得跑了,因为众人的惊恐已然变成指责。跑出大门的时候,他听到女人的哭声,还有人咒他不得好死。跑下台阶的时候他左脚踢右脚,摔了下去,然后他就醒了,发现自己紧紧地抱着一个男人,阳光洒在他们身上。男人的身上有微微的汗味和淡淡的沐浴露的清香,白皙的皮肤显露出若隐若现的胸肌。男人的身体一半裹在被子里,侧身对着杉松。他想起来,昨晚是在酒吧遇见的这个男人。他第一次进那样的酒吧,游走一圈之后没找到地方落座,然后发现离店门最远的角落有个男人和他一样局促。他们没有聊什么,杉松只记得他盯着这个男人的眼睛,感受他的视线在自己身上扫荡,他的唇看起来水润可口,便借着酒劲儿吻了上去。那晚他点的是一杯无酒精莫吉托。等他们在酒店房间里脱掉衣服,男人已经硬得不行。他越过杉松的双腿,凑到杉松耳边的时候,他告诉杉松他是处男。杉松感受着面前这个男人身上散发的热气和他自己不断升高的体温,突然把头埋进他的手臂下面,用鼻孔吸气,贪婪地嗅着他腋窝的味道。

他不记得之后发生什么了,兴许结束得也很快,兴许他神智不清了。杉松一直保持睡姿,直到太阳变换方向,照亮了男人的整张脸。他这才发觉这个男人长得没什么特色,短发干净平整,嘴唇微微凸出,一张老实人的面孔,但五官还算立体。男人被亮光照醒了,睡眼惺忪,看到杉松之后他笑了,身子往杉松这边移,伸出手臂想要抱住杉松。男人还硬着,呼吸声很重,他似乎非常兴奋。男人问他还做不做。杉松吓坏了,跟随而来的是羞耻感。他在慌乱中穿好衣服开门,男人在后面挽留,走之前他还能在门缝里看到男人硬着的那个东西。他逃进电梯,门关上的一瞬间他才发现自己的心跳得好快。他并不觉得那个男人可怕,他应该也做好了保护措施,那他究竟在怕什么呢?电梯门开了,迎面而来的是大堂的一面镜子,杉松惊得一愣,随后快步离开了酒店。

路上,杉松想起铭锐,想起他们之前谈过的事情。铭锐定下的条条规则,他木讷地听着,最后他抬头看见铭锐期待的眼神,他没法说不。他知道,把那个男人的事情告诉铭锐,对方反而会欣然接受,因为他遵照了规则,但他说不出口,因为那等于承认那个一直硬着的男人是他的男伴,他更不想像铭锐把那个女人带回家一样,让他们握手,看着双方的眼睛,然后心照不宣地关上房门,留另一人在门外痴痴地听着。杉松每次都在厕所里躲着,等他们关门之后,塞住耳朵在沙发上蜷缩成一团。铭锐和他一起的时候,好几次不举,要么就是坚持不下去,然后,铭锐总是闭着眼睛,轻吻杉松的脸和上身,然后从后面抱着杉松,等他自己把事情解决掉。夜里铭锐会把杉松抱得很紧,像是要补偿他。其实,如果事情保持这个样子,杉松依旧会觉得自己是幸福的,也正因此,当铭锐提出要对他们的关系做出些改变的时候,他的心都凉透了。

杉松努力想要回忆起有关这个男人的更多细节,他记得男人点了杯鸡尾酒,但没喝几口。男人比他矮一些,跟他坐在一起的时候,男人一直看着他,没有看手机,没有看酒吧里别的男人,更没有看女人。男人似乎还说了什么,但他忘了,只记得男人说话时的表情和动作有些不自然。他还记得男人的脸很烫,像是发烧了。他只记得这些。站在地铁上的时候,杉松才想起来看消息,铭锐昨晚给他打了好几个电话,他没接,还有几条问他去哪了的文字消息,最后是一条语音。杉松戴上耳机,听见铭锐用他熟悉的轻柔的声音叫他的名字,随后听到铭锐用一样的语调说:回来的时候如果卧室门关着,就不要敲门。

接近中午,杉松打开门。铭锐看见杉松身后躲着一只雪白的大狗,他什么也没说。

二

这对男孩儿在一年前曾一起去内蒙古自驾游,因为有人告诉他们,在决定一起生活之前,最好一起旅行一次,这样可以检验双方是否匹配。杉松没考驾照,于是一路都是铭锐开车。他对此没什么怨言,只是需要经常在休息站停下来,两个休息站隔得很远的时候,铭锐会不太想说话,音乐也得关掉,免得让他没法集中注意力。杉松除了尽力配合,帮男友买水和干粮,也没别的事情可做了。路途稍微轻松一些时,他们会聊天,总是有很多话可以聊,从电影聊到各自喜欢的东西,对不同事物的看法等等。

在旁人眼里,两个人都很干净,不邋遢,想必生活上也会很合拍。起初他们也是这么想的,或许铭锐还是这样想的,不过杉松很快发现了,铭锐并不是爱干净,而是怕脏。有一天早上他们从旅馆出来之后,发现有鸟在车窗上留下了一大团排泄物,铭锐皱着眉头,打开雨刮器和冲水器,还是洗不干净,最后是杉松拿着清洁剂和纸巾把它收拾干净了。后来他们在当地农民那儿买了一箱蜜瓜,箱子里面垫着茅草,有不少伸到了箱子外面。铭锐搬箱子的时候,手臂抬得高高的,生怕茅草碰到上衣,不过那时他觉得铭锐有点可爱。车上的垃圾袋偶尔会有东西掉出来,他发现铭锐每次倒垃圾的时候,都会装作没看见下面的东西,之后再用新的袋子把掉出来的垃圾遮住。某天晚上,他们住的旅店只剩一间大床房,铭锐在酒店前台装作半开玩笑的样子说要在中间用枕头把两个人隔开,实际上杉松已经等很久了。那天晚上,他们抱在一起,铭锐先吻了杉松,杉松试探地把手放在铭锐的腰间。铭锐的动作突然停下了,杉松等着等着,铭锐把他抱得更紧了一点儿,把头放在他肩上,凑在耳边对杉松说,他准备好和他一起生活了。杉松答应了,尽管他意识到家务活可能都要由他来做了,但为了铭锐他什么都愿意做,而且他本来就很爱干净。那个时候,他们还没有做过。

两个人回到他们的城市之后,杉松就搬进了铭锐家。铭锐在书柜上腾出了一小半的位置,问铭锐要不要把他们都有的书卖掉一本,卖谁的都行,杉松说之后再看。事实是,杉松的书还没有把这一小半书柜装满。那之后,杉松也没买过几本新书,他一直在读铭锐的书,偶尔铭锐也读他的。读对方读过的书能给他们提供很多话题,他们常常能聊到半夜一点,不过偶尔也会遇到感想大相径庭的情况,这个时候他们就会早早地睡觉。今天傍晚吃过饭后,杉松没有读书,而是坐在沙发上给狗抹药,抹完驱虫的药,还要处理没愈合的伤口。狗很听话,安静地趴在杉松身上,也不乱动。杉松感到很平静。铭锐从身边走过的时候,他没抬头看,他觉得那脚步声很重。“今天她不会来。”“哦。”铭锐走向书柜。“你这几天是不是在读爱伦·坡?有《厄舍府的崩塌》的那本?”“还没读完。”之后,杉松继续给狗梳毛,没有人说话。

晚些时候,杉松收到一条短信,发信人说自己叫莫冉,他们昨晚在酒吧见过,没提更晚些时候和今早发生的事情。杉松这时候想,原来那男人是有名字的。这是句废话,但他之前的确没这么想过。莫冉问他愿不愿意去游泳,他朋友出差了,朋友家里有个泳池,今晚他可以来接杉松。杉松抬头看了看狗,狗伸出舌头对他笑。他在想这只狗识不识水性,但想起犬科动物都天生会游泳。他发消息问,可以带狗吗。过了一会儿,对方说可以。

剩下的几个小时过得很快,九点的时候,莫冉说他已经在楼下了。杉松这时才有些后怕,竟然直接把住址给对方了吗?他昨晚把电话号码给那个人了吗?他不记得了,但一定是给了。杉松牵着狗出门,铭锐以为他是去遛狗,什么也没问。

上车的时候,狗有点怕生,不敢进去,杉松得抱着他才行,所以只能坐后排。这倒也省心了,坐生人的车时,他总是不知道应该坐后排还是副驾。他自己是更喜欢后排的,但怕对方觉得自己冷淡。上车之后,狗一直趴在杉松大腿上。从后视镜里看到这一幕的莫冉说了些很俗套的话,像是他好嫉妒那条狗之类的。这之后莫冉开始谈起这辆车有多好,和另一个牌子的车比起来有哪些亮点,这家厂商有什么竞争力、未来的打算是什么。杉松对这些完全不感兴趣,不过他挺喜欢听男人充满热情地讲解谁比谁更厉害,似乎能激发出他们身体里的某种野性,具体的原因他也说不上来。莫冉一路上都显得有些急迫,他想说些令人印象深刻的话给杉松听,可惜一直弄巧成拙。可能是因为没得到什么回应,他问杉松听不听歌,杉松没拒绝,他便打开车载音响播放一些老情歌,其中一首是《你是我的眼》。杉松感到有些意外,笑了出来。随后他的视线和莫冉在后视镜里交错了一瞬间,看到他笑对方似乎很高兴,把音乐声音调大了些。杉松觉得他斜着身子去按屏幕的样子很夸张,明明是伸手就能碰到的距离。

快到九点半的时候,车子开进市郊的小区,随后在一栋两层高的房子面前停下来。莫冉下车拿钥匙开了院子的门,让杉松先进去等他,他去把车子停好。狗从刚下车就很兴奋,门打开之后就直接冲了进去,大概是在车上闷了太久。在院子里转了一圈之后,杉松牵着狗到泳池旁边。水似乎是刚换过,很清澈,瓷砖上还能看到一些残留的白色粉末,闻起来就是氯的味道。莫冉只开了院子门,屋子还锁着。于是杉松先把包放下,想看看狗想不想要先下水。可是狗显得焦躁不安,一直舔鼻子,在杉松身后转圈。它可能是怕水吧,兴许以前跳进水里玩儿,被人打过。杉松带着它在泳池边的躺椅上坐下,狗就趴在脚边,尾巴摇晃的时候还是会微微地抽搐。一会儿,院子里的大灯打开了,莫冉从身后走过来,只穿了一条紧身的黑色泳裤。

“我其实还挺喜欢刚才橙色的光的。”杉松转过头看他,他好像故意摆出抬头挺胸的姿势,刻意压着上扬的嘴角,直直地盯着前方。“这样亮堂,亮堂点儿好。”莫冉指了指屋里可以换衣服的地方,门已经打开了。“它不太敢下水,我就在上面陪他吧。”莫冉盯着狗看了一会儿,问它叫什么名字。“还没起名字。”莫冉走下扶梯,半个身子进入到水里。“那你就在上面看我游吧。”

杉松听到咕咚的声音,莫冉很利落地潜进了水里。水面激起波纹,但几乎没有水花。他游得漂亮,杉松这样想。尽管他很少看人游泳,但缺乏专业知识也能够在自然的状态下欣赏美,尤其是运动的美,或许球类运动除外,那类运动竞技性太强。莫冉游了几圈之后,狗似乎不那么害怕了,它好奇地绕着泳池小跑,跟随着时而潜泳,时而出水的莫冉。莫冉倚着扶梯爬上来的时候,杉松看到那个皮肤亮着水光的男人含情脉脉地看着他,眼神比刚才要平静不少。他似乎累了。上岸之后,狗一直抬头盯着他,尾巴不自然地摇着。莫冉在浴巾上擦了擦手,蹲下来摸狗。狗也坐了下来,眯着眼睛享受抚摸。夜幕下,一个健壮阳光的男人和一条忠诚的大狗,晚风温柔且凉爽,杉松竟然不为所动。他想,如果他不是自己就好了,其他人会为了这样的画面,心甘情愿地交出财富、时间,甚至自由。

“你困了吗?”“没有。”“我之前夜游,累了躺在岸边休息,听着水声睡着了。半夜醒的时候,我发现自己光溜溜地躺着。我朋友把我的裤子拽下来丢水里了。”杉松笑着盯着他,伸出手迎接跑过来的狗。“从那以后我就只穿紧身泳裤了。”莫冉蹲在躺椅旁边,一如既往地用那双眼睛看着杉松。“你想休息的话,我们可以去楼上客房。”杉松觉得他蹲下来的姿势很像一条大狗。“不用了,我想回家。”杉松说这话的时候从自己的语气里感受到了什么,但他忘了,他看到莫冉的眼珠往下看,左右转了一圈。他身上有淡淡的氯水味儿,毛孔散发着热气,白皮肤被灯照得泛着光。杉松刚好能侧过头看见他棱角分明的锁骨。他正想说他改主意了,可以留下来,莫冉这时站起来说好,要送他回家。

车开过来的时候,莫冉把车窗摇了下来,狗很兴奋地踩着车门站了起来。它看起来不害怕了,后排的车门打开之后就钻上了车。“坐前排吧。”莫冉用他炯炯的眼睛看着杉松,于是杉松从车后面绕过去。打开了副驾驶的门。从前面走会近一些,但他有点儿不想被那样一直看着。上车之后他发现莫冉只是套了件短袖,底下还穿着泳裤。“游泳的人不应该被晒得除了泳裤以外的地方都很黑吗?”莫冉笑着问杉松怎么知道他泳裤下面是什么样的。杉松觉得那话俗套,但还是装出害羞的样子,怪莫冉明知故问。杉松用余光看到他笑得露出了牙齿。今天夜里无星,也见不到月亮,不然,莫冉可能会说出“今晚月色真美”之类的话。杉松觉得自己能感受到莫冉现在的所思所想,不是猜测,就是感受。这个男人看上去有很多想法,但其实一切都写在脸上,想让他高兴其实很容易。“你给我放首歌吧。”杉松看见莫冉斜着身子往他这边靠,就为了拿放在中间的手机。他放的是林俊杰的《她说》,接着便唱了起来。车开上了一座桥,莫冉把前排的窗户摇了下来,风里飘来一些尘土的味道。莫冉唱得起劲,腿跟着抬起又放下。杉松不知怎么地,一直盯着他的大腿看。狗也把身子探到前排来。吐着舌头的狗和深情的男人,杉松能欣赏这个画面,但同样地,他不为所动。

三

某天下午,美术馆里人挤人,全是大人带着小孩来看他们自己也不懂的东西,还有被伴侣不情愿地带来的男人和女人。杉松小心地避开各个方向的人流,终于从人群中走出来时,他看到角落里摆着的一幅画面前站着一个人。那幅画他看不懂,黑色的背景上有一条条粗细不一的白灰色纹理,看着像岩石。戴着黑框眼镜的男人看得很认真,杉松盯着那双似乎在沉思的眼睛看了很久。“人真多啊”,杉松很自然地说出一句话。男人对他笑了笑,没说话。“这是大理石吗?”男人有些疑惑,随后说他不知道,他只是想在没人的地方站一会儿。接着他又说,兴许是吧。杉松想把话题继续下去,可他完全不擅长搭话。他刚想到一部最近上映的片子,男人却先开了口。“你看过 B 展厅那副四五米高的大画吗?”“看过。”“我总觉得太张扬了。”“是啊。”杉松其实被那幅画震撼到了,而且他觉得那画应该是七八米高,但他没说别的。接着杉松谈起刚才想说的那部电影,直觉使他把原本的解读变成了抨击,男人笑出声了,又补上几句批评,那些话是杉松想都想不出来的。

接着杉松找到了他了解更多的话题,和男人有来有回地聊了起来。最后他们交换了联系方式,各自回家之后又发消息聊了一晚上。杉松不记得他们聊了什么东西,似乎有一些电影和电视剧,还有小说。谈到某些小说的时候,杉松还从自己的书架里找出那本书,拍照发给对方看。他们认识的第二天,杉松约他去看电影。离开的时候他们简单谈了谈电影情节,出了门之后就要分开,因为两个人住在不同的方向。杉松记得他盯着男人的背影看了好久,他一直害怕男人也会回头看他,但没有,留在杉松脑海里的只有男人和他道别时的笑脸。转身离开时,他发现路灯是橙色的,夜晚是明亮的。

那晚到家时他很激动,不停想起男人明亮的眼睛。终于,他在睡前决定给男人打电话,他问男人旁边有没有人,男人说他在朋友家,他问男人方不方便到没人的地方,男人照做了。然后,他告诉男人他是个很好的人,他觉得自己不会再遇到这样的人了。这些都是实话。最后,他问男人愿不愿意做他男朋友,电话那头只剩沉默。杉松壮起胆子要男人说话,男人问他是否知道他喜欢女人,杉松没说话了,他竟然没有想过这个问题。怎么会没考虑这个问题呢?男人不断地道歉,说他没注意分寸,让杉松误会了,杉松说没事。男人问他现在是否伤心,杉松说他还好,反正也没认识几天。男人继续道歉,杉松挂断了电话。他记得自己当时真的不伤心,他从阳台走回房间,关上滑门,然后走近卧室,身体碰到床铺的那一刻他就开始落泪,随后是哭泣,真正伤心的眼泪总是比哭喊声先来。他合不上嘴巴,喉咙无法控制地发出声音,他的脸一定皱成一团了。进屋的时候他没有开灯,他在黑暗中翻阅和男人的聊天记录。不是为了找到证据或解脱,具体的缘由,他自己也不清楚。

第二天杉松请了年假,乘列车去了另一个城市。除了他自己,什么也没带,也正是因为他带上了自己,环境的变更也没能让他的心绪得到安宁。他在大街上走着,想着走的那晚要是亲了他就好了,这样即便是被拒绝,至少也留下了更多值得留念的东西。突然,他听见人群躁动起来,有人用手指天。杉松抬头看见太阳缺了一个口,露出月牙的模样,天空也黑沉了下来。他心头一震,先是和人群一样感到慌乱,随后,他竟萌生出许愿的想法,尽管他不知道到底有没有人会对着日食许愿。他许愿男人会爱上自己,几秒钟后,挡在太阳前面的东西不见了,太阳又亮得让人睁不开眼,刚才的只是日偏食。杉松觉得愿望大概是不会实现了。

杉松的旅行还没结束,就接到了男人的电话。电话那头的话说得很模糊,男人又道歉了,总之他们约了饭,在离车站很近的一家餐厅。杉松提前结束行程,赶了回来。他们看见男人在车站等他,帮他提行李。他们一开始像几天前一样聊天,说起余华和卢梭,随后男人沉默了一阵,问起杉松这几天感觉怎么样。杉松撒谎了,他说挺好的,去了很远的地方散心。男人开始罕见地谈起自己的性格,说他总是在心里评判别人,渐渐地也开始害怕别人怎么看自己的。杉松不知道说什么,只能说他觉得男人很好,说完竟流泪了。他看到男人也在抹眼泪,他觉得这个男人从未如此有魅力。杉松吻了他,男人没有拒绝。男人的名字叫铭锐。

杉松到家的时候已经是夜里十一点,铭锐就坐在客厅的餐桌上,屋里漆黑,只开着一盏落地灯。灯光是橙色的,暖暖的。铭锐做令他羞愧的事情时,总是不开灯。铭锐在桌子上看书,是一本红红的大书。杉松把遛狗绳取下来,把东西收好放进柜子里,狗也乖乖地趴在窝里,卷成一团,它累了。杉松走近了才看到,铭锐读的是他的那本《帕尔马修道院》。杉松在男友面前坐下,越过书脊看到他的脸,似乎严肃,似乎又心不在焉。他想知道铭锐怎么看书里的情节,便问他读到哪儿了。

“伯爵夫人变成公爵夫人了。”“噢,那你应该读到莫斯卡伯爵和公爵夫人的关系了。”“嗯。”杉松不知道为什么有些坐不住了,他突然迫切地想要从伴侣那里得到回应。“你看到莫斯卡伯爵嫉妒心发作想要杀死公爵夫人侄子那一段了吗,法布里斯?”铭锐缓缓地翻了一页,扫了几行后答道:“没有。”“我觉得那段写得很精彩。”接着是一段望不到头的沉默,狗很快跑过来,跳上了杉松的大腿。杉松想起了今晚车上的那段沉默。铭锐又翻了一页,他没看杉松。杉松开始搓手,时而抚摸大狗的脑袋。他想要开口说话,但不知道说什么。铭锐又翻了一页,杉松拍了拍狗的屁股想让它下去,但狗没动,于是只能把他抱起来,再放在地上。狗狗焦急地绕着他转了几个圈,杉松看见桌上的水杯见底了,便拿起男友的水杯走进厨房,从灶台上提起水壶,试了试温度,然后倒满了温水。把水端进客厅的时候,铭锐已经把书合上了,似乎没夹书签。铭锐盯着身后的狗看,还是没看杉松。杉松穿过客厅,把杯子放在桌子的杯垫上。

“你去那个男人家里做什么?”铭锐的口气很冷,杉松心头一凉,可这话从铭锐嘴里说出来,他觉得好别扭。他不该遭受这种对峙,可他还是怕了,因为这个男人的声音此时像刀一样刺了上来,再多的理他也说不出了。“那不是他家,是他朋友的。”“好,去他朋友家做什么?”“去看他游泳。”杉松已经听不见自己的声音了。“什么?”狗发出呜呜的声音,在杉松身旁转圈。“告诉我,你去看了什么?”“你为什么要问我这些问题。这种关系是你想要的。”杉松说完这句话,已经没有力气继续站着了,他转过身,坐在另一边的沙发上。狗也爬了上去,但保持着距离。“我们不是这样谈的。”

铭锐站起身,椅子发出刮擦地板的声音,向杉松走过来。杉松本能地伸出手护住自己,右手往沙发另一边摸,但没有摸到狗。铭锐拉住杉松的左手手臂,说:“我不打人。”此时,杉松意识到自己在抽泣,只是没有流泪。他感到喉咙在强迫他不断吸气,但反而觉得难以呼吸到氧气。“我们说的是,不能隐瞒对方,更不能偷偷出去幽会。”“我说了我只是去看他游泳。”杉松这个时候才意识到狗不见了,他从沙发上站起来四处张望,沿着客厅转了一圈。“它跑哪儿去了。”“不知道。门关着呢。”杉松踉跄地走近厨房,他担心刀具掉在狗身上。“我没有要你和他分开,我只是想知道他是谁。”铭锐紧追不放,杉松见狗不在厨房里,赶忙回头,差点和铭锐撞上。他从铭锐身边钻过去,回到客厅里,趴下来检查沙发和桌子底下。“我说了门关着,它跑不了。”杉松推开卧室门,开始检查衣柜和床底。“卧室门刚才一直关着,别找了。这件事情你应该和我说的,你是在哪里认识他的?”“酒吧。”“酒吧?”“对。”“你不喝酒的。”“对。”“你们做的时候戴套了吗?”羞耻心涌上心头,杉松转头背对铭锐,他到窗边检查窗帘后面。“带了吗?”杉松想要掉头去别的房间找狗,这次铭锐拦住了他。“我不记得了!”“这种事情你怎么能不记得?”杉松终于压不住情绪,跳起来,双脚跪在床上,抡起枕头往铭锐身上砸。他一边喊一边砸,要铭锐闭嘴。“我不想认识她!我不想认识她!我不想知道我男朋友和什么女人搞在一起!”杉松把枕头扔在铭锐身上,从床上跨了过去。铭锐把枕头轻轻放在床边,担忧地看着杉松。杉松哭了,咆哮着,说他不想在门外面听弹簧嘎吱嘎吱的响声,不想听女人叫自己的男朋友是贱狗、骚货,不想听自己那个女人穿着高跟鞋踩在自己男朋友背上的时候他发出的叫喊声。杉松抓起床头柜上的书和玩偶往铭锐身上砸,冲进卫生间,发现狗趴在洗手池底下颤抖。

一个小时后,杉松给莫冉打了电话。他要莫冉把眼睛蒙住,把衣服全部脱光,跪着趴在酒店房间的地板上等他。

四

铭锐怕狗,在小区里碰到遛狗的邻居会绕着走。他跟别人说他不喜欢狗,但有一天,杉松和铭锐在乘电梯下楼时,中间一层的邻居牵了一只阿拉斯加上电梯,杉松看见铭锐几乎是跳着后退到了角落,眼睛瞪大了盯着那只狼一样的动物走近。那不是厌恶,是恐惧。杉松牵起铭锐的手,对他笑,铭锐侧过头不说话。那个时候他意识到,这个男人从没说过他害怕什么东西,他只会说不喜欢,然后把头偏向一边。恐惧是被动的,但厌恶给了他控制权。当时杉松回忆起他第一次吻铭锐的那晚,他意识到铭锐心底其实享受失控的感觉。他想被弄脏,而杉松就是他生命里的污点,一个活生生的,能让他的生活不再洁净的存在。

电梯门开了,牵狗的女人回过头,铭锐把杉松的手放下。女人见他魂不守舍的样子,笑了,说他们家饺子还没社会化的时候在外面咬死过一只松鼠,然后盯着铭锐看。铭锐直勾勾地盯着那吐舌头的大狗不敢说话,女人笑出了声,说他是开玩笑的,饺子其实很亲人。那天晚上,铭锐第一次在床上硬了。自那以后,每次在小区里见到牵着饺子的女人,铭锐都佯装镇定,和饺子打招呼。杉松觉得女人看着铭锐笑的样子很奇怪,后来,他又在饺子和小区里其他狗狗玩的时候,在女人脸上看到了一样的笑,只是眼神里少了轻蔑。一天夜里,女人拿着项圈进他们家,门关上之后,他听见铭锐情绪激动地喊妈妈。

“你没蒙眼睛。”“我想看见你进门再说。”杉松看见面前这个男人瞪大了眼睛,目不转睛地注视着自己的一举一动。他们目光交错的时候,莫冉嘴角上扬,身子稍微站起来了一些,从跪姿转到半蹲,他这个时候才硬了起来。杉松二话没说,从桌子上抓起一块黑色的布,把莫冉的眼睛蒙上。系紧带子的之后,他把莫冉的肩膀往下摁,对方踉跄着一头栽进了他的腹部。“我没想到你喜欢这种。”“别说话。”

结束的时候,莫冉洁白的皮肤上已经肿起好几道红色,那颜色近似玫瑰。他的胸腔快速地起伏,费力地呼吸着。他已经不硬了。莫冉自己把眼罩取了下来,他看到杉松已经闭上眼睛睡了。他也侧躺下来,看着杉松熟睡的样子,看着月光洒在他身上。杉松其实没有睡,他只是觉得自己需要展示被动的一面给莫冉看。之后,他感到莫冉轻轻地爬了起来,双腿跨在他身体两边,两手撑着墙。他感到莫冉轻轻吻了他的嘴,但他没有动。他闻到莫冉身上的汗味儿,莫冉的呼吸声也逐渐变得沉重,他眯着眼睛看到莫冉又硬了。他目不转睛地注视着杉松,开始抽动右手。他刚才没射出来。过了一会儿,莫冉从床上下去了,他听见莫冉轻微的叫喊声,然后闻到了石楠花的味道。又过了一会儿,莫冉上床了,把头轻轻地放在杉松的肩上,依偎着睡着了。

睡醒的时候,莫冉看到杉松坐在床边靠窗的位置,天已经蒙蒙亮了。莫冉问他这个时候是不是该来根烟,杉松说他不抽烟,莫冉说他也不抽。莫冉从后面抱住杉松,杉松感到一阵温暖和莫冉皮肤的气味。“天快亮了。”“嗯。”莫冉问他要不要吃早饭,杉松说好。接着莫冉的手臂放开了,杉松听见布料摩擦和拉拉链的声音,接着门开了,然后是很重的关门声。过了一会儿,门开了,他听到纸袋子哗哗的声音,莫冉给他递过来一个松饼。这个时候他才回过头,他看见莫冉跪在床上,背挺得很直,笑着盯着他看。他的脸颊上有什么东西红红的,眼角还有泪痕。他穿的是一条黑色短裤和卡其色背心。莫冉坐了下来,看着杉松吃饭。杉松问他吃的什么,莫冉说他不吃。杉松问要不要出去逛逛,莫冉说好。他本想问要不要去电影院,但话没说出口,最后他们去了电玩城,一直打游戏到很晚。最后莫冉开车送杉松回家。

杉松进小区的时候,莫冉一直坐在车里目送他。到楼下等电梯的时候,杉松在想,或许就照铭锐说的做吧,没什么大不了的,或许干脆跟铭锐分手吧,他不会孤独的。铭锐不知道那个女人能给铭锐带来什么,那个女人轻蔑的眼神里似乎并没有爱。杉松不想思考这些了,他的思绪本来很简单,要让自己开心,要让陪着自己的人开心。随着电梯的楼层数一点点上升,杉松的心绪也越来越混乱。一想到铭锐,他就没办法只想那些简单的东西了。电梯停了,但不是他家的那一层,开门进来的是那个女人。他们两个都不说话。女人没按楼层,站在杉松前面,双手抱胸,抬头盯着显示楼层的 LED 屏。她的香水味熏得杉松喘不过气。女人先出了电梯,站在门口较远的位置,杉松拿出钥匙开门。门打开的时候,他脑子里什么也没想,扶着门等着女人进去。几秒后她意识到事情不对劲,他转过头看女人,女人的脸上写满了恐惧,快要倒在地上。杉松走进屋里,他看到雪白的大狗又变成棕红色的了,它的血还在往外流,一直流,直到染红了杉松的鞋。杉松抬头看到铭锐手里拿着菜刀。他没开灯,他看不到铭锐脸上的表情。

杉松转头就跑,在脸色煞白的女人面前留下血红的脚印。他冲下楼梯,跑了不知道几层楼,然后冲进电梯间,拼命地按按钮。电梯门开了,里面没人。杉松跑出小区大门的时候,他看到莫冉的车还停在外面。他开门坐了进去,莫冉却在驾驶室里哭,止不住地哭。杉松把他抱进怀里,拍拍他的背,还吻了他的脸颊。他看到驾驶座的下面有一个药盒,上面写的是米氮平片。莫冉抽泣着说这都不是真的,他是个骗子。杉松问他什么意思,莫冉说这车是他租来的,他自己开的是另一个牌子的车,就是他之前说不如这辆车的那个牌子。杉松怜爱地抚摸他的头,说没关系,他不在乎他开什么车。莫冉还是止不住泪水,打湿了杉松的衬衣,他说他不知道下次来接杉松的时候要怎么面对他。他说他根本没有把他泳裤扒掉扔进水里的朋友,他说郊区那房子是他客户借给他的,一切都是假的。杉松第一次感受到了这个男人的吸引力,他觉得这个时候应该吻他。果然,莫冉不哭了,用闪着泪光的眼睛静静地看着杉松,杉松觉得能说的都说完了。一阵沉默过后,莫冉伸手环抱住杉松的肩,凑在耳边小声说他可以把裤子脱掉在后排等他。

杉松看向车外,橙黄色的灯光下只有一个身影从公路上走过。铭锐抱着那只没有名字的狗,笔直走向街对面,把尸体扔进了绿化带里。杉松回过头,看见白皙的后背和不自然地扭动着的臀。

大脑充血 Vol.99

2026-09-21 07:39:21

最近一直在小步缓行,不急不忙地把事情做好。一方面是上班之后的确没有大块且高精力的时间来做自己想做的事情了,只能把事情拆得很碎,一点一点来做。这几天在读波拉尼奥的短篇集《地球上最后的夜晚》,每篇真的很短,大概十几二十页,可以每天上班前没有压力地读上一两篇。我很喜欢他的文风,平静却具有冲击力,不做作,就是普通人生活的感觉。

weepinbell

weepinbell 是我半年前开始写的 Clojure 项目,它是个 Webmention 接收器。如果你不知道这是什么,可以阅读我写的《 Webmention 简明指南 》

最近我又把它捡了起来,发布了 v0.5.0 版本,并且仍然在更新。我还在 Issues 里写了很多虽然写着欢迎贡献,但更多是写给自己看的计划(因为项目拒绝合并 LLM 生成的代码,而我非常清楚既关心 IndieWeb 又会写 Clojure,还有 Codeberg 账号,并且愿意给我贡献代码的人,很有可能根本不存在吧!)。

这两周我大概做了这些事情:

  1. 把用来给 Webmention 生成 ID 的 MD5 换成了 SHA256,后者的抗碰撞性会更好。虽然到最后我都会截断成 32 位字符,光是生成 ID 似乎也不需要考虑安全性,但…… 好吧说实话我已经忘记为什么我要换了。

  2. 换掉了一大半之前胡乱引入的依赖,把包大小从 46MB 降低到了 15MB。一些简单的功能就自己写 Clojure 代码或者调 Java 标准库实现了。依赖减少之后真的轻了很多,顺带也重构了一些不太合理的代码。

  3. Webhook 支持携带数据了,比方说可以给自己的 Bark 或 ntfy 端点发送请求,同时在 Headers 或 POST Body 里包含 Webmention 相关的信息。这样就能在收到请求时看到是谁向自己发送了 Webmention,具体的来源和目标 URL 又是什么。如果有的话,还可以显示对方的 microformats 头像。

    用户可以自己写个 Clojure 函数构造请求体,或者用预设的 :ntfy 和 :bark 函数,总之很容易拓展。

  4. 由于没有稳定且完整的 microformats 解析器可以用(也就是说没有 Clojure 或 Java 库),打算用 Will Noriss 维护的 Go 语言 解析器 。我绞尽脑汁只想出用微服务和 gRPC,或者让程序用 Shell 执行 CLI 并解析命令输出这两种方法,最后求助了 LLM,才恍然大悟:原来 JVM 上可以直接运行 WebAssembly!

    然后就写了 mf2clj ,并且第一次把软件包发布到 Clojars 上。Clojure 程序可以直接使用这个库,不过实际上这个程序是在运行嵌入其中的 WebAssembly,而这个 WebAssembly 文件是从 Go 编译过来的。

    WebAssembly 真是伟大的发明!

目前 weepinbell 只会把数据存储在一个 .edn 文件里,这对个人使用来说完全足够了。不过我在考虑用 weepinbell 提供公共的 Webmention 接收服务1,所以打算实现 SQLite 存储。由于 SQL 数据库都大差不差,所以之后复用代码实现 PostgreSQL 存储(或者用户想要自己写一个别的实现)也很容易。

此外感谢 Cytrogen ,她可是我的第一个用户啊!

tempest

一两个月前我就想要重写现在用的 Hugo 主题,因为里面遗留了太多我如今看来不太满意的设计。比起大刀阔斧地修改,不如重新开始。很快我又意识到,我并不想写 HTML,那为什么不用 Lisp 呢?

刚学 Clojure 没多久的时候我写过一篇《 网页即列表! 》,那时我就讲过用 S-Expression 来表示 XML 会干净很多(或者说叫 X-Expression),手敲代码的感觉也会更令人愉悦。除了 Clojure 的 Hiccup,Common Lisp 也有名为 spinneret 的库可以把 Lisp 转换为 HTML:

(with-html-string (:a :href "/about" "About"))
;; => "<a href=https://www.geedea.pro/about>About</a>"

所以我在想,既然已经能用 Lisp 生成 HTML 了,那只要稍做拓展,就能用来生成 Go Templates,同时支持 Hugo 的某些特性,终于也可以将 {{ mustache }} 语法消除掉了。

不用写这种代码:

{{ with .Params.tags }}
 {{ range . }}
 <a href="{{ .Permalink }}">{{ .Title }}</a>
 {{ end }}
{{ end }}

只需要这样:

(mu:with-range (mu:c :*params :tags)
 (:a :href (mu:c :*permalink) (mu:c :*title)))

于是就有了 tempest 。写 Hugo 主题的时候,只需要执行 tempest watch .,就可以实时地把 .lisp 文件转换为 .html 文件,近乎无感。

不过,上述示例代码还没有实现。本质上,tempest 只是对要输入给 spinneret 的列表做了些预处理而已,大多数时候只是在转换字符串,写着写着就发现了一些逻辑上很难理清楚的地方。比如 mu:c(Muastache chain)如果是生成 {{ .Params.tags }} 这个字符串的话,那放在 if 等语句内部时要怎么办,难道要额外写一个函数用来生成 .Params.tags 吗?

反正目前只有我一个人用,慢慢来也没事,先去忙点其他的,之后再说。

又投简历

经前辈提醒,我才意识到最近秋招,我应该投简历开始找工作了。我确实不打算在我实习的这家公司久待,但想起求职又觉得焦虑,我真的不擅长展示自己。

最近听了《不把天聊死》的 新一期 播客,主题是租房和搬家。几位主播聊到「主动搬家」和「被动搬家」,发现彼此对主动与被动的定义有些差别。有些人认为自己选择去某个城市,求学或求职,都是主动的,而另一些人认为,自己当时根本不想离开这个保护自己的空间,学业和事业的变动似乎都是无奈之举。

我更偏向后者,不过也有些微妙。我是很想要主动离开某个环境,去寻求更好或者说更合适的机会的,但我讨厌搬家,我讨厌把自己的生活塞进小小的纸箱子和搬家袋里面,我讨厌不断丢东西压缩行李,我讨厌把生活的一部分丢弃,我讨厌适应新的生活区域。搬家是我必须要做的取舍。我也不想买房之后被钉在某个地方的感觉,我渴望随时能前往另一个城市的自由,不过自由有代价,我必须承受生活变动的痛苦。

找实习的时候我还在犹豫,一直在重庆和成都附近寻找机会,最后不知怎么地,误打误撞来了济南。听播客主播聊起搬家这件事情有多难,需要寻求朋友帮助时,我才意识到我搬家时产生的各种焦虑和自责完全没有必要,这件事情本来就很难。然而我现在已经到了很远的地方,似乎也适应了,我是有能力走远的。

于是这次秋招我就开始往上海、深圳和北京投简历了。说实话,其实还有别的私心,我在重庆走路老是被电动车惊扰,不得安宁(我很喜欢散步),来了山东之后才发现这里更糟,很多路完全不是设计给人走的——小区门口没有人行道,人只能和电瓶车共用空间;在路边走到一半经常发现前面被围栏围住了,只能从马路上走;过红绿灯时,我时常环顾四周,发现身边全是电动车,没有一个行人;过马路时,明明是绿灯,我竟然被径直开过来的车鸣笛警告,疑惑之余,一辆电瓶车从我身边穿过,我就这样被围堵。

我只是印象里记得,似乎几年前就有不少沿海城市推出了限制电瓶车的政策,似乎也有不少街道适合走路。当然,这完全有可能只是源自于我对 小地方的怨恨 。我知道这还挺不健康的。

呼,其实我貌似已经错过秋招的黄金时段了,而我现在也没投出去几份。没事,慢慢来吧,逼自己也没用,为什么一定要急着把事情做完呢?


  1. 这个服务大概会叫作 ringabell——ring a bell,和披头士的 Ringo 没有关系。 ↩︎