From 1dbd4e3c0d7e76e491e6e665e3ff73386f9450ec Mon Sep 17 00:00:00 2001 From: eltrac Date: Sat, 26 Sep 2026 10:06:26 +0800 Subject: [draft] rename clojure tutorials --- content/article/clj0.md | 260 ++++++++++++++++++++++++++ content/article/clj1.md | 277 ++++++++++++++++++++++++++++ content/article/clj2.md | 372 ++++++++++++++++++++++++++++++++++++++ content/article/clj3.md | 259 ++++++++++++++++++++++++++ content/article/tutorial-clj-0.md | 259 -------------------------- content/article/tutorial-clj-1.md | 276 ---------------------------- content/article/tutorial-clj-2.md | 359 ------------------------------------ content/article/tutorial-clj-3.md | 258 -------------------------- 8 files changed, 1168 insertions(+), 1152 deletions(-) create mode 100644 content/article/clj0.md create mode 100644 content/article/clj1.md create mode 100644 content/article/clj2.md create mode 100644 content/article/clj3.md delete mode 100644 content/article/tutorial-clj-0.md delete mode 100644 content/article/tutorial-clj-1.md delete mode 100644 content/article/tutorial-clj-2.md delete mode 100644 content/article/tutorial-clj-3.md (limited to 'content') diff --git a/content/article/clj0.md b/content/article/clj0.md new file mode 100644 index 00000000..2473f968 --- /dev/null +++ b/content/article/clj0.md @@ -0,0 +1,260 @@ ++++ +title = 'Coffee Break Clojure, Vol.0' +subtitle = '初识 Clojure 与 Lisp' +slug = 'clj0' +aliases = '/article/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》。 + +希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。 + +--- + +## 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 对构造的。 diff --git a/content/article/clj1.md b/content/article/clj1.md new file mode 100644 index 00000000..3f043afb --- /dev/null +++ b/content/article/clj1.md @@ -0,0 +1,277 @@ ++++ +title = 'Coffee Break Clojure, Vol.1' +subtitle = '准备工具' +slug = 'clj1' +aliases = ['/article/tutorial-clj-1'] +tags = ["Lisp","编程"] +categories = '茶歇蔻芥' +date = 2026-09-19 01:45:49 +draft = false +toc = true +fedi = 'https://akk.eltr.ac/objects/4b9561d5-80f2-47fe-8ede-1663cf501db6' ++++ + +[上一篇](/article/tutorial-clj-0)我们了解了 Lisp 中「代码即数据」的本质,知道了不求值(Eval)的列表就是数据,被求值的列表就是代码,而代码可以像数据一样经过任意的变换之后再求值。我们还捋了捋列表、形式、对象和原子的概念,可以说,读者已经掌握 Lisp 语法的精髓了。这里除了括号和待求值的一堆括号,别无他物。 + +了解了语法,在深入 Clojure 的语义(semantics)之前,先来准备些趁手工具。 + +## 读取-求值-打印-循环 + +正式开始之前,你需要先安装 Java 和 [Clojure](https://clojure.org/guides/install_clojure)(因为 Clojure 运行在 JVM 之上),之后就可以在终端输入 `clojure`,按下回车之后便会看到新的命令提示符: + +``` +Clojure 1.12.6 +user=> +``` + +在 Lisp 的世界里,这一般被称作 REPL,全称是:Read-Eval-Print-Loop(读取-求值-打印-循环)。顾名思义,它读取用户输入,求值,打印这个值,然后循环。有趣的是,在 Common Lisp 等方言中,你可以用一行代码启动最原始的 REPL: + +```lisp +(loop (print (eval (read)))) +``` + +Clojure 不能这样写,因为 `loop` 的用法不同。不过把 `loop` 换成 `while` 就好了,效果差不多。试试看,在你的 REPL 里输入这段代码: + +```clojure +(while true (println (eval (read)))) +``` + +然后你就会在 REPL 里获得一个简陋的子 REPL:输入一段代码,按下回车,求值的结果就会显示在下一行。 + +不难发现,REPL 和 [Shell](https://en.wikipedia.org/wiki/Shell_(computing)) 非常类似,前者允许用户用命令行界面与编程语言交互,后者允许用户用命令行界面与操作系统交互。或者说,Shell 语言就是用来跟操作系统交互的编程语言,而 Shell 程序就是 Shell 语言的 REPL。 + +现在,按下 `Ctrl + D` 退出子 REPL,回到*真正的* REPL 里。 + +在开启你的 Clojure/Lisp 旅程之前,务必和 REPL 打好关系。我记得我刚开始写 Clojure 的时候,还像个麻瓜一样写完代码,保存,然后执行整个文件甚至整个项目。这很折磨,而且让 Clojure 感觉上就是个语法更小众的普通编程语言。 + +REPL 不仅仅是跟 Clojure 交互的媒介,还是与你自己的代码交互的媒介,也可以是用户与你的代码交互的媒介。 + +Lisp 常见的编程实践叫作交互式编程(Interactive Programming),允许程序员一边写代码一边与已经写好的代码交互。原理其实很简单,每写好一段代码,都把代码发送到 REPL 里求值,函数定义、变量和各种状态被加载到 REPL 中,就可以使用之前已经定义好的符号继续编程。 + +最原始的交互式编程是:电脑上一边打开编辑器,另一边打开终端运行 REPL,每写一段代码就复制粘贴到 REPL 中。不过这太不优雅了,Emacs 的 [SLIME](https://github.com/slime/slime) 和 Neovim 的 [Conjure](https://github.com/Olical/conjure)[^1] 允许程序员直接在编辑器内部对代码求值,它们用某种协议把代码发送给 REPL 进程(或 REPL 服务器),随后 REPL 把结果返回给编辑器。 + +这有什么用呢?比方说你想要临时测一测你写的某个函数是否正常工作,或者看看某个库提供的函数返回的具体的数据格式,你不需要手动把他们的依赖库以及你已经在文件里定义好的变量手动复制到 REPL 里,也不需要手动执行整个程序,只需要按几个快捷键,结果就是呈现在你的编辑器里,显示在你的代码旁边。 + +如果你写过前端,你一定对热重载(Hot Relaod)不陌生。==REPL 就是 Lisp 通用的热重载机制==。不需要重启系统,每个改动都可以直接加载到 REPL 里。 + +REPL 作为用户界面也大有用处。在 1988 年,运行在太空中的一个 Lisp 程序陷入了死锁,名叫 Ron Garret 的 Lisp 程序员正是用 [REPL](https://corecursive.com/lisp-in-space-with-ron-garret/#a-repl-on-a-spacecraft) 查询程序状态并发送指令,解决了运行在二百四十万公里以外的某个程序的 Bug。正如程序员可以一边写代码一边运行刚写完的代码,用户也可以通过 REPL 与正在运行的程序交互。 + +## 对话式编程 + +我假定你已经安装了 Clojure 并且启动了 REPL。为了让 Clojure 写起来更舒服,我们可以让编辑器与 REPL 集成,再安装能够辅助我们在括号之间快速游走而不会 *Lost In Stupid Parentheses* 的插件。 + +如果你是 Emacs 用户,我相信你们的检索能力,你们大多数应该完全有能力自己配置环境,而且对 (Emacs) Lisp 应该很熟悉了。我只需要给你们指指路——[CIDER](https://cider.mx/)。不过,我不详细写 Emacs 的原因其实是:我不用 Emacs 写代码。 + +如果你是 Neovim 用户,接下来我会一步一步讲解如何在 Neovim 中配置 Clojure 环境。 + +如果你是 VS Code 用户,~~Get your text-editing browser out of my house!~~ + +尽管 Clojure 自带的命令行就够用了,但 Clojure 生态中最常用也最容易上手的工具是 [Leiningen](https://leiningen.org/)。你可以把它和 JavaScript 的 NPM 对比,~~不过不会隔三岔五就受到供应链攻击~~。Leiningen 的标语是: + +> With a focus on project automation and declarative configuration, it gets out of your way and lets you focus on your code. +> +> 聚焦项目自动化和声明式配置,为你扫清道路,让你专注你的代码。 + +事实也的确如此。你可以用非常简短易记的命令,让 Leiningen 帮你创建项目、运行项目、运行测试编译项目等等。不过我们暂时不必创建项目,我们可以用 Leinigen 启动 REPL。 + +```sh +lein repl +``` + +这和直接运行 Clojure 有什么区别呢? + +``` +nREPL server started on port 52293 on host 127.0.0.1 - nrepl://127.0.0.1:52293 +REPL-y 0.5.1, nREPL 1.3.0 +Clojure 1.12.2 +OpenJDK 64-Bit Server VM 26.0.2.1 + Docs: (doc function-name-here) + (find-doc "part-of-name-here") + Source: (source function-name-here) + Javadoc: (javadoc java-object-or-class-here) + Exit: Control+D or (exit) or (quit) + Results: Stored in vars *1, *2, *3, an exception in *e + +user=> +``` + +可以看到,执行 `lein repl` 之后,我们不仅启动了 Clojure,还启动了 [nREPL](https://nrepl.org/nrepl/index.html) 服务器,n 代表 Network,我们可以通过网络连接到这个 nREPL 服务器。 + +此外,我们可以看到服务器的地址是 `nrepl://127.0.0.1:52293`,协议头部分是 `nrepl://`。nREPL 本身并不是服务器,而是一套通信协议。 + +一般而言,服务器本身是个 Clojure REPL,而客户端可以终端也可以是任意的应用程序,当然,这里我们的客户端就是编辑器(或者说 IDE)。 + +重点并不是网络连接,而是通信协议。有了它,编辑器就能自动调用 API 和 REPL 交互,不需要我们手动输入表达式进去了,只需要在编辑器里照常写代码。 + +现在 REPL 和 nREPL 服务器都启动了,怎么让编辑器和它对话呢? + +在这个教程中,我用到的是 [Conjure](https://github.com/Olical/conjure)。你可以到根据你的 Neovim 使用的插件管理器到[文档](https://github.com/Olical/conjure?tab=readme-ov-file#installation)里复制对应的配置。如果是 [lazy.nvim](https://github.com/folke/lazy.nvim),就用这个: + +```lua +-- lazy.nvim +return { "Olical/conjure" } +``` + +```fennel +;; lazy.nvim with Fennel +[:Olical/conjure] +``` + +Conjure 的很多快捷键要用到局部 Leader 键(local leader key),这取决于你的配置,可以检查你的配置文件中 `vim.g.maplocalleader` 这个变量的值。我的 local leader 是 `\` 键,接下来我也会用这个键表示 local leader。 + +安装完成后,创建一个 `.clj` 文件,用 Neovim 打开。 + +```sh +nvim hello.clj +``` + +```clojure +;; hello.clj +(defn hello [string] + (println "Hello" string)) +``` + +![截图,左侧是运行 lein repl 的终端,显示 nREPL 启动了;右侧是 Neovim,Neovim 界面的右上角弹出提示表示已经连接到 nREPL](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/lein-repl-conjure.jpeg) + +打开这个文件的时候,你应该能看到右上角出现了 nREPL 连接的提示。如果没有,你需要确保 Neovim 的执行目录和 Leiningen 的执行目录一致,因为 `lein repl` 启动 nREPL 的时候会在当前目录生成一个 `.nrepl-port` 文件,内容为 nREPL 服务器监听的端口。Conjure 需要读取到这个文件才能知道怎么跟服务器通信。 + +``` +❯ cat .nrepl-port +52947 +``` + +一旦显示连接成功,就可以开始使用 nREPL 了。 + +试着先把光标移动到第一行第一列,也就是 `defn` 的左括号,按下 `\ee`,也就是先按下 local leader key,再按下两次 `E`。 + +![Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在左上角。按下快捷键之后光标右侧出现了 => #'user/hello 文本,右侧是 nREPL 的记录,也有相同的文本。这个文本是执行 defn 的结果](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/conjure-eval-defn.png) + +光标所在行出现了 `=> #'user/hello`,右侧的 nREPL 历史记录里也有——这是刚才 Eval 这个表达式的返回值。 + +不知道你还记不记得,在[上一篇](/article/tutorial-clj-0)中我写过,`'` 是 `quote` 的简写。此处的 `#'` 是 `var` 的简写,和 `quote` 一样是特殊形式(即在编译器内部实现和定义的形式,可以理解为编译器特殊对待的对象)。说实话,`var` 不太好理解,也不必深究,只需要知道他表示某个**命名空间**下的**符号**就好。 + +`user` 便是一个命名空间,是在没有定义命名空间时的默认值,而 `hello` 是我们刚刚用 `defn` 定义的函数。`#'user/hello` 更准确地描述了这个符号的身份,让编译器知道应该去哪里找到它。 + +总而言之,它是定义函数时会得到的返回值。 + +接下来把光标移动到第二行的第一个括号,按下 `\ee`。 + +![Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在 println 上,光标右侧出现了 => nil 文本,右侧除了 nil 之外,还显示:;; (out) Hello world!](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/conjure-eval-println.png) + +此时我们只对 `(println "Hello world!")` 求值,得到了 `nil`。右侧的 nREPL 历史记录里还有一条以 `(out)` 开头的注释,内容正是我们要打印的 `Hello world!`。可见,`println` 的返回值是 `nil`(也就是 `null`,表示空值),而输出到 `stdout` 或者别的什么输出流里的 `Hello world` 只是求值 `println` 产生的**副作用**。 + +做个小练习,换行,写 `(hello)` 这段代码,然后把光标移到上面按下 `\ee`,看看会发生什么。 + +`\ee` 的作用是对当前鼠标所在位置的形式(form)进行求值,另一个常用快捷键是 `\eb`,意思是 Eval Buffer,会把当前这整个 Buffer 的代码全部发送给 nREPL。如果你对整个文件都做了相当多的修改,可以用 `\eb` 快速告知 nREPL。如果你的光标在一个嵌套的形式里面(比如 `(println "Hello World")`),但你想要对最外层的形式求值(比如 `(defn ...)`),可以按 `\er`(Eval Root),这样就不用移动光标了。 + +如果你觉得右边打开的窗口太小,想要查看完整的历史记录,可以用 `\ls`(Log Split),可以打开水平分割的一个 Buffer,在里面显示所有的历史记录;或者用 `\lv` 垂直分割。更多操作可以用命令 `:help conjure` 打开使用手册查询。 + +## 结构化编辑 + +普通的编辑器一般只会把代码当文本来看,最多给括号开闭和缩进层级做高亮显示。操作上,双击可以复制整个词、三击可以复制整段话,也只是对词句的操作。这对 Lisp 代码来说可不是好事,一层又一层的括号令人望而却步,而编辑器却帮不上忙。 + +Emacs 有 [paredit](https://paredit.org/),而 Neovim 也有对应的[实现](https://github.com/julienvincent/nvim-paredit)。如果不需要改键位的话,直接用默认配置就好: + +```lua +-- lazy.nvim +return { + "julienvincent/nvim-paredit", + config = function() + require("nvim-paredit").setup() + end +} +``` + +```fennel +;; lazy.nvim with Fennel +{1 :julienvincent/nvim-paredit + :config (fn [] + ((. (require :nvim-paredit) :setup)))} +``` + +`nvim-paredit` 基于 Tree-sitter[^2],如果你发现后面提到的快捷键用不了,可能需要手动启动 Tree-sitter: + +```lua +-- init.lua +vim.api.nvim_create_autocmd("FileType", { + pattern = {"clojure"}, + callback = function () + vim.treesitter.start() + end +}) +``` + +```fennel +;; init.fnl +(vim.api.nvim_create_autocmd + "FileType" {"pattern" ["clojure"] + "callback" (lambda [] (vim.treesitter.start))}) +``` + +Paredit 的各种操作解释起来有点困难,有个[动画演示教程](http://danmidwood.com/content/2014/11/21/animated-paredit.html),不过是英文的,而且用的是 Emacs 的快捷键。我就讲一些我常用的快捷键,剩下的请读者自行探索。 + +首先是 Slurp 和 Barf,这是两个拟声词,分别是吞咽食物的声音和打嗝的声音,可以理解为「吞」和「吐」。这两个操作都有方向,可以向前吞,或向后吞。吞和吐的对象是列表里的元素。 + +```clojure +;; 原本的代码 +(list some) elements + +;; slurp forwards(向前吞)之后的代码 +(list some elements) + +;; barf backwards(向后吐)之后的代码 +list (some elements) + +;; slurp backwards(向后吞)之后的代码 +(list some elements) + +;; barf forwards(向前吐)之后的代码 +(list some) elements +``` + +前和后的区分可能有点反直觉,用英文的 *forwards* 或者用「前进」这个词来理解可能会清晰一点。代码前进的方向就是一直往后写的方向,所以向前吐是往代码右边吐参数,而向后吞是把代码左边的形式放进第一个元素的位置。`nvim-paredit` 中,向前吞的快捷键是 `>)`,向后吞的快捷键是 `<(`;向前吐的快捷键是 `<)`,向后吐的快捷键是 `>(`。说实话有点难记,多练练就好,或者改成别的键。 + +也很常用且相对好理解的是 Drag(拖拽),按下 `>e` 可以把当前元素往前拖,`f,把当前形式往后拖 +(-(+ a b) c) +``` + +如果熟练掌握结构化编辑,编辑 Lisp 代码的速度会超过编辑一般代码的速度,不过就像刚从全拼转成双拼或者别的输入法时会觉得自己不会打字了一样,paredit 的快捷键需要一段时间适应。 + +除了编辑,可能还需要一些快速移动的按键: + +1. 默认的 Vim 和 Neovim,在 NORMAL 模式里按下 `w`(word)会跳转到下一个词的开头,按下 `e`(end)会跳转到下一个词的末尾,按下 `b`(back)会返回上一个词;装了 paredit 之后,用 `W`、`E`、`B` 可以分别跳转到下一个元素的开头、末尾,和上一个元素的开头。 +2. 在 VISUAL 模式中,按下 `af`(或者连起来:`vaf`,其中 `v` 是切换到 VISUAL 模式),可以选中当前形式(包含括号);如果换成 `if`,就只会选中形式内部的元素,不包含外部的括号。把 `f`(form)换成 `e`(element)可以选中元素,换成 `F` 可以选中顶层的形式而非当前形式。 + +![](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/paredit-vaf.png "用 `af`(around form)选中一整个形式") + +如果不能马上掌握也不必气馁,接触新的编辑方式就是很难。即便不用 Paredit,也能写 Lisp 代码,不过能学会的话会舒服很多。 + +--- + +## 最后 + +好啦,希望你已经能够在电脑上运行 Clojure 程序,并且没那么害怕用一大堆括号编程了。 + +下一篇文章将重点关注形式(form)的诸多种类:函数(function form)、宏(macro form)和特殊形式(special form)。除了基本的整数、浮点数和字符串,我们还会见到符号(symbol)类型。 + + +[^1]: Conjure 还支持 Python、JavaScript、PHP、R、Lua、Rust 等语言。 +[^2]: 如果你不知道 Tree-sitter 是什么,其实大部分编辑器都有这个东西。编程语言一般都有自己的 Tree-sitter,用来把某门语言的代码解析成分析用的 AST,可以分析代码结构,最常用来做代码高亮。 diff --git a/content/article/clj2.md b/content/article/clj2.md new file mode 100644 index 00000000..a5d6207c --- /dev/null +++ b/content/article/clj2.md @@ -0,0 +1,372 @@ ++++ +title = 'Coffee Break Clojure, Vol.2' +subtitle = "Forms O' Plenty" +slug = 'clj2' +aliases = '/article/tutorial-clj-2/' +tags = ["Lisp","编程"] +categories = '茶歇蔻芥' +date = 2026-09-25 18:43:53 +draft = true +toc = true ++++ + +[上一篇](http://localhost:1313/article/tutorial-clj-1/)我们配置好了 REPL,可以随时开始编写 Clojure 代码并执行。[第零篇](/article/tutorial-clj-0/)提到,被求值(Eval)的列表叫作形式(form),形式的第一个元素是操作符,剩余的是参数。根据操作符的不同,形式被分为函数形式、Lambda 形式、宏形式和特殊形式。今天的文章就来介绍这几种形式。 + +## 函数形式 + +不难理解,函数形式就是操作符为函数的形式,比如这个 `println` 形式: + +```clojure +(println "Hello World") +``` + +我们知道 `"Hello World"` 是字符串,那 `println` 是什么类型?在其他语言里我们可能很少考虑函数名本身的类型,但别忘了 Lisp 的「代码即数据」本质。要把待求值的形式当作数据而非形式处理,只需要把它 `quote` 起来。 + +这里要引入 Clojure 的 `type` 函数,它返回输入值的类型。我们不妨把刚才的形式 `quote` 起来,然后获取他的第一个元素 `println`,看看它是什么类型。 + +```clojure +(type (first '(println "Hello World"))) +;; => clojure.lang.Symbol +``` + +类型是 `Symbol`(符号)!这是 Lisp 中的一种特殊数据类型,和其他的原子不同,符号不是自求值(self-evaluating)的。意思是说,对 `"string"` 求值得到的是 `"string"` 本身,但对符号求值得到的是符号指向的其他值。 + +要定义一个符号很简单,只需要用到 `def`。 + +```clojure +(def my-symbol 123) +;; => #'user/my-symbol + +123 +;; => 123 + +my-symbol +;; => 123 +``` + +可见符号和它指向的数据本身是等价的,对 `123` 求值和对 `my-symbol` 求值得到的结果都是 `123`。 + +接下来试试让符号指向函数: + +```clojure +(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` 本身也是个函数吗? + +```clojure +((eval 'my-fn) 1 2) +;; => 3 +``` + +之前我们提到对 `(eval (quote x))` 求值和对 `x` 本身求值是等价的,这里也成立。对 `my-fn` 求值返回的是个符号,对 `(eval 'my-fn)` 求值得到的也是个符号。只要形式的第一个元素是指向函数的符号,就能完成函数调用。 + +根据条件返回不同类型的符号也是可以的: + +```clojure +(def a 1) +;; => #'user/a +((if (> a 1) + -) 2 1) +;; => 1 +``` + +好了好了,没人会这么写程序的。你现在知道,==函数形式就是操作符为指向函数的符号的形式==,对函数形式求值就是调用函数。还记得刚才让一个符号指向一个值的 `def` 吗?如果要用来声明函数,还有更简便的写法: + +```clojure +(def my-fn + (fn [a b] (+ a b))) + +;; 等价于 +(defn my-fn [a b] + (+ a b)) +``` + +写 Clojure 函数的时候你多半只会用到 `defn`,但不要忘了函数名和变量名一样,都只是符号而已。 + +记住这个原理你能做到很多有趣的事情。比方说,在其他语言里我们经常对值做一些操作,然后赋值给变量,就像这样: + +```clojure +(def a (reduce + '(1 2 3))) +;; => #'user/a + +a +;; => 6 +``` + +我们对列表 `(1 2 3)` 做了累加操作,变成了 `6`,然后赋值给 `a`。函数也可以这么干,比如 `partial` 就是对函数的操作,可以让我们提前填充函数的某些参数,然后返回新的函数: + +```clojure +(def plus-one (partial + 1)) +;; => #'user/plus-one + +(plus-one 3) +;; => 4 + +((partial + 1) 3) +;; => 4 +``` + +我们对函数 `+` 做了 `partial` 操作,给它提前填上了一个参数 `1`。调用得到的新函数就相当于调用 `(+ 1 ...)`。在其他语言里,要做到类似的事情,你可能要这么写: + +```clojure +(defn plus-one [num] + (+ 1 num)) +``` + +你可能注意到了 Clojure 函数和非 Lisp 语言的另一个区别:没有 `return` 语句。函数如果有多个并列的形式,就会以最后一个形式的值作为函数的返回值。 + +做个习题,下面这段函数调用为什么返回了 `nil`? + +```clojure +(defn chatty-add [a b] + (let [result (+ a b)] + result + (println result))) + +(chatty-add 1 2) ;; => nil +``` + +## Lambda 形式 + +Lambda 形式类似 JavaScript 里的立即执行函数,也就是定义一个匿名函数,然后立马执行它,这样就可以限定变量的作用域。 + +```js +(function (a, b) { + let c = a + b + return c +})(1, 2) +// => 3 +``` + +如果是 Clojure,就要这么写: + +```clojure +((fn [a b] (+ a b)) 1 2) +;; => 3 +``` + +Lambda 形式的操作符不是指向函数的符号,就是函数本身。 + +在 Scheme(另一门 Lisp 方言)里要这么写: + +```scheme +((lambda (a b) (+ a b)) 1 2) +;; => 3 +``` + +在 Scheme 里,这种写法有个语法糖,叫 `let`: + +```scheme +(let ((a 1) (b 2)) + (+ a b)) +``` + +没错,这和 JavaScript 和 Rust 等语言用于声明变量的 `let` 很类似,但 Scheme 中 `let` 仅仅是立即执行函数的语法糖。不过,在 Clojure 中,`let` 是个特殊形式,有专门的[解析器](https://github.com/clojure/clojure/blob/07adae6d24a0efcb4e6b39577c473b61df9d5f5b/src/jvm/clojure/lang/Compiler.java#L6902),我会在后文展开。 + +## 宏形式 + +前面我一直在用加减法做例子,现在不妨来看看逻辑运算。Clojure 中表示与、或、非运算的操作符分别是 `and`、`or` 和 `not`。其中 `not` 是个非常简单的函数: + +```clojure +(defn not [x] + (if x false true)) +;; 如果 x 为真,返回 false +;; 如果 x 为假,返回 true +``` + +但 `and` 和 `or` 不可以是函数,必须是宏。为什么?我们不妨假设他们是函数,看看会发生什么。 + +```clojure +(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` 这个宏的定义: + +```clojure +(defmacro or + ([] nil) + ([x] x) + ([x & next] + `(let [or# ~x] + (if or# or# (or ~@next))))) +``` + +没有参数时n展开为 `nil`,只有一个参数时展开为这个参数本身,有多个参数时会被展开为嵌套在 `let` 里的 `if`,我们看到宏还能像函数一样递归,因为 `or` 的定义包含了 `or` 本身。 + +这看起来有点难以理解,我们不妨手动展开一下: + +```clojure +(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` 是个函数,下面这段表达式会变成什么样子? + +```clojure +(fn [a b c] + (println a b c) + (+ a b c)) +``` + +
+ 如果 fn 是函数的解释 + +首先对 `[a b c]` 求值,解释器会发现 `a` 这个符号不存在,然后报错。 + +
+ +关于怎么自定义宏,就放在之后的文章再讲了。 + +## 特殊形式 + +前面提到 `if` 和 `let` 都是特殊形式,可以理解为其他语言里的保留字。这些符号有特殊的解释规则,或者编译规则。==特殊形式就是由编译器和解释器实现的形式==。函数和宏都是用户定义的,只不过有的定义在标准库里。 + +有趣的是,Clojure 的 `let` 最外层其实是个宏,而 `let*` 才是特殊形式,只不过 `let` 最终会被展开为 `let*`。 + +```clojure +(macroexpand '(let [a 1 b 1] (+ a b))) +;; => (let* [a 1 b 1] (+ a b)) +``` + +看起来 `let` 被展开之后仅仅是把 `let` 替换成了 `let*`,为什么要套一层宏呢? + +这次我们故意把 `let` 写错,看看会发生什么。 + +```clojure +(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` 里写的符号绑定应该是偶数个。这个语法检查并不是编译器实现的,而是宏定义的: + +```clojure +(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 也有解构: + +```js +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` 解构的例子: + +```clojure +(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` 形式做宏展开,就会得到: + +```clojure +(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*` 的作用不同,比方说下面这段代码是有问题的: + +```lisp +(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*`: + +```lisp +(let* ((a 1) (b 2) (c (+ a b))) + (+ a b c)) +; 6 +``` + +《[A Scheme Primer](https://files.spritely.institute/papers/scheme-primer.html#fn.more-lets)》里写到,如果 Scheme 的规范要从头开始指定,最好要把 `let`、`let*` 还有 `letrec`(允许递归引用符号自己的 `let`)都合成同一个。Clojure 作为较新的一门 Lisp 方言,少了很多历史遗留问题。 + +--- + +## 最后 + +现在你应该知道求值 Clojure 形式时,解释器底下大概都发生了什么,你应该也熟悉了一些基本的函数、宏和特殊形式,比如 `let` `if` 和 `fn`,可以写一些简单的 Clojure 程序了。 + +其实除了本文提到的四种形式,Clojure 还可以用特定类型的数据作为操作符(比如用一个集合做操作符,用于查看参数是否存在于集合内),也可以当作一种类型的形式。这要留到下篇讲了。 + +下一篇我们要了解 Clojure 里的集合数据类型,分别是我们已经见过的列表(list)和向量(vector),此外还有集合(set)和映射(map)。我们还会讲到如何操作这些集合,包括遍历和筛选等等。我们还会了解到惰性序列(Lazy sequence),然后你就可以写程序处理相对复杂的数据,并且能够到同时做到语法简洁和高性能。这些都是后话了。 + +回见! + diff --git a/content/article/clj3.md b/content/article/clj3.md new file mode 100644 index 00000000..80475c32 --- /dev/null +++ b/content/article/clj3.md @@ -0,0 +1,259 @@ ++++ +title = 'Coffee Break Clojure, Vol.3' +subtitle = '只用圆括号怎么够?' +slug = 'clj3' +aliases = '/article/tutorial-clj-3' +tags = ["Lisp","编程"] +categories = '茶歇蔻芥' +date = 2026-09-25T20:17:41+08:00 +draft = true +toc = true ++++ + +[上一篇](/article/tutorial-clj-2)我们讨论了不同类型的形式,了解了函数、Lambda、宏和特殊形式的皮毛,也学会了用一些基本的运算符、`let` 和 `if` 等等。今天的文章要讨论集合数据类型。不过在此之前,我们还是先看看普通的数据类型有哪些吗? + +## 你已经知道和你可能不知道的 + +和大部分编程语言一样,Clojure 支持数字、浮点数、字符串和布尔值。 + +由于 Clojure 是运行在 JVM 上的,所以 Java 这些数据类型实际上对应一个 Java 类。数字是 `Long` 而不是 `Int`,前者能表示的最大数是 2 的 63 次方减 1,也就是有 64 位,第一位是符号位,后者是 32 位。和 Java 一样,Clojure 没有无符号数(unsigned int)。 + +Clojure 还能在这些类型上调用 Java 的方法,比如 `String.getBytes()`。 + +```clojure +(seq (.getBytes "Hello")) +;; => (72 101 108 108 111) +(type (first (seq (.getBytes "Hello")))) +;; => java.lang.Byte +``` + +Clojure 也没有用更小的 `Float`,也是用了双精度浮点数 `Double`。 + +```clojure +(type 1.1) +;; => java.lang.Double +``` + +Clojure 是门动态类型语言,一般不需要考虑太多类型问题,不过知道这些类型底层是什么样的也挺好。 + +值得注意的是 Clojure 还有个很常用的自定义类型,叫作关键词(keyword)。 + +```clojure +(type :keyword) +;; => clojure.lang.Keyword +``` + +关键词类似字符串,不过是以 `:` 开头,中间不能有空格。其实他更像其他语言里的枚举类型(enums),但无需提前定义。关键词不能像字符串一样做裁剪、找子串、当作字符数组遍历之类的操作,就是一个整体。 + +关键词可以用在大部分需要枚举类型的场合,也用来表示映射(map)里的键(key)。 + +其他的非集合类型还有我们上一篇讨论过的符号(Symbol)。 + +## 圆括号和列表 + +我们在之前就见过列表(list),最普通的集合数据类型。 + +```clojure +(list "string" 1 :item true) +;; or +'("string" 1 :item true) +``` + +和许多动态类型语言一样,列表可以包含不同类型的数据。 + +注意,上面这个例子中两种创建列表的方式有区别,我在[第零篇](/article/tutorial-clj-0)留了个习题,现在公布答案: + +```clojure +(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`。 + +```lisp +(cons 42 (cons 69 (cons 613 nil))) +;; 等价于 +(list 42 69 613) +``` + +![](https://img.eltr.ac/geedeapro/article/common-lisp-start/Cons-cells.svg.png) +{.dark:invert} + +虽然 Clojure 的 `cons` 也可以用相同的方式构造列表,但 *cons* 对却不存在。`cons` 的第二个函数只能是序列,不能是原子。 + +```clojure +(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` 只被用来「把数据放进列表的前面」。 + +```clojure +(cons 1 '(2 3)) +;; => (1 2 3) +``` + +Clojure 的列表叫作持久化列表(PersistentList),Clojure 的所有集合数据类型几乎都是持久化(Persistent)且不可变(Immutable)的。 + +```clojure +(type '(1)) +;; => clojure.lang.PersistentList +``` + +不可变很好理解,对数据做操作只会产生新的数据,而不会修改原来的数据,原来的数据还保存在内存的同一个地方。那什么是[持久化](https://zh.wikipedia.org/wiki/%E5%8F%AF%E6%8C%81%E4%B9%85%E5%8C%96%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84)呢?意思是数据被修改后,历史的版本还保留着并且还**能被访问**。 + +不妨看看 Clojure 是怎么实现 `PersistentList` 的: + +```java +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()` 的: + +```java +public void add(int index, Object element){ + throw new UnsupportedOperationException(); +} +``` + +嗯?直接抛出异常了? + +令人费解,而且有违反里氏替换原则的嫌疑。仔细想想,Clojure 的确没有给列表直接添加元素的方法,我们用的是 `cons` 和 `conj`(后者与 `cons` 相反,是在序列的尾部添加元素)。那 `cons` 是怎么实现的? + +```java +public Cons(Object first, ISeq _more){ + this._first = first; + this._more = _more; +} +``` + +这下就清晰了,求值 `(cons 1 '(2 3))` 得到 `(1 2 3)`,原来的 `1` 和 `(2 3)` 都留在原地,我们只是创建了一个 `Cons`,指向那两个值,而 `Cons` 本身也是个序列,可以再用它创建新的 `Cons` 或者其他序列(`Cons` 是 `ASeq` 的子类,也就实现了 `ISeq`,序列接口,`PersistentList` 也是序列)。 + +这种构造持久化列表的方式叫作结构共享(Structural Sharing),既做到了不可变,又没有在内存中管理大量的数据副本。本质上,一切都是指针。 + +由于不可变,所以列表的大小可以作为不变的属性保存起来,回去看 `PersistentList` 的 Java 代码,`_first` 和 `_rest` 下面就是 `_count`。Clojure 的所有集合数据类型都能用 `count` 函数获取其长度,并且这个算法的时间复杂度是 *O(1)*,因为只需要 `return _count`。此外,有很多操作都因为结构共享变得非常高效,Clojure 的性能在 Lisp 中算很好的那一类。 + +至于不可变还有什么好处,我们之后谈函数式编程再提。 + +## 方括号和向量 + +理解一门 Lisp 中的列表如何构造,可以说就理解了它的核心。我们知道列表是持久化且不可变的,由 `Cons` 和指向其他 `Cons` 的「指针」构成。 + +向量基本上和列表一致,但向量中所有元素都有索引(index),这点和 JavaScript 的数组很像,写起来也和 JavaScript 数组很像,也可以存放不同类型的数据。 + +```clojure +[1 "I'm a vector" :vector true] +``` + +列表和向量都可以用 `first`、`rest` 和 `cons` 函数。 + +```clojure +(first [1 2 3 4]) ;; => 1 +(first '(1 2 3 4)) ;; => 1 + +(rest [1 2 3 4]) ;; => (2 3 4) +(rest '(1 2 3 4)) ;; => (2 3 4) + +(cons 1 [2 3 4]) ;; => (1 2 3 4) +(cons 1 '(2 3 4)) ;; => (1 2 3 4) + +;; 当然还支持其他操作序列的函数 +;; 列表和向量都可以通用 +``` + +你可能发现执行了序列操作之后,向量的方括号变成了圆括号。不必担心,它实际上还是向量,行为是一致的。 + +```clojure +(type (rest [1 2 3 4])) +;; => clojure.lang.PersistentVector$ChunkedSeq +``` + +向量和列表最大的不同点在于,可以用索引访问向量的元素,但列表没有索引。 + +```clojure +(get [1 2 3 4] 2) ;; => 3 +(get '(1 2 3 4) 2) ;; => nil +``` + +向量和列表的另一个区别,尽管他们都是连续的(sequential),但只有列表是序列(sequence)。用 `seq?` 检查数据是不是序列的时候,向量会返回 `false`。 + +```clojure +(seq? '(1 2)) ;; => true +(seq? [1 2]) ;;=> false +``` + +如果希望向量也返回 `true`,就要检查一个集合有没有实现 `Sequential` 接口。 + +```clojure +(sequential? '(1 2)) ;; => true +(sequential? [1 2]) ;; => true +``` + +有意思的是,把 `seq?` 的问号去掉,你也能让向量返回一个真值。 + +```clojure +(if (seq [1 2]) + "I am a seq" + "I am not a seq") +;; => "I am a seq" +``` + +`seq` 的作用是把可以转换为序列的数据结构转换为序列,字符串也可以转换成序列。 + +```clojure +(seq "string") +;; => (\s \t \r \i \n \g) +;; \ 用来表示 Clojure 中的字符 +``` + +集合只要不是空的,都会返回一个正常的序列,会被条件判断当作真值。因此 `seq` 经常被用来检查一个集合是否不为空;相反,`emtpy?` 用来检查一个集合是否为空。 + +序列(sequence)是只支持线性访问,不支持随机访问的集合,可以当作列表理解。序列的行为和列表几乎是一致的,它也没有索引。 + +```clojure +(get (seq [1 2]) 1) +;; => nil +``` + +最后,向量其实还继承了 `AFn` 类,可以当成函数使用。 + +```clojure +([1 2 3] 0) ;; => 1 +;; 等价于: +(get [1 2 3] 0) ;; => 1 +``` + +这样就省掉了写 `get` 的工夫。 + +## 花括号和映射 + +Clojure 的映射写起来和 JSON 很像。 + +```clojure +{:key1 "val1" + :key2 {:subkey1 2 + :subkey2 [1 2 3]} + :key3 nil} +``` + +https://www.brainonfire.net/files/seqs-and-colls/main.html + +## 井号和集合 + +[^1]: 可参考:《[Just A Common Lisper](/article/common-lisp-start/)》 diff --git a/content/article/tutorial-clj-0.md b/content/article/tutorial-clj-0.md deleted file mode 100644 index 444ba2aa..00000000 --- a/content/article/tutorial-clj-0.md +++ /dev/null @@ -1,259 +0,0 @@ -+++ -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》。 - -希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。 - ---- - -## 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 对构造的。 diff --git a/content/article/tutorial-clj-1.md b/content/article/tutorial-clj-1.md deleted file mode 100644 index 6ead5a50..00000000 --- a/content/article/tutorial-clj-1.md +++ /dev/null @@ -1,276 +0,0 @@ -+++ -title = 'Coffee Break Clojure, Vol.1' -subtitle = '准备工具' -slug = 'tutorial-clj-1' -tags = ["Lisp","编程"] -categories = '茶歇蔻芥' -date = 2026-09-19 01:45:49 -draft = false -toc = true -fedi = 'https://akk.eltr.ac/objects/4b9561d5-80f2-47fe-8ede-1663cf501db6' -+++ - -[上一篇](/article/tutorial-clj-0)我们了解了 Lisp 中「代码即数据」的本质,知道了不求值(Eval)的列表就是数据,被求值的列表就是代码,而代码可以像数据一样经过任意的变换之后再求值。我们还捋了捋列表、形式、对象和原子的概念,可以说,读者已经掌握 Lisp 语法的精髓了。这里除了括号和待求值的一堆括号,别无他物。 - -了解了语法,在深入 Clojure 的语义(semantics)之前,先来准备些趁手工具。 - -## 读取-求值-打印-循环 - -正式开始之前,你需要先安装 Java 和 [Clojure](https://clojure.org/guides/install_clojure)(因为 Clojure 运行在 JVM 之上),之后就可以在终端输入 `clojure`,按下回车之后便会看到新的命令提示符: - -``` -Clojure 1.12.6 -user=> -``` - -在 Lisp 的世界里,这一般被称作 REPL,全称是:Read-Eval-Print-Loop(读取-求值-打印-循环)。顾名思义,它读取用户输入,求值,打印这个值,然后循环。有趣的是,在 Common Lisp 等方言中,你可以用一行代码启动最原始的 REPL: - -```lisp -(loop (print (eval (read)))) -``` - -Clojure 不能这样写,因为 `loop` 的用法不同。不过把 `loop` 换成 `while` 就好了,效果差不多。试试看,在你的 REPL 里输入这段代码: - -```clojure -(while true (println (eval (read)))) -``` - -然后你就会在 REPL 里获得一个简陋的子 REPL:输入一段代码,按下回车,求值的结果就会显示在下一行。 - -不难发现,REPL 和 [Shell](https://en.wikipedia.org/wiki/Shell_(computing)) 非常类似,前者允许用户用命令行界面与编程语言交互,后者允许用户用命令行界面与操作系统交互。或者说,Shell 语言就是用来跟操作系统交互的编程语言,而 Shell 程序就是 Shell 语言的 REPL。 - -现在,按下 `Ctrl + D` 退出子 REPL,回到*真正的* REPL 里。 - -在开启你的 Clojure/Lisp 旅程之前,务必和 REPL 打好关系。我记得我刚开始写 Clojure 的时候,还像个麻瓜一样写完代码,保存,然后执行整个文件甚至整个项目。这很折磨,而且让 Clojure 感觉上就是个语法更小众的普通编程语言。 - -REPL 不仅仅是跟 Clojure 交互的媒介,还是与你自己的代码交互的媒介,也可以是用户与你的代码交互的媒介。 - -Lisp 常见的编程实践叫作交互式编程(Interactive Programming),允许程序员一边写代码一边与已经写好的代码交互。原理其实很简单,每写好一段代码,都把代码发送到 REPL 里求值,函数定义、变量和各种状态被加载到 REPL 中,就可以使用之前已经定义好的符号继续编程。 - -最原始的交互式编程是:电脑上一边打开编辑器,另一边打开终端运行 REPL,每写一段代码就复制粘贴到 REPL 中。不过这太不优雅了,Emacs 的 [SLIME](https://github.com/slime/slime) 和 Neovim 的 [Conjure](https://github.com/Olical/conjure)[^1] 允许程序员直接在编辑器内部对代码求值,它们用某种协议把代码发送给 REPL 进程(或 REPL 服务器),随后 REPL 把结果返回给编辑器。 - -这有什么用呢?比方说你想要临时测一测你写的某个函数是否正常工作,或者看看某个库提供的函数返回的具体的数据格式,你不需要手动把他们的依赖库以及你已经在文件里定义好的变量手动复制到 REPL 里,也不需要手动执行整个程序,只需要按几个快捷键,结果就是呈现在你的编辑器里,显示在你的代码旁边。 - -如果你写过前端,你一定对热重载(Hot Relaod)不陌生。==REPL 就是 Lisp 通用的热重载机制==。不需要重启系统,每个改动都可以直接加载到 REPL 里。 - -REPL 作为用户界面也大有用处。在 1988 年,运行在太空中的一个 Lisp 程序陷入了死锁,名叫 Ron Garret 的 Lisp 程序员正是用 [REPL](https://corecursive.com/lisp-in-space-with-ron-garret/#a-repl-on-a-spacecraft) 查询程序状态并发送指令,解决了运行在二百四十万公里以外的某个程序的 Bug。正如程序员可以一边写代码一边运行刚写完的代码,用户也可以通过 REPL 与正在运行的程序交互。 - -## 对话式编程 - -我假定你已经安装了 Clojure 并且启动了 REPL。为了让 Clojure 写起来更舒服,我们可以让编辑器与 REPL 集成,再安装能够辅助我们在括号之间快速游走而不会 *Lost In Stupid Parentheses* 的插件。 - -如果你是 Emacs 用户,我相信你们的检索能力,你们大多数应该完全有能力自己配置环境,而且对 (Emacs) Lisp 应该很熟悉了。我只需要给你们指指路——[CIDER](https://cider.mx/)。不过,我不详细写 Emacs 的原因其实是:我不用 Emacs 写代码。 - -如果你是 Neovim 用户,接下来我会一步一步讲解如何在 Neovim 中配置 Clojure 环境。 - -如果你是 VS Code 用户,~~Get your text-editing browser out of my house!~~ - -尽管 Clojure 自带的命令行就够用了,但 Clojure 生态中最常用也最容易上手的工具是 [Leiningen](https://leiningen.org/)。你可以把它和 JavaScript 的 NPM 对比,~~不过不会隔三岔五就受到供应链攻击~~。Leiningen 的标语是: - -> With a focus on project automation and declarative configuration, it gets out of your way and lets you focus on your code. -> -> 聚焦项目自动化和声明式配置,为你扫清道路,让你专注你的代码。 - -事实也的确如此。你可以用非常简短易记的命令,让 Leiningen 帮你创建项目、运行项目、运行测试编译项目等等。不过我们暂时不必创建项目,我们可以用 Leinigen 启动 REPL。 - -```sh -lein repl -``` - -这和直接运行 Clojure 有什么区别呢? - -``` -nREPL server started on port 52293 on host 127.0.0.1 - nrepl://127.0.0.1:52293 -REPL-y 0.5.1, nREPL 1.3.0 -Clojure 1.12.2 -OpenJDK 64-Bit Server VM 26.0.2.1 - Docs: (doc function-name-here) - (find-doc "part-of-name-here") - Source: (source function-name-here) - Javadoc: (javadoc java-object-or-class-here) - Exit: Control+D or (exit) or (quit) - Results: Stored in vars *1, *2, *3, an exception in *e - -user=> -``` - -可以看到,执行 `lein repl` 之后,我们不仅启动了 Clojure,还启动了 [nREPL](https://nrepl.org/nrepl/index.html) 服务器,n 代表 Network,我们可以通过网络连接到这个 nREPL 服务器。 - -此外,我们可以看到服务器的地址是 `nrepl://127.0.0.1:52293`,协议头部分是 `nrepl://`。nREPL 本身并不是服务器,而是一套通信协议。 - -一般而言,服务器本身是个 Clojure REPL,而客户端可以终端也可以是任意的应用程序,当然,这里我们的客户端就是编辑器(或者说 IDE)。 - -重点并不是网络连接,而是通信协议。有了它,编辑器就能自动调用 API 和 REPL 交互,不需要我们手动输入表达式进去了,只需要在编辑器里照常写代码。 - -现在 REPL 和 nREPL 服务器都启动了,怎么让编辑器和它对话呢? - -在这个教程中,我用到的是 [Conjure](https://github.com/Olical/conjure)。你可以到根据你的 Neovim 使用的插件管理器到[文档](https://github.com/Olical/conjure?tab=readme-ov-file#installation)里复制对应的配置。如果是 [lazy.nvim](https://github.com/folke/lazy.nvim),就用这个: - -```lua --- lazy.nvim -return { "Olical/conjure" } -``` - -```fennel -;; lazy.nvim with Fennel -[:Olical/conjure] -``` - -Conjure 的很多快捷键要用到局部 Leader 键(local leader key),这取决于你的配置,可以检查你的配置文件中 `vim.g.maplocalleader` 这个变量的值。我的 local leader 是 `\` 键,接下来我也会用这个键表示 local leader。 - -安装完成后,创建一个 `.clj` 文件,用 Neovim 打开。 - -```sh -nvim hello.clj -``` - -```clojure -;; hello.clj -(defn hello [string] - (println "Hello" string)) -``` - -![截图,左侧是运行 lein repl 的终端,显示 nREPL 启动了;右侧是 Neovim,Neovim 界面的右上角弹出提示表示已经连接到 nREPL](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/lein-repl-conjure.jpeg) - -打开这个文件的时候,你应该能看到右上角出现了 nREPL 连接的提示。如果没有,你需要确保 Neovim 的执行目录和 Leiningen 的执行目录一致,因为 `lein repl` 启动 nREPL 的时候会在当前目录生成一个 `.nrepl-port` 文件,内容为 nREPL 服务器监听的端口。Conjure 需要读取到这个文件才能知道怎么跟服务器通信。 - -``` -❯ cat .nrepl-port -52947 -``` - -一旦显示连接成功,就可以开始使用 nREPL 了。 - -试着先把光标移动到第一行第一列,也就是 `defn` 的左括号,按下 `\ee`,也就是先按下 local leader key,再按下两次 `E`。 - -![Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在左上角。按下快捷键之后光标右侧出现了 => #'user/hello 文本,右侧是 nREPL 的记录,也有相同的文本。这个文本是执行 defn 的结果](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/conjure-eval-defn.png) - -光标所在行出现了 `=> #'user/hello`,右侧的 nREPL 历史记录里也有——这是刚才 Eval 这个表达式的返回值。 - -不知道你还记不记得,在[上一篇](/article/tutorial-clj-0)中我写过,`'` 是 `quote` 的简写。此处的 `#'` 是 `var` 的简写,和 `quote` 一样是特殊形式(即在编译器内部实现和定义的形式,可以理解为编译器特殊对待的对象)。说实话,`var` 不太好理解,也不必深究,只需要知道他表示某个**命名空间**下的**符号**就好。 - -`user` 便是一个命名空间,是在没有定义命名空间时的默认值,而 `hello` 是我们刚刚用 `defn` 定义的函数。`#'user/hello` 更准确地描述了这个符号的身份,让编译器知道应该去哪里找到它。 - -总而言之,它是定义函数时会得到的返回值。 - -接下来把光标移动到第二行的第一个括号,按下 `\ee`。 - -![Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在 println 上,光标右侧出现了 => nil 文本,右侧除了 nil 之外,还显示:;; (out) Hello world!](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/conjure-eval-println.png) - -此时我们只对 `(println "Hello world!")` 求值,得到了 `nil`。右侧的 nREPL 历史记录里还有一条以 `(out)` 开头的注释,内容正是我们要打印的 `Hello world!`。可见,`println` 的返回值是 `nil`(也就是 `null`,表示空值),而输出到 `stdout` 或者别的什么输出流里的 `Hello world` 只是求值 `println` 产生的**副作用**。 - -做个小练习,换行,写 `(hello)` 这段代码,然后把光标移到上面按下 `\ee`,看看会发生什么。 - -`\ee` 的作用是对当前鼠标所在位置的形式(form)进行求值,另一个常用快捷键是 `\eb`,意思是 Eval Buffer,会把当前这整个 Buffer 的代码全部发送给 nREPL。如果你对整个文件都做了相当多的修改,可以用 `\eb` 快速告知 nREPL。如果你的光标在一个嵌套的形式里面(比如 `(println "Hello World")`),但你想要对最外层的形式求值(比如 `(defn ...)`),可以按 `\er`(Eval Root),这样就不用移动光标了。 - -如果你觉得右边打开的窗口太小,想要查看完整的历史记录,可以用 `\ls`(Log Split),可以打开水平分割的一个 Buffer,在里面显示所有的历史记录;或者用 `\lv` 垂直分割。更多操作可以用命令 `:help conjure` 打开使用手册查询。 - -## 结构化编辑 - -普通的编辑器一般只会把代码当文本来看,最多给括号开闭和缩进层级做高亮显示。操作上,双击可以复制整个词、三击可以复制整段话,也只是对词句的操作。这对 Lisp 代码来说可不是好事,一层又一层的括号令人望而却步,而编辑器却帮不上忙。 - -Emacs 有 [paredit](https://paredit.org/),而 Neovim 也有对应的[实现](https://github.com/julienvincent/nvim-paredit)。如果不需要改键位的话,直接用默认配置就好: - -```lua --- lazy.nvim -return { - "julienvincent/nvim-paredit", - config = function() - require("nvim-paredit").setup() - end -} -``` - -```fennel -;; lazy.nvim with Fennel -{1 :julienvincent/nvim-paredit - :config (fn [] - ((. (require :nvim-paredit) :setup)))} -``` - -`nvim-paredit` 基于 Tree-sitter[^2],如果你发现后面提到的快捷键用不了,可能需要手动启动 Tree-sitter: - -```lua --- init.lua -vim.api.nvim_create_autocmd("FileType", { - pattern = {"clojure"}, - callback = function () - vim.treesitter.start() - end -}) -``` - -```fennel -;; init.fnl -(vim.api.nvim_create_autocmd - "FileType" {"pattern" ["clojure"] - "callback" (lambda [] (vim.treesitter.start))}) -``` - -Paredit 的各种操作解释起来有点困难,有个[动画演示教程](http://danmidwood.com/content/2014/11/21/animated-paredit.html),不过是英文的,而且用的是 Emacs 的快捷键。我就讲一些我常用的快捷键,剩下的请读者自行探索。 - -首先是 Slurp 和 Barf,这是两个拟声词,分别是吞咽食物的声音和打嗝的声音,可以理解为「吞」和「吐」。这两个操作都有方向,可以向前吞,或向后吞。吞和吐的对象是列表里的元素。 - -```clojure -;; 原本的代码 -(list some) elements - -;; slurp forwards(向前吞)之后的代码 -(list some elements) - -;; barf backwards(向后吐)之后的代码 -list (some elements) - -;; slurp backwards(向后吞)之后的代码 -(list some elements) - -;; barf forwards(向前吐)之后的代码 -(list some) elements -``` - -前和后的区分可能有点反直觉,用英文的 *forwards* 或者用「前进」这个词来理解可能会清晰一点。代码前进的方向就是一直往后写的方向,所以向前吐是往代码右边吐参数,而向后吞是把代码左边的形式放进第一个元素的位置。`nvim-paredit` 中,向前吞的快捷键是 `>)`,向后吞的快捷键是 `<(`;向前吐的快捷键是 `<)`,向后吐的快捷键是 `>(`。说实话有点难记,多练练就好,或者改成别的键。 - -也很常用且相对好理解的是 Drag(拖拽),按下 `>e` 可以把当前元素往前拖,`f,把当前形式往后拖 -(-(+ a b) c) -``` - -如果熟练掌握结构化编辑,编辑 Lisp 代码的速度会超过编辑一般代码的速度,不过就像刚从全拼转成双拼或者别的输入法时会觉得自己不会打字了一样,paredit 的快捷键需要一段时间适应。 - -除了编辑,可能还需要一些快速移动的按键: - -1. 默认的 Vim 和 Neovim,在 NORMAL 模式里按下 `w`(word)会跳转到下一个词的开头,按下 `e`(end)会跳转到下一个词的末尾,按下 `b`(back)会返回上一个词;装了 paredit 之后,用 `W`、`E`、`B` 可以分别跳转到下一个元素的开头、末尾,和上一个元素的开头。 -2. 在 VISUAL 模式中,按下 `af`(或者连起来:`vaf`,其中 `v` 是切换到 VISUAL 模式),可以选中当前形式(包含括号);如果换成 `if`,就只会选中形式内部的元素,不包含外部的括号。把 `f`(form)换成 `e`(element)可以选中元素,换成 `F` 可以选中顶层的形式而非当前形式。 - -![](https://img.eltr.ac/geedeapro/article/tutorial-clj-1/paredit-vaf.png "用 `af`(around form)选中一整个形式") - -如果不能马上掌握也不必气馁,接触新的编辑方式就是很难。即便不用 Paredit,也能写 Lisp 代码,不过能学会的话会舒服很多。 - ---- - -## 最后 - -好啦,希望你已经能够在电脑上运行 Clojure 程序,并且没那么害怕用一大堆括号编程了。 - -下一篇文章将重点关注形式(form)的诸多种类:函数(function form)、宏(macro form)和特殊形式(special form)。除了基本的整数、浮点数和字符串,我们还会见到符号(symbol)类型。 - - -[^1]: Conjure 还支持 Python、JavaScript、PHP、R、Lua、Rust 等语言。 -[^2]: 如果你不知道 Tree-sitter 是什么,其实大部分编辑器都有这个东西。编程语言一般都有自己的 Tree-sitter,用来把某门语言的代码解析成分析用的 AST,可以分析代码结构,最常用来做代码高亮。 diff --git a/content/article/tutorial-clj-2.md b/content/article/tutorial-clj-2.md deleted file mode 100644 index 3bfd5983..00000000 --- a/content/article/tutorial-clj-2.md +++ /dev/null @@ -1,359 +0,0 @@ -+++ -title = 'Coffee Break Clojure, Vol.2' -subtitle = "Forms O' Plenty" -slug = 'tutorial-clj-2' -tags = ["Lisp","编程"] -categories = '茶歇蔻芥' -date = 2026-09-25 18:43:53 -draft = true -toc = true -+++ - -[上一篇](http://localhost:1313/article/tutorial-clj-1/)我们配置好了 REPL,可以随时开始编写 Clojure 代码并执行。[第零篇](/article/tutorial-clj-0/)提到,被求值(Eval)的列表叫作形式(form),形式的第一个元素是操作符,剩余的是参数。根据操作符的不同,形式被分为函数形式、Lambda 形式、宏形式和特殊形式。今天的文章就来介绍这几种形式。 - -## 函数形式 - -不难理解,函数形式就是操作符为函数的形式,比如这个 `println` 形式: - -```clojure -(println "Hello World") -``` - -我们知道 `"Hello World"` 是字符串,那 `println` 是什么类型?在其他语言里我们可能很少考虑函数名本身的类型,但别忘了 Lisp 的「代码即数据」本质。要把待求值的形式当作数据而非形式处理,只需要把它 `quote` 起来。 - -这里要引入 Clojure 的 `type` 函数,它返回输入值的类型。我们不妨把刚才的形式 `quote` 起来,然后获取他的第一个元素 `println`,看看它是什么类型。 - -```clojure -(type (first '(println "Hello World"))) -;; => clojure.lang.Symbol -``` - -类型是 `Symbol`(符号)!这是 Lisp 中的一种特殊数据类型,和其他的原子不同,符号不是自求值(self-evaluating)的。意思是说,对 `"string"` 求值得到的是 `"string"` 本身,但对符号求值得到的是符号指向的其他值。 - -要定义一个符号很简单,只需要用到 `def`。 - -```clojure -(def my-symbol 123) -;; => #'user/my-symbol - -123 -;; => 123 - -my-symbol -;; => 123 -``` - -可见符号和它指向的数据本身是等价的,对 `123` 求值和对 `my-symbol` 求值得到的结果都是 `123`。 - -接下来试试让符号指向函数: - -```clojure -(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` 本身也是个函数吗? - -```clojure -((eval 'my-fn) 1 2) -;; => 3 -``` - -之前我们提到对 `(eval (quote x))` 求值和对 `x` 本身求值是等价的,这里也成立。对 `my-fn` 求值返回的是个符号,对 `(eval 'my-fn)` 求值得到的也是个符号。只要形式的第一个元素是指向函数的符号,就能完成函数调用。 - -根据条件返回不同类型的符号也是可以的: - -```clojure -(def a 1) -;; => #'user/a -((if (> a 1) + -) 2 1) -;; => 1 -``` - -好了好了,没人会这么写程序的。你现在知道,==函数形式就是操作符为指向函数的符号的形式==,对函数形式求值就是调用函数。还记得刚才让一个符号指向一个值的 `def` 吗?如果要用来声明函数,还有更简便的写法: - -```clojure -(def my-fn - (fn [a b] (+ a b))) - -;; 等价于 -(defn my-fn [a b] - (+ a b)) -``` - -写 Clojure 函数的时候你多半只会用到 `defn`,但不要忘了函数名和变量名一样,都只是符号而已。 - -记住这个原理你能做到很多有趣的事情。比方说,在其他语言里我们经常对值做一些操作,然后赋值给变量,就像这样: - -```clojure -(def a (reduce + '(1 2 3))) -;; => #'user/a - -a -;; => 6 -``` - -我们对列表 `(1 2 3)` 做了累加操作,变成了 `6`,然后赋值给 `a`。函数也可以这么干,比如 `partial` 就是对函数的操作,可以让我们提前填充函数的某些参数,然后返回新的函数: - -```clojure -(def plus-one (partial + 1)) -;; => #'user/plus-one - -(plus-one 3) -;; => 4 - -((partial + 1) 3) -;; => 4 -``` - -我们对函数 `+` 做了 `partial` 操作,给它提前填上了一个参数 `1`。调用得到的新函数就相当于调用 `(+ 1 ...)`。在其他语言里,要做到类似的事情,你可能要这么写: - -```clojure -(defn plus-one [num] - (+ 1 num)) -``` - -## Lambda 形式 - -Lambda 形式类似 JavaScript 里的立即执行函数,也就是定义一个匿名函数,然后立马执行它,这样就可以限定变量的作用域。 - -```js -(function (a, b) { - let c = a + b - return c -})(1, 2) -// => 3 -``` - -如果是 Clojure,就要这么写: - -```clojure -((fn [a b] (+ a b)) 1 2) -;; => 3 -``` - -Lambda 形式的操作符不是指向函数的符号,就是函数本身。 - -在 Scheme(另一门 Lisp 方言)里要这么写: - -```scheme -((lambda (a b) (+ a b)) 1 2) -;; => 3 -``` - -在 Scheme 里,这种写法有个语法糖,叫 `let`: - -```scheme -(let ((a 1) (b 2)) - (+ a b)) -``` - -没错,这和 JavaScript 和 Rust 等语言用于声明变量的 `let` 很类似,但 Scheme 中 `let` 仅仅是立即执行函数的语法糖。不过,在 Clojure 中,`let` 是个特殊形式,有专门的[解析器](https://github.com/clojure/clojure/blob/07adae6d24a0efcb4e6b39577c473b61df9d5f5b/src/jvm/clojure/lang/Compiler.java#L6902),我会在后文展开。 - -## 宏形式 - -前面我一直在用加减法做例子,现在不妨来看看逻辑运算。Clojure 中表示与、或、非运算的操作符分别是 `and`、`or` 和 `not`。其中 `not` 是个非常简单的函数: - -```clojure -(defn not [x] - (if x false true)) -;; 如果 x 为真,返回 false -;; 如果 x 为假,返回 true -``` - -但 `and` 和 `or` 不可以是函数,必须是宏。为什么?我们不妨假设他们是函数,看看会发生什么。 - -```clojure -(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` 这个宏的定义: - -```clojure - -(defmacro or - ([] nil) - ([x] x) - ([x & next] - `(let [or# ~x] - (if or# or# (or ~@next))))) -``` - -没有参数时n展开为 `nil`,只有一个参数时展开为这个参数本身,有多个参数时会被展开为嵌套在 `let` 里的 `if`,我们看到宏还能像函数一样递归,因为 `or` 的定义包含了 `or` 本身。 - -这看起来有点难以理解,我们不妨手动展开一下: - -```clojure -(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` 是个函数,下面这段表达式会变成什么样子? - -```clojure -(fn [a b c] - (println a b c) - (+ a b c)) -``` - -
- 如果 `fn` 是函数的解释 - -首先对 `[a b c]` 求值,解释器会发现 `a` 这个符号不存在,然后报错。 - -
- -关于怎么自定义宏,就放在之后的文章再讲了。 - -## 特殊形式 - -前面提到 `if` 和 `let` 都是特殊形式,可以理解为其他语言里的保留字。这些符号有特殊的解释规则,或者编译规则。==特殊形式就是由编译器和解释器实现的形式==。函数和宏都是用户定义的,只不过有的定义在标准库里。 - -有趣的是,Clojure 的 `let` 最外层其实是个宏,而 `let*` 才是特殊形式,只不过 `let` 最终会被展开为 `let*`。 - -```clojure -(macroexpand '(let [a 1 b 1] (+ a b))) -;; => (let* [a 1 b 1] (+ a b)) -``` - -看起来 `let` 被展开之后仅仅是把 `let` 替换成了 `let*`,为什么要套一层宏呢? - -这次我们故意把 `let` 写错,看看会发生什么。 - -```clojure -(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` 里写的符号绑定应该是偶数个。这个语法检查并不是编译器实现的,而是宏定义的: - -```clojure -(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 也有解构: - -```js -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` 解构的例子: - -```clojure -(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` 形式做宏展开,就会得到: - -```clojure -(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*` 的作用不同,比方说下面这段代码是有问题的: - -```lisp -(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*`: - -```lisp -(let* ((a 1) (b 2) (c (+ a b))) - (+ a b c)) -; 6 -``` - -《[A Scheme Primer](https://files.spritely.institute/papers/scheme-primer.html#fn.more-lets)》里写到,如果 Scheme 的规范要从头开始指定,最好要把 `let`、`let*` 还有 `letrec`(允许递归引用符号自己的 `let`)都合成同一个。Clojure 作为较新的一门 Lisp 方言,少了很多历史遗留问题。 - ---- - -## 最后 - -现在你应该知道求值 Clojure 形式时,解释器底下大概都发生了什么,你应该也熟悉了一些基本的函数、宏和特殊形式,比如 `let` `if` 和 `fn`,可以写一些简单的 Clojure 程序了。 - -其实除了本文提到的四种形式,Clojure 还可以用特定类型的数据作为操作符,也可以当作一种类型的形式。这要留到下篇讲了。 - -下一篇我们要了解 Clojure 里的集合数据类型,分别是我们已经见过的列表(list)和向量(vector),此外还有集合(set)和映射(map)。我们还会讲到如何操作这些集合,包括遍历和筛选等等。我们还会了解到惰性序列(Lazy sequence),然后你就可以写程序处理相对复杂的数据,并且能够到同时做到语法简洁和高性能。这些都是后话了。 - -回见! - diff --git a/content/article/tutorial-clj-3.md b/content/article/tutorial-clj-3.md deleted file mode 100644 index 237d8527..00000000 --- a/content/article/tutorial-clj-3.md +++ /dev/null @@ -1,258 +0,0 @@ -+++ -title = 'Coffee Break Clojure, Vol.3' -subtitle = '只用圆括号怎么够?' -slug = 'tutorial-clj-3' -tags = ["Lisp","编程"] -categories = '茶歇蔻芥' -date = 2026-09-25T20:17:41+08:00 -draft = true -toc = true -+++ - -[上一篇](/article/tutorial-clj-2)我们讨论了不同类型的形式,了解了函数、Lambda、宏和特殊形式的皮毛,也学会了用一些基本的运算符、`let` 和 `if` 等等。今天的文章要讨论集合数据类型。不过在此之前,我们还是先看看普通的数据类型有哪些吗? - -## 你已经知道和你可能不知道的 - -和大部分编程语言一样,Clojure 支持数字、浮点数、字符串和布尔值。 - -由于 Clojure 是运行在 JVM 上的,所以 Java 这些数据类型实际上对应一个 Java 类。数字是 `Long` 而不是 `Int`,前者能表示的最大数是 2 的 63 次方减 1,也就是有 64 位,第一位是符号位,后者是 32 位。和 Java 一样,Clojure 没有无符号数(unsigned int)。 - -Clojure 还能在这些类型上调用 Java 的方法,比如 `String.getBytes()`。 - -```clojure -(seq (.getBytes "Hello")) -;; => (72 101 108 108 111) -(type (first (seq (.getBytes "Hello")))) -;; => java.lang.Byte -``` - -Clojure 也没有用更小的 `Float`,也是用了双精度浮点数 `Double`。 - -```clojure -(type 1.1) -;; => java.lang.Double -``` - -Clojure 是门动态类型语言,一般不需要考虑太多类型问题,不过知道这些类型底层是什么样的也挺好。 - -值得注意的是 Clojure 还有个很常用的自定义类型,叫作关键词(keyword)。 - -```clojure -(type :keyword) -;; => clojure.lang.Keyword -``` - -关键词类似字符串,不过是以 `:` 开头,中间不能有空格。其实他更像其他语言里的枚举类型(enums),但无需提前定义。关键词不能像字符串一样做裁剪、找子串、当作字符数组遍历之类的操作,就是一个整体。 - -关键词可以用在大部分需要枚举类型的场合,也用来表示映射(map)里的键(key)。 - -其他的非集合类型还有我们上一篇讨论过的符号(Symbol)。 - -## 圆括号和列表 - -我们在之前就见过列表(list),最普通的集合数据类型。 - -```clojure -(list "string" 1 :item true) -;; or -'("string" 1 :item true) -``` - -和许多动态类型语言一样,列表可以包含不同类型的数据。 - -注意,上面这个例子中两种创建列表的方式有区别,我在[第零篇](/article/tutorial-clj-0)留了个习题,现在公布答案: - -```clojure -(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`。 - -```lisp -(cons 42 (cons 69 (cons 613 nil))) -;; 等价于 -(list 42 69 613) -``` - -![](https://img.eltr.ac/geedeapro/article/common-lisp-start/Cons-cells.svg.png) -{.dark:invert} - -虽然 Clojure 的 `cons` 也可以用相同的方式构造列表,但 *cons* 对却不存在。`cons` 的第二个函数只能是序列,不能是原子。 - -```clojure -(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` 只被用来「把数据放进列表的前面」。 - -```clojure -(cons 1 '(2 3)) -;; => (1 2 3) -``` - -Clojure 的列表叫作持久化列表(PersistentList),Clojure 的所有集合数据类型几乎都是持久化(Persistent)且不可变(Immutable)的。 - -```clojure -(type '(1)) -;; => clojure.lang.PersistentList -``` - -不可变很好理解,对数据做操作只会产生新的数据,而不会修改原来的数据,原来的数据还保存在内存的同一个地方。那什么是[持久化](https://zh.wikipedia.org/wiki/%E5%8F%AF%E6%8C%81%E4%B9%85%E5%8C%96%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84)呢?意思是数据被修改后,历史的版本还保留着并且还**能被访问**。 - -不妨看看 Clojure 是怎么实现 `PersistentList` 的: - -```java -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()` 的: - -```java -public void add(int index, Object element){ - throw new UnsupportedOperationException(); -} -``` - -嗯?直接抛出异常了? - -令人费解,而且有违反里氏替换原则的嫌疑。仔细想想,Clojure 的确没有给列表直接添加元素的方法,我们用的是 `cons` 和 `conj`(后者与 `cons` 相反,是在序列的尾部添加元素)。那 `cons` 是怎么实现的? - -```java -public Cons(Object first, ISeq _more){ - this._first = first; - this._more = _more; -} -``` - -这下就清晰了,求值 `(cons 1 '(2 3))` 得到 `(1 2 3)`,原来的 `1` 和 `(2 3)` 都留在原地,我们只是创建了一个 `Cons`,指向那两个值,而 `Cons` 本身也是个序列,可以再用它创建新的 `Cons` 或者其他序列(`Cons` 是 `ASeq` 的子类,也就实现了 `ISeq`,序列接口,`PersistentList` 也是序列)。 - -这种构造持久化列表的方式叫作结构共享(Structural Sharing),既做到了不可变,又没有在内存中管理大量的数据副本。本质上,一切都是指针。 - -由于不可变,所以列表的大小可以作为不变的属性保存起来,回去看 `PersistentList` 的 Java 代码,`_first` 和 `_rest` 下面就是 `_count`。Clojure 的所有集合数据类型都能用 `count` 函数获取其长度,并且这个算法的时间复杂度是 *O(1)*,因为只需要 `return _count`。此外,有很多操作都因为结构共享变得非常高效,Clojure 的性能在 Lisp 中算很好的那一类。 - -至于不可变还有什么好处,我们之后谈函数式编程再提。 - -## 方括号和向量 - -理解一门 Lisp 中的列表如何构造,可以说就理解了它的核心。我们知道列表是持久化且不可变的,由 `Cons` 和指向其他 `Cons` 的「指针」构成。 - -向量基本上和列表一致,但向量中所有元素都有索引(index),这点和 JavaScript 的数组很像,写起来也和 JavaScript 数组很像,也可以存放不同类型的数据。 - -```clojure -[1 "I'm a vector" :vector true] -``` - -列表和向量都可以用 `first`、`rest` 和 `cons` 函数。 - -```clojure -(first [1 2 3 4]) ;; => 1 -(first '(1 2 3 4)) ;; => 1 - -(rest [1 2 3 4]) ;; => (2 3 4) -(rest '(1 2 3 4)) ;; => (2 3 4) - -(cons 1 [2 3 4]) ;; => (1 2 3 4) -(cons 1 '(2 3 4)) ;; => (1 2 3 4) - -;; 当然还支持其他操作序列的函数 -;; 列表和向量都可以通用 -``` - -你可能发现执行了序列操作之后,向量的方括号变成了圆括号。不必担心,它实际上还是向量,行为是一致的。 - -```clojure -(type (rest [1 2 3 4])) -;; => clojure.lang.PersistentVector$ChunkedSeq -``` - -向量和列表最大的不同点在于,可以用索引访问向量的元素,但列表没有索引。 - -```clojure -(get [1 2 3 4] 2) ;; => 3 -(get '(1 2 3 4) 2) ;; => nil -``` - -向量和列表的另一个区别,尽管他们都是连续的(sequential),但只有列表是序列(sequence)。用 `seq?` 检查数据是不是序列的时候,向量会返回 `false`。 - -```clojure -(seq? '(1 2)) ;; => true -(seq? [1 2]) ;;=> false -``` - -如果希望向量也返回 `true`,就要检查一个集合有没有实现 `Sequential` 接口。 - -```clojure -(sequential? '(1 2)) ;; => true -(sequential? [1 2]) ;; => true -``` - -有意思的是,把 `seq?` 的问号去掉,你也能让向量返回一个真值。 - -```clojure -(if (seq [1 2]) - "I am a seq" - "I am not a seq") -;; => "I am a seq" -``` - -`seq` 的作用是把可以转换为序列的数据结构转换为序列,字符串也可以转换成序列。 - -```clojure -(seq "string") -;; => (\s \t \r \i \n \g) -;; \ 用来表示 Clojure 中的字符 -``` - -集合只要不是空的,都会返回一个正常的序列,会被条件判断当作真值。因此 `seq` 经常被用来检查一个集合是否不为空;相反,`emtpy?` 用来检查一个集合是否为空。 - -序列(sequence)是只支持线性访问,不支持随机访问的集合,可以当作列表理解。序列的行为和列表几乎是一致的,它也没有索引。 - -```clojure -(get (seq [1 2]) 1) -;; => nil -``` - -最后,向量其实还继承了 `AFn` 类,可以当成函数使用。 - -```clojure -([1 2 3] 0) ;; => 1 -;; 等价于: -(get [1 2 3] 0) ;; => 1 -``` - -这样就省掉了写 `get` 的工夫。 - -## 花括号和映射 - -Clojure 的映射写起来和 JSON 很像。 - -```clojure -{:key1 "val1" - :key2 {:subkey1 2 - :subkey2 [1 2 3]} - :key3 nil} -``` - - - -## 井号和集合 - -[^1]: 可参考:《[Just A Common Lisper](/article/common-lisp-start/)》 -- cgit v1.2.3