惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
量子位
D
DataBreaches.Net
博客园 - 司徒正美
J
Java Code Geeks
博客园 - 【当耐特】
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
aimingoo的专栏
aimingoo的专栏
B
Blog
The Cloudflare Blog
D
Docker
I
InfoQ
爱范儿
爱范儿
MongoDB | Blog
MongoDB | Blog
腾讯CDC
月光博客
月光博客
Hugging Face - Blog
Hugging Face - Blog
Microsoft Azure Blog
Microsoft Azure Blog
Vercel News
Vercel News
阮一峰的网络日志
阮一峰的网络日志
小众软件
小众软件
S
SegmentFault 最新的问题
GbyAI
GbyAI
有赞技术团队
有赞技术团队

極客死亡計劃

可怖的沉默 大脑充血 Vol.98 第一个人 Token 应译作「符」 大脑充血 Vol.97 少年承载了太多年长者的恶臭投射 现象学导论 Writing following the F-pattern is a horrible horrible idea Generated work should not be published. 大脑充血 Vol.96 给我发邮件吧,放轻松 计算机网络如何帮我理解「人们难以相互理解」? 大脑充血 Vol.95 骷髅编程 诺兰的《奥德赛》采取了什么样的改编策略? III 诺兰的《奥德赛》采取了什么样的改编策略? II 大脑充血 Vol.94 我的世界一直下雨 诺兰的《奥德赛》采取了什么样的改编策略? 大脑充血 Vol.93 新知识分子的新庸俗 艾尔特拉克在岣琅 大脑充血 Vol.92 川渝人在山东吃到没有辣味的麻辣香锅和红油水饺之后产生的哲学思考 Are We Interfacing Yet? 大脑充血 Vol.91 如何用宝可梦属性玩剪刀石头布? 什么是工程问题? 大脑充血 Vol.90 逃离合居生活
Coffee Break Clojure, Vol.0
Eltrac · 2026-09-15 · via 極客死亡計劃

如果有人问我最喜欢的编程语言是什么,我可能会在 Clojure 和 Go 之间纠结一番,但无疑我更享受手写 Clojure 的体验。可就像大部分 Lisp 一样,Clojure 的讨论度并不高,中文内容更是少得可怜。所以我为什么不能贡献一些内容呢?

我前段时间听过一档名为《Coffee Break French》的播客,内容是可以在午休时喝着咖啡听完的法语对话。学习一门编程语言怎么不可以这样轻松呢?我最近也有在帮忙翻译《 A Scheme Primer 》,这篇教程的语言相当通俗。受其启发,再加上我一直想要练习节制地写作,而不是滔滔不绝地写上好几千字,所以我决定开启新的博客系列:《Coffee Break Clojure》。

希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。


我在了解一门编程语言之前,总想要先有些视觉印象,所以会找些代码片段来看。我说的不是 Hello World,而是真实的代码。

下面是一段从我的项目里摘取出来的 代码片段

(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(列表处理)。

函数定义是这么写的:

(defn function-name [arg1 arg2]
  (println "I'm a function!")
  (str arg1 arg2))

这也是个列表,列表的元素分别是 defnfunction-name[arg1 arg2] 以及后面的函数体。

如果要表示列表数据呢?可以这么写:

(list "a" "b" "c")
;; => ("a" "b" "c")

函数调用和函数定义的写法一样,代码和数据的写法一样,除了个别语法糖,Clojure 几乎没有别的语法了,一切都可以用列表表示,Clojure 程序就是一个个相互嵌套的列表。

回看列表:

(list "a" "b" "c")
;; => ("a" "b" "c")

它返回的值是 ("a" "b" "c")。为什么 "a" 作为列表的第一个元素,没有被当作操作的名字?为什么 "b""c" 没有被当作参数?

带着这个问题,先来理解 Eval 这个概念。你可能在 Python 和 JavaScript 里见过 eval() 函数,这个函数把字符串(也就是数据)当作代码来执行,像这样:

// 你现在就可以按下 Ctrl/Command + Shift + I 打开 Console 
// 在你的浏览器里输入这段 JavaScript 代码: 

eval("1 + 1")
// => 2

JavaScript 也可以用函数构造数组:

eval('Array("a", "b", "c")')
// => ['a', 'b', 'c']

上面这段代码和下面这段是等价的:

Array("a", "b", "c")
// => ['a', 'b', 'c']

让我们回到 Clojure,下面这两段代码也是等价的:

(list "a" "b" "c")
;; => ("a" "b" "c")
(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")

等等,单独出现的那个 ' 是什么?为什么不写成字符串?

不妨试试求值字符串会发生什么:

(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"

得到的是字符串本身!为什么会这样?

在 Clojure(以及所有的 Lisp)里,数据可以大致理解为两类:原子(atom)和形式(form)。括号里不能再分的东西就是原子,比如 list"a" 都是原子,而 (list "a" "b" "c") 是形式。

对原子执行 eval 操作,或者说求值(evaluate),得到的是原子本身,比如 (eval 1) 得到的就是 1;对形式求值,就要把形式的第一个原子当作操作,剩下的原子当作参数——这就是我们在前一节建立的对 Clojure 的理解。

其实我们一直预设我们写的所有 Clojure 代码最终都会被求值。如果把一整个 Clojure 源代码文件当作形式(form)的集合,我们执行源代码的过程其实就是在对其中的形式求值(eval)。

理解 Eval 对理解 Lisp 非常重要,如果没理解,可以多读几遍。

回看这段代码:

(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"

eval 求值的是一个原子,一个字符串值,不是形式。

(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")

这段代码才是在对形式求值。是时候解释 ' 是什么了。这个符号其实是个语法糖,上面的代码等价于:

(eval (quote (list "a" "b" "c")))
;; => ("a" "b" "c")

我们不妨试试,没有这个 quote,最终会得到什么结果:

(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"),于是上面的代码就变成了:

(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')

Clojure 没办法把字符串 "a" 转换为函数类型的数据,所以报错了。

要保证 Clojure 不把这个列表误以为是函数调用,就要用到 quote。给形式打上引号,让 Clojure 把 quote 里面的东西当作数据而不是代码来处理。

写在源代码文件里的形式最终都会在运行时被求值(eval),如果想要一部分形式不被求值、被当作数据处理,就要打引号,用 quote 或者 ' 标记。quote 看起来像函数,实际上是个特殊形式(special form),里面的参数不会被求值。

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')

不妨练习一下,搞清楚上面这段代码被求值时发生了什么。

还有一些别的小练习:

;; 1. 为什么下面这两段代码是等价的?
(eval (quote (println "Hello World")))
(println "Hello World")
;; 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。

这么看来,我们可以对代码做非常有趣的事情:

(cons '+ (list 1 2 3))
;; => (+ 1 2 3)

如你所见,我们用 cons+ 放在了数字列表的最前面。这个形式被 Eval 了一次得到 (+ 1 2 3),不难想象再 Eval 一次会发生什么。

(eval (cons '+ (list 1 2 3)))
;; => 6

上述代码有个简便写法1

(apply + (list 1 2 3))
;; => 6

往列表里添加元素不过是冰山一角,我们能对数据做什么样的变换,就能对代码做什么样的变换。我们还能借助宏(macro)在求值前变换代码,来构造某些很方便的写法,按自己的想法拓展这门语言。具体的内容会在之后谈到,今天的内容已经够多了。

回见!