diff options
| author | eltrac <hi@eltr.ac> | 2026-09-15 23:01:35 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-09-15 23:01:35 +0800 |
| commit | fafc1f91d8281185a810e225d9158552e78608da (patch) | |
| tree | 880a3d2147c6f37a9e8053649a28e9549896b252 /content/article/tutorial-clj-0.md | |
| parent | 28e604992a26db448d1c4b65f73e0c4a95c3ca8c (diff) | |
[publish] /article/tutorial-clj-0
Diffstat (limited to 'content/article/tutorial-clj-0.md')
| -rw-r--r-- | content/article/tutorial-clj-0.md | 217 |
1 files changed, 192 insertions, 25 deletions
diff --git a/content/article/tutorial-clj-0.md b/content/article/tutorial-clj-0.md index 7b2a5952..af5c02c1 100644 --- a/content/article/tutorial-clj-0.md +++ b/content/article/tutorial-clj-0.md @@ -1,35 +1,32 @@ +++ -title = 'Coffee Break Clojure #0' -subtitle = '什么是 Clojure?' -slug = '' -tags = [] -categories = '' -date = 2026-09-15T12:50:00+08:00 -draft = true +title = 'Coffee Break Clojure, Vol.0' +subtitle = '初识 Clojure 与 Lisp' +slug = 'tutorial-clj-0' +tags = ["Lisp","编程"] +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》。 +我前段时间听过一档名为《Coffee Break French》的播客,内容是可以在午休时喝着咖啡听完的法语对话。学习一门编程语言怎么不可以这样轻松呢?我最近也有在帮忙翻译《[A Scheme Primer](https://files.spritely.institute/papers/scheme-primer.html)》,这篇教程的语言相当通俗。受其启发,再加上我一直想要练习节制地写作,而不是滔滔不绝地写上好几千字,所以我决定开启新的博客系列:《Coffee Break Clojure》。 -我希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。 - ---- - -我只是受《A Scheme Primer》的语言风格启发,并不是妄想写一篇同等量级和地位的文本。我对 Clojure 的了解也没有特别深入,我甚至没有自信讲清楚十分重要的宏(macro)。不过,至少我有一些实操经验,也踩过坑。再者,教是最好的学,也响应 [Goedecke 的号召](https://www.seangoedecke.com/blog-about-things-you-dont-understand-yet/),我觉得我完全有*资格*写这样一篇教程。 - -若有事实问题和技术性错误,还请指出。 - -此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。 +希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。 --- -## 初识 +## Show Me The Code! -什么是 Clojure?我在了解一门编程语言之前,总想要先有些视觉印象,所以会找些代码片段来看。我不要*给小孩子看的* Hello World 程序,我要看真实的代码。不必读懂,感受这门语言的形状就好。 +我在了解一门编程语言之前,总想要先有些视觉印象,所以会找些代码片段来看。我说的不是 Hello World,而是真实的代码。 -下面是一段从我项目里摘取出来的[代码片段](https://codeberg.org/eltrac/weepinbell/src/commit/2ddf4e3fe155b1e4ff5dde8a587758d37d665ed4/src/weepinbell/webhook.clj#L16-L30)。 +下面是一段从我的项目里摘取出来的[代码片段](https://codeberg.org/eltrac/weepinbell/src/commit/2ddf4e3fe155b1e4ff5dde8a587758d37d665ed4/src/weepinbell/webhook.clj#L16-L30)。 ```clojure (defn trigger @@ -53,15 +50,19 @@ toc = true 不过,初学者也能发现一些熟悉的东西,比如字符串 `"Webhook"` `"triggeerd."`,还有条件语句 `if`。看样子,`defn` 和 Python 的 `def`、Go 的 `func` 一样,用来定义函数。 -其实其他的也不难理解,比如第 10 行的 `(get webhooks keyw-or-urls)`,如果换个写法,读者可能就能看懂一些了: +其他的也不难理解,比如第 10 行的 `(get webhooks keyw-or-urls)`,如果换个写法,读者可能就能看懂一些了: ``` get(webhooks, keyw-or-urls) ``` -这段代码中的 `webhooks` 是映射(map),里面存的是键值对;`keyw-or-urls` 在这里是 `keyw(ord)`(关键词),也就是键值对的「键」;`get` 函数从 `webhooks` 中取出指定键的值。 +这段代码中的 `webhooks` 是映射(map),里面存的是键值对;`keyw-or-urls` 在这里是 `keyw(ord)`(关键词),也就是键值对的「键」;`get` 函数从 `webhooks` 中取出指定键的值。(之所以有 `-or-urls`,是因为这是门动态类型语言,这个值也可能本身就是我们要的 URL,不必从 `webhooks` 数组中取出) + +没看懂也没关系,上面的代码不重要。 + +Clojure(以及所有的 Lisp)中的函数调用都写作 `(函数名 参数1 参数2 ...)` 的形式,符号之间一般用空格分隔。 -Clojure(以及所有的 Lisp)中的函数调用都写作 `(函数名 参数1 参数2 ...)` 的形式,符号之间用逗号分隔。这看起来可能不像函数调用,倒像个数组,或者说列表(list)。不仅是函数调用,函数定义、宏、条件语句、循环结构,以及包括列表本身在内的数据,都用列表表示。 +这看起来可能不像函数调用,倒像个数组,或者说列表(list)。不仅是函数调用,函数定义、宏、条件语句、循环结构,以及包括列表本身在内的数据,都是列表。Lisp 的意思是 *LISt Processing*(列表处理)。 函数定义是这么写的: @@ -71,7 +72,7 @@ Clojure(以及所有的 Lisp)中的函数调用都写作 `(函数名 参数1 (str arg1 arg2)) ``` -这也是个列表,列表的元素分别是 `defn` `function-name` `[arg1 arg2]` 以及后面的函数体。 +这也是个列表,列表的元素分别是 `defn`、`function-name`、`[arg1 arg2]` 以及后面的函数体。 如果要表示列表数据呢?可以这么写: @@ -82,5 +83,171 @@ Clojure(以及所有的 Lisp)中的函数调用都写作 `(函数名 参数1 函数调用和函数定义的写法一样,代码和数据的写法一样,除了个别语法糖,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)和形式(form)。括号里不能再分的东西就是原子,比如 `list` 和 `"a"` 都是原子,而 `(list "a" "b" "c")` 是形式。 + +对原子执行 `eval` 操作,或者说求值(evaluate),得到的是原子本身,比如 `(eval 1)` 得到的就是 `1`;对形式求值,就要把形式的第一个原子当作操作,剩下的原子当作参数——这就是我们在前一节建立的对 Clojure 的理解。 + +其实我们一直预设我们写的所有 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') +``` + +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 写的,但代码很易读。 |
