diff options
| author | eltrac <hi@eltr.ac> | 2026-03-18 00:22:28 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-03-18 00:22:28 +0800 |
| commit | f6b0cfaa4976b2401b363fd540dd375c4392ecc7 (patch) | |
| tree | fa0cdc9e6e9284cff98346228c856950508d950f /content/article | |
| parent | b58b01d6d266c3a3aab491a0e562bcfb2c80c4ab (diff) | |
docs: publish /article/compute-infinity
Diffstat (limited to 'content/article')
| -rw-r--r-- | content/article/compute-infinity.md | 111 |
1 files changed, 95 insertions, 16 deletions
diff --git a/content/article/compute-infinity.md b/content/article/compute-infinity.md index bfe667ed..cdf050a9 100644 --- a/content/article/compute-infinity.md +++ b/content/article/compute-infinity.md @@ -1,15 +1,15 @@ +++ title = '计算无穷' subtitle = 'Clojure 中的惰性序列' -slug = '' -tags = ['Lisp'] +slug = 'compute-infinity' +tags = ['Lisp','编程'] categories = '代码炼金术' -date = 2026-03-17T20:01:03+08:00 -draft = true +date = 2026-03-18 00:22:11 +draft = false toc = true +++ -由于计算机算法的特性之一是有穷性,一切能被计算机计算的东西都必须是能够在优先步骤内完成的,所以,计算机大概是不能够应对无穷长的数据的。 +由于计算机算法的特性之一是有穷性,一切能被计算机计算的东西都必须是能够在有限步骤内完成的,所以,计算机不能够应对无穷长的数据。 真的是这样吗?<!--more--> @@ -40,7 +40,9 @@ func parseMarkdown(raw string) string { 这种做法看起来并不少见,所以能否简化呢?有没有办法让我们不需要把数据放进变量里,再取出来,再传给下一个参数,而是能够直接把所有的操作都串起来呢? -用 thread-first 和 thread-last 就能做到。 +而且,我们知道,Lisp 语言一般遵从函数式编程范式,这种范式一般要求变量是不可变的,对赋值有着极为苛刻的要求,在 Lisp 语言里用这么多变量是不现实的,尽管能够用不太优雅的方式做到。 + +用 thread-first 和 thread-last 这两个宏就能解决上述问题。 ```clojure (defn parseMarkdown [raw] @@ -56,13 +58,25 @@ func parseMarkdown(raw string) string { cat foo.txt | grep bar ``` -使用 `->` 可以让代码变得简洁,比如,假如你想要读取一个 JSON 文件的内容,这个文件不大,你想直接加载到内存里并直接转换为 Clojure 的数据结构,就可以这样写:`(-> filename slurp json/read-string)`。 +使用 `->` 可以让代码变得简洁。假如你想要读取一个 JSON 文件的内容,这个文件不大,你想直接加载到内存里并直接转换为 Clojure 的数据结构,就可以这样写:`(-> filename slurp json/read-string)`。 + +thread-first 和 thread-last 的区别是什么呢?刚刚我们演示的函数都只接受一个参数,而 first 或 last 指的是把上一个函数运算的结果传给下一个函数的第一个参数或最后一个参数,所以并没有体现出区别。 + +假设我们要操作的数据是列表,而操作这类数据的函数往往在最后一个参数的位置接收列表(比如 `(map f coll)` 的作用是对 `coll` 中的每一个元素执行 `f` 操作,然后返回一个惰性序列,`coll` 就是最后一个参数)。如果使用 thread-first,也就是 `->` 宏,列表就会作为第一个参数传入,也就是被当成函数 `f` 而非 `coll`。 -thread-first 和 thread-last 的区别是什么呢?刚刚我们演示的函数都只接受一个参数,而 first 和 last 指的是把上一个函数运算的结果传给下一个函数的第一个参数还是最后一个参数,所以并没有体现出区别。 +箭头宏还有另外的版本,`cond->` 和 `cond->>`,这里的 `cond` 是 condition(条件),你可以根据条件判断要不要执行这条流水线里的某一环。 + +```clojure +(cond-> filename + true slurp ;; 由于条件为 true,所以总是会被执行 + json? json/read-string ;; 如果是 json,才会被解析 + json? :body ;; 如果是 json,才会尝试获取 body + true println) +``` -假设我们要操作的数据是集合,比如列表和向量,而操作这类数据的函数往往在最后一个参数的位置接收集合(比如 `(map f coll)` 的作用是对集合 `coll` 中的每一个元素执行 `f` 操作,然后返回一个惰性序列,集合 `coll` 就是最后一个参数),如果使用 thread-first,也就是 `->` 宏,集合就会我传入第一个参数,也就是被当成函数 `f` 而非 `coll`。 +箭头宏的用处很多,最常见的场景之一是用来处理列表,毕竟,Lisp 就是列表处理语言(List Processing)啊! -假设我们有一个装满了狗狗的向量,我们要从所有的狗狗中找出听话的好狗狗,并给好狗狗每只狗一块零食吃,就可以这样写:[^1] +假设我们有一个装满了狗狗的向量,我们要从所有的狗狗中找出听话的好狗狗,并给好狗狗每只狗一块零食吃。[^1] ```clojure (defn treat-good-dogs [dogs] @@ -91,8 +105,8 @@ thread-first 和 thread-last 的区别是什么呢?刚刚我们演示的函数 ```clojure (->> dogs - (filter :good) - (map give-treat)) + (filter :good) + (map give-treat)) ``` 不会,因为 `filter` 和 `map` 返回的是惰性序列(Lazy sequence)。 @@ -109,10 +123,10 @@ thread-first 和 thread-last 的区别是什么呢?刚刚我们演示的函数 ```clojure (->> dogs - (filter :good) - (map give-treat) - (take n) - doall) + (filter :good) + (map give-treat) + (take n) + doall) ``` 上面这段代码的意思是: @@ -125,4 +139,69 @@ thread-first 和 thread-last 的区别是什么呢?刚刚我们演示的函数 ## 实践惰性 +你可能会以为,`dogs` 为无穷长的序列只是个假设,如果真的有无穷长的序列,Clojure 也是处理不了的。 + +实际上 Clojure 真的可以生成无穷长的惰性序列。[range](https://clojuredocs.org/clojure.core/range) 函数一般接收一到两个参数,生成一段由连续整数组成的惰性序列,例如执行 `(range 3)` 会得到 `(0 1 2)`,执行 `(range 1 3)` 会得到 `(1 2)`。如果不传入任何参数,也就是不限定边界,就会得到一个无穷长的惰性序列。 + +读《Living Clojure》的时候,读到作者写了这样一句话: + +> 不要运算它,否则会使你的 REPL[^2] 崩溃 + +于是我就这么做了。 + +` 的后果") + +Clojure 显然是陷入了每个程序员都遇到过的无限循环,这是怎么发生的?首先,我输入了 `(range)`,构造了一个无穷的惰性序列,然后 REPL 读取了这个惰性序列,并想立刻求出这个惰性序列的值,于是惰性序列中的元素就被运算了,运算之后被打印了出来,就造就了图中的局面。 + +我们可以优雅地从无穷里取出有限个元素,来避免无限循环。 + +```clojure +(take 5 (range)) +;; => (0 1 2 3 4) +``` + +显然,我们并不会遇到规模为无穷的问题,否则就算有能力处理无穷长的惰性序列,在实际运算这个惰性序列时,也会因为陷入无限循环而无法继续执行程序。 + +不过,这不妨碍我们利用惰性序列优雅地处理大规模的问题。 + +假设有一个几万行的 JSONL 文件,也就是说有好几万条 JSON 数据被存储在这个文件里,每一行都是有效的 JSON 对象。我们知道 I/O 操作开销很大,把这么大的数据整个读取到内存里也极其消耗资源,更别提还要把 JSON 字符串解析为可以直接操作的数据结构,为了保证程序不会太低效,必须采取一些手段。 + +惰性序列就是很好的解决方案。 + +```clojure +(with-open [rdr (io/reader "very-large-file.jsonl")] + (let [lines (line-seq rdr)] ;; line-seq 返回包含所有行的惰性序列 + ;; 它接收的数据类型是 java.io.BufferedReader + (count lines))) ;; 返回文件行数,但没有真的读取文件内容 +``` + +你也可以用 `filter`、`map` 和 `reduce` 等函数直接操作惰性序列,它们接收惰性序列,返回的也是惰性序列,只要不执行 `doall` 或 `dorun`,或者用其他函数读取其中的值,它们就永远不会被计算。 + +如果你打算遍历这个文件的所有行,只有被遍历到的行才会被读取,余下的行仍然是惰性的。换句话说,如果你在惰性序列里寻找一个符合特定条件的元素,比如包含特定字符串的行,而符合条件的元素恰好就是第一个元素,那么整个遍历操作实际上只会读取文件的第一行。 + +如此优雅的操作,只需要三行代码! + +这就是把一切都变成列表的好处。除了用于筛选的 `filter`、用于创建映射的 `map` 和用于累计计算所有元素的 `reduce`,还有许多可以用来操作列表的工具,比如 `remove` 可以移除列表中符合条件的元素,`flatten` 可以打平任意嵌套的列表,`into` 可以把一个列表的元素全部放进另一个列表,`partition` 可以把一个列表分割成相等长度的多个列表。 + +你可能还发现上面的许多函数都还涉及判断列表元素是否符合某个条件,在其他语言里你可能见过类似的做法,比如给排序函数传入一个函数作为参数,用来比较谁应该放在前、谁应该放在后,这种函数一般叫谓词函数(predicate)。 + +Clojure 里有很多好用的谓词函数。比如 `nil?` 可以判断一个值是 `nil` 还是不是,如果要移除列表里所有的 `nil`,就可以这样写 `(remove nil? coll)`。我也很喜欢 `complement` 谓词,它的意思是「补集」,比如 `nil?` 的补集 `(complement nil?)` 就是判断一个值不是 `nil` 的谓词,如果不是 `nil` 就返回 `true`。刚才写的 `(remove nil? coll)` 也可以写作 `(filter (complement nil?) coll)`。 + +你也可以用匿名函数作为谓词,写法是 `(fn [] ...)`,简便写法是 `#(...)`,在简便写法中可以使用 `%` 来表示参数,如果有多个参数,就写作 `%1` `%2`...... 上面写的用来在列表中移除 `nil` 值的函数,还可以这样写:`(filter #(not (nil? %)) coll)`。 + +利用好这些谓词函数和操作列表的函数来操作惰性序列,在最后才读取惰性序列中的值,就能用最简短的方式写出高性能的代码,~~虽然跑在 JVM 上性能也高不到哪里去就是了。~~ + +## 最后 + +本文是学习 Clojure 约莫一个月时间,在一个月内连连感到大脑被击穿,在人脑代码解析器过载和好奇心被狠狠满足之间反复横跳后产生的东西。 + +到目前为止,对 Clojure 唯一的不满就是:我写出来的东西只能编译成 `.jar` 文件,用 Java 运行环境来执行,因为这是一门运行在 Java 虚拟机上的语言。虽然能偷 Java 的库来用,但也不可避免地继承了 Java 的臃肿,比如 [weepinbell](https://codeberg.org/eltrac/weepinbell) 编译之后竟然有 170+ MB 大小,实在是太让人不爽了。 + +唉,美味中拌了点垃圾也将就吃吧。 + + + [^1]: 假设「好狗狗」的数据结构长这样 `{:good true}`,这是 Clojure 中映射的写法,可以用关键词作为操作取出其映射的值,比如 `(:good {:good true})` 就会返回 `true`。 + +[^2]: REPL:Read Eval Print Loop(读取-求值-打印-循环),是与 Clojure 交互的最简单方式,基本上就是一个接收代码然后运算,再把结果打印出来的程序。 + |
