December 12, 2023
By: Kevin

Clojure 的致命问题

  1. 什么是惰性?
  2. Clojure 中的惰性
  3. 惰性的优点 👍
    1. 避免不必要的工作
    2. 无限序列
    3. 仿佛拥有无限内存
    4. 统一的序列 API
  4. 惰性的缺点 👎
    1. 错误处理
    2. 动态绑定
    3. 资源释放
    4. 对于"大数据"处理的脆弱性
    5. 大/无限序列是一个火药桶
    6. 令人困惑的副作用
    7. 复杂的基准测试和性能分析
    8. 使用序列 API 的低效迭代
    9. 性能开销
    10. 无法强制所有东西
    11. 分块是不可预测的
    12. 重复的函数
    13. REPL 和最终程序之间的不匹配
    14. fordoseq 的巨大字节码足迹
  5. 如何与之共存 🤔
    1. 脚注

本文旨在探讨 Clojure 中的惰性(laziness). 希望能对惰性序列这一特性进行一次全面且尽可能客观的批判. 我绝无意评判将 Clojure 设计为惰性的那个决定.

Clojure 这门语言不是按部就班的产物; 它的创造过程包含了无数影响深远的选择. 从 Clojure 的长盛不衰我们可以判断, 其整体设计是成功的.

本文也更意批评 Clojure 背后的人们. 事后回顾总是清晰的(Hindsight doesn't need glasses); 创造一门语言(更不用说一门成功的语言)极其困难, 而挑剔其 所谓的缺点却轻而易举.

本文目标是统一 Clojure 社区对惰性的立场. 开发者们一次又一次地发现自己陷入惰性方法的复杂性中, 并将这些挣扎归咎于对 Clojure 之道(Clojure Way)[1] 的 理解不足.

我想证明, 构成 Clojure 之道的东西有很多, 而惰性不必是其决定性特征. 我希望程序员们能减少或消除对惰性的依赖, 并且不会因此感到内疚, 仅仅因为惰性自 Clojure 诞生之初就已根深蒂固.

写这篇文章是否有益, 只有时间能证明, 但我愿意一试. 另外, 抱歉用了个标题党, 这个标题太吸引人了, 实在忍不住.

什么是惰性?

惰性求值(Lazy evaluation), 也称为延迟执行(deferred execution), 是一种编程语言特性, 它将计算结果的产生推迟到该值被其他求值过程明确需要时为止.

可以从几个不同的角度来看待它, 它们都相似, 但每个角度都能带来新的见解:

  • 声明与执行在时间上的分离. 编写一个表达式并不会立即产生结果. 程序员必须意识到计算一个值需要两个阶段(这到底是好是坏, 我们稍后讨论).

  • 声明式 vs 命令式. 上述分离鼓励开发者更多地以声明式的方式思考程序. 程序不再是让计算机逐行执行的指令, 而更像一个灵活的"食谱". 这使得编程更接近数学, 在纸上写下一个公式并不会立即强迫你去解它. 对于编译器来说, 声明式方法开启了额外的优化可能性, 因为它可以自由地重排甚至消除某些执行步骤.

  • 程序作为一棵求值树. 当所有值都是惰性的并相互依赖时, 整个程序就变成了一棵等待执行的声明式树. 轻触树根--即入口点--这棵树就会递归地遍历自身, 从叶子节点向 上计算[2], 最终折叠成一个单一的结果. 而那些未与树根相连的叶子和分支则不会被求值.

  • 拉取 vs 推送. 从这个比喻出发, 惰性求值是一种拉取(pull)方法. 除非被明确请求(拉取), 否则不会产生任何东西.

在像 Haskell 这样普遍采用惰性的语言中, 每个表达式都会产生一个惰性值. 它甚至不会计算 2 + 3, 除非有其他东西需要这个结果. 根据" 程序作为一棵树"的理论, 很明显, "其他东西"最终必须以某种方式影响外部世界, 产生副作用--比如打印到屏幕, 写入文件等. 没有副作用的惰性程序, 就像一本无人照做的食谱.

惰性求值的原理在任何支持将任意代码块封装命名(匿名函数, 匿名类都行)的语言中都很容易模拟. 在 Clojure 中, 这可以是一个普通的 lambda 表达式或专门的 delay 构造:

(fn [] (+ 2 3)) ;; 极致的惰性

(delay (+ 2 3)) ;; 类似, 但结果只计算一次并缓存.

将惰性作为一种语言特性而非一种技术区分开来的是, 它对用户是自动且透明的. 消费一个值的代码不必知道这个值是否是惰性的, API 完全相同, 也无法分辨(实际上, 有时可以, 但很少有必要). 相比之下, delay 也代表一个延迟计算, 但必须用 @ 显式地解引用.

Clojure 中的惰性

虽然 Clojure 在多方面受到 Haskell 的启发, 但它对惰性的处理方式要务实得多. Clojure 中的惰性仅限于惰性序列(lazy sequences). 请注意, 我们不说"惰性集合", 因为 序列是唯一惰性的集合. 例如, 在 Clojure 中更新一个哈希映射(hashmap)是即时(eager)的, 而在 Haskell 中则是惰性的:

(assoc m :foo "bar") ;; 立即发生

开发者可以从几个来源获得惰性序列:

  1. 最常见的是序列处理函数, 如 map, filter, concat, take, partition 等. 这类函数是惰性友好的(它们能接受惰性序列且不强制求值), 并且自身也返回一个惰性序列(即使提供的集合不是惰性的).
  2. 产生无限序列的函数: iterate, repeat, range.
  3. 为通常有限的资源提供基于拉取 API 的函数: line-seq, file-seq.
  4. 低级别的惰性序列构造器: lazy-seq, lazy-cat. 这些在 clojure.core 命名空间之外很少使用, 它们是构建更高级别序列函数的基础.

让我们看一个涉及惰性序列的示例代码:

(let [seq1 (iterate inc 1)      ; 自然数的无限序列,
                                ; 显然是惰性的.

      seq2 (map #(* % 2) seq1)  ; 步长为2的等差数列, 仍然
                                ; 是无限的, 惰性的.

      seq3 (take 100 seq2)      ; 从前一个序列中取100个元素,
                                ; 是惰性的, 此时什么都还没发生.

      seq4 (map inc [1 2 3 4])  ; 结果是惰性的, 即使输入是
                                ; 一个已实现的(非惰性)向量.

      seq5 (concat seq3 seq4)]  ; 惰性输入和惰性结果.

  (vec seq5))                   ; 真正的工作从这里开始, 因为我们
                                ; 把序列转换成向量, 而向量
                                ; 不是惰性集合.

上面的例子展示了一些函数如何产生惰性序列, 一些函数消费它们并保持惰性, 而另一些函数(如 vec)则强制求值. 理解这一切需要一些时间.

Clojure 没有完全采用惰性的原因是惰性是昂贵的. 对于每一个延迟计算的值, 运行时都必须跟踪它, 并记住要执行的代码及其上下文(局部变量).

一个值在内部被替换为一个包含所有这些信息的包装器, 一个 thunk(Haskell 术语). Thunk 通常会引发额外的内存分配, 占用内存空间, 并引入 间接调用, 从而减慢程序执行速度. 这些低效问题有很多可以通过像 Haskell 那样的高级编译器来缓解.

但 Clojure 的设计倾向于一个简单, 直接的编译器, 所以在 Clojure 中完全采用惰性可能会导致性能问题.

但这还不够. 即使只对序列使用惰性, 为每个后继元素创建一个 thunk 的成本也非常高. 为了解决这个问题, Clojure 采用了一个叫做**序列分块(sequence chunking)**的概念.

简单来说, 这意味着" 惰性的单位" , 即一次性被实现的元素数量, 不是 1 而是 32. 这样做的好处是, 在处理大型集合时, 惰性机制的开销可以更好地分摊到每个元素上. 这里有一个分块行为的经典例子:

(let [seq1 (range 100)
      seq2 (map #(do (print % " ") (* % 2)) seq1)]
  (first seq2))

;; 打印输出:
;; 0  1  2  3  4  5  6  7  8  9  10  11  12  13  14  15  16
;; 17  18  19  20  21  22  23  24  25  26  27  28  29  30  31

map 步骤中加入了一个副作用, 以观察它操作了多少个元素. 在最后一步, 我们只请求惰性集合的第一个元素, 所以很自然地会认为 map 内部只会打印一个元素, 但实际上, 我们 看到了 32 个元素被打印出来.

这就是分块的效果, 因为当惰性集合用尽"已实现"的元素时, 它会一次性强制对下 32 个元素求值. 无论下一步需要 1 个, 5 个还是 32 个元素, 这 32 个元素都会被求值. 如果我们请求 33 个元素, 那么 64 个元素将被求值, 以此类推.

Clojure 中的惰性序列是带缓存的(cached), 这意味着延迟的值只会被计算一次. 后续访问将返回保存的结果, 而不会重新计算该值. 一个简短的演示:

(let [s (time (map #(Thread/sleep %) (range 0 200 10)))]
  (time (doall s))
  (time (doall s)))

;; Elapsed: 0.1287 msecs   - map 产生一个惰性序列, 此时什么都没发生
;; Elapsed: 1970.8 msecs   - doall 强制了求值
;; Elapsed: 0.0039 msecs   - 值已经计算完毕, 第二次不会重新求值

这使得惰性集合与不保留求值结果的普通 lambda 表达式有所不同, 而更接近于会保留结果的 delay.

接下来分析下惰性集合的优点和缺点.

惰性的优点 👍

避免不必要的工作

Clojure 中惰性序列的主要价值主张(以及其他语言中惰性的普遍价值)是只计算需要的东西. 你可以编写代码而无需预先考虑消费者稍后会需要多少结果. 这种控制反转允许我们编写这样的代码:

(defn parse-numbers-from-file [rdr]
  (->> (line-seq rdr)
       (map parse-long)))

(take 10 (parse-numbers-from-file (io/reader "some-file.txt")))

函数 parse-numbers-from-file 不需要知道最终会需要多少行, 也不必担心解析所有行是否浪费. 代码的写法就像它会解析所有内容一样, 而调用代码稍后会决定实际解析多少.

无限序列

我们无法用任何即时求值的集合来表示无限序列, 因为计算它需要无限的时间. 相反, 无限序列可以用其他方式表示--某种流式 API 或迭代器. 在 Clojure 的情况下, 惰性序列是无限集合的一个恰当抽象.

这成了一个很棒的" 派对戏法" --那种能让人惊叹" 哇" 并产生兴趣的语言特性[3]. 你写下 (iterate inc 1) 就能得到所有自然数, 这多 酷啊[4]? 而且由于大多数序列处理函数都是惰性友好的, 可以派生出新的无限序列, 这些序列可以一直保持无限, 直到程序要求某个有界的结果.

想创建一个无限的斐波那契数列吗? 没问题:

;; `iterate` 的 API 已经有些别扭了. 我们必须将每个项
;; 存储为两个数字的元组, 然后丢弃第二个数字. 这是因为
;; `iterate` 只让我们访问紧邻的前一个项.
(->> (iterate (fn [[a b]] [b (+ a b)]) [0 1])
     (map first)
     (drop 30) ;; 这步和下一步给我们序列的第30到40个元素.
     (take 10))

=> (832040 1346269 2178309 3524578 5702887 9227465 14930352 24157817 39088169 63245986)

仿佛拥有无限内存

因为惰性序列可以像流一样工作, 只在内存中保留序列的单个元素(或者说, 一个 32 项的块), 所以它可以用我们熟悉的序列处理函数来处理大文件或 网络负载, 而无需关注数据集的大小. 我们已经看过这个例子:

(->> (line-seq (io/reader "very-large-file.txt"))
     (map parse-long)
     (reduce +))

我们传给它的文件如果完全加载可能会超出内存, 但这不关我们的事, 也不会改变我们编写代码的方式. line-seq 返回文件中所有行的序列, 而 惰性确保了它们不会同时驻留在内存中. 这为开发者减少了一个需要考虑的障碍. 万一他们没考虑到这一点, 程序可能因为惰性而更健壮; 比如说, 开发 者只在小文件上测试了这样的代码, 而惰性确保了它在处理大文件时也能正确执行.

统一的序列 API

惰性序列, 非惰性序列, 无限序列, 大于内存的序列都可以用同一套函数来处理. Clojure 的设计者不必为每种子类型实现单独的函数; 使用者也不必 去学习它们. 这样一来, 语言核心更小, 潜在的 bug 也更少.

惰性的缺点 👎

在本节中, 我将列举那些要么是惰性固有的, 要么是 Clojure 实现中偶然产生的问题. 它们的顺序是任意的, 但对于每个问题, 我都会给出我个人对其严重性的看法.

许多问题源于这样一个事实: 要使惰性完全无缝, 它要求完全的引用透明性. 但 Clojure 是一门务实的语言, 允许在任何地方产生副作用, 并且不优先考虑用 纯函数包装器来替代有副作用的方法. 惰性序列与副作用相处得并不好, 我们将在本节中多次看到这一点.

错误处理

在 Haskell 中, 错误处理是通过特殊的返回值实现的, 这些值可能包含成功的结果或错误(例如 Either). 因此, 在 Haskell 中, 错误是常规求值流程的一等公民, 与语言的惰性不冲突.

然而, Clojure 使用 Java 基于栈的异常基础设施作为其主要的错误处理机制. 这是最务实的选择, 因为任何其他解决方案都需要重新包装底层 Java 代码可能抛出的所有异常.

这可能会产生巨大的性能影响. 此外, 在动态类型的 Clojure 中, 基于结果的错误处理不会像在静态类型语言中那样方便.

所以, 我们只能使用异常. 同时我们又有惰性序列. 会出什么问题呢? 思考下面这个我敢肯定 99% 的 Clojure 程序员都至少遇到过一次的 bug:

;; 我们需要生成一个数字列表 1/x.
(defn invert-seq [s]
  (map #(/ 1 %) s))

(invert-seq (range 10))
;; java.lang.ArithmeticException: Divide by zero

我们写了一个函数, 接受一个数字列表并生成一个 1/x 的数字列表. 一切都很好, 直到有人给我们一个包含零的序列. 我们想保护自己, 所以我们修复了函数:

(defn safe-invert-seq [s]
  (try (map #(/ 1 %) s)
       ;; 为举例方便, 如果遇到除零错误, 我们返回一个空列表.
       (catch ArithmeticException _ ())))

(safe-invert-seq (range 10))
;; java.lang.ArithmeticException: Divide by zero

结果是, 我们的修复并没有让函数变得更安全. 尽管看起来我们把危险的代码包装在了 try-catch 块中, 但里面的代码仅仅返回了一个惰性序列. 此时还没有什么 可 catch 的.

但当值从惰性集合中被"拉取"时, 映射代码早已离开了 try-catch 块, 抛出的异常导致程序崩溃. 要真正修复这个例子, 我们必须在每次 map 迭代内部捕获异常:

(defn really-safe-invert-seq [s]
  (map #(try (/ 1 %)
             (catch ArithmeticException _ ##Inf))
       s))

(really-safe-invert-seq (range 10))
;; => (##Inf 1 1/2 1/3 1/4 1/5 1/6 1/7 1/8 1/9)

在所有由惰性引起的问题中, 这算是一个非常严重的问题. Java 的异常处理方法告诉我们, 将代码包装在 try-catch 中应该能处理内部抛出的异常[5].

在存在惰性序列的情况下, 再也不能依赖这一点了, 除非你强制所有惰性求值(我稍后会提到这样做的弊端). 这个问题出人意料, 频繁发生, 并会引发焦虑.

只能通过确保被包装的代码中没有惰性, 或者在每个惰性迭代步骤中捕获所有异常来解决它.

动态绑定

与异常处理类似, Clojure 中的动态绑定也是基于栈的. 一旦执行退出 binding 形式, 动态变量就会恢复其之前的值.

(def ^:dynamic *multiplier* 1)

(let [seq1 (binding [*multiplier* 100]
             (map #(* % *multiplier*) (range 10)))]
  (vec seq1))

;; => [0 1 2 3 4 5 6 7 8 9]

动态变量 *multiplier* 的根值为 1. 我们将其绑定为 100, 并用该变量乘以一堆数字. 我们期望 map 中的数字会乘以 100. 然而, 由于惰性, map 的实际执行只发生在 (vec seq1) 步骤, 而此时绑定早已失效.

解决这个问题的一种方法是将惰性执行的函数包装在一个特殊的 bound-fn* 函数中. bound-fn* 会捕获其调用时看到的所有动态值, 并将这些值传递给被包装的函数. 无论被包装的函数在何时执行, 它都会接收到动态变量, 就像它立即运行一样.

(let [seq1 (binding [*multiplier* 100]
             (map (bound-fn* #(* % *multiplier*))
                  (range 10)))]
  (vec seq1))

;; => [0 100 200 300 400 500 600 700 800 900]

在我看来, 这种与惰性的交互显著降低了动态变量在任何重要事务中的用处. 当然, 多线程也会"破坏"动态变量, 所以惰性不是唯一的罪魁祸首.

这是一个需要时刻注意的问题, 大多数时候, 完全放弃在代码中使用动态变量会更简单, 更明智.

资源释放

与前两个问题有些类似, 释放先前获得的资源(例如, 文件句柄, 网络套接字)发生在特定的时间点, 而惰性带来的所有执行延迟与此完全不兼容. 这里是另一个几乎每个人都应该熟悉的 bug:

(defn read-words []
  (with-open [rdr (io/reader "/usr/share/dict/words")]
    (line-seq rdr)))

(count (read-words))
;; java.io.IOException: Stream closed

with-open 的实现是打开指定资源, 在该资源可用时执行主体代码, 最后关闭资源. 这里使用 with-open 并非关键--你可以手动写出这些步骤, 结果会一样.

这种资源管理意味着对资源的所有操作都必须在那个打开的窗口内发生, 所以你必须绝对确定, 在资源被释放后, 没有仍然想使用该资源的惰性代码未被执行.

这种 bug 不常潜入我的代码, 但一旦出现, 我就会深恶痛绝, 并再次进行一次全面清扫, 以清除程序中所有可能的惰性. 我称之为中等大小的问题.

对于"大数据"处理的脆弱性

惰性序列对于处理"大于内存" 的数据集很方便. 然而, 从个人经验来看, 只有当数据访问模式是线性的, 直接的时候, 这种方法才是健壮的. 在我迷恋惰性的 那些日子里, 我开发了一个程序, 需要处理多个包含嵌套数据的大文件, 我严重依赖惰性序列来实现它.

程序最终变得越来越复杂, 数据访问从简单的流式变为需要聚合, 转置和扁平化. 最终, 我完全无法理解程序在任何给定时间点需要多少内存, 也几乎没有信心 那些已经处理过的项会被及时丢弃以腾出空间给新的项.

甚至还有一个被官方承认的错误, 叫做**"持有头部的引用(holding onto one's head)***, 这个错误相对容易犯. 如果你保留了对一个大序列头部的引用, 那么 在遍历它时, 运行时将不得不在内存中保留整个序列.

最终, 会耗尽内存. Clojure 编译器会积极地将所有不再使用的局部变量置为 nil, 这个特性叫做 局部变量清理(locals clearing). 它的存在就是为了防止一些持有头部引用的 bug.

这也是一个大问题, 因为它直接与惰性序列最激动人心的价值主张相矛盾. 如果一个工具在它声称专门解决的条件下变得不可靠, 那么这个工具的适用性就应该受到质疑.

大/无限序列是一个火药桶

cider

当你返回一个无界的惰性序列作为 API 的一部分时, 应极其谨慎. 消费者除了依赖文档外, 无法判断结果是否可以安全地完全加载到内存中.

消费者还必须知道哪些函数在惰性集合上调用是安全的, 哪些可能会导致程序崩溃. 通常, 他们不会知道或考虑这些影响, 然后事情就会变糟.

即使你确定你返回的序列不是无限的或过大的, 仍然有方法可以搞砸你的 API 用户. 一个很好的例子是 Cheshire, 它的函数 parse-stream 在顶层 JSON 对象是数组时返回一个惰性序列.

将其与资源释放问题结合起来, 再要是遇上异步处理, 你就会得到一个我曾经花了一个小时才找出的 bug[6].

这个问题至少值得中等重要性. 对惰性集合心不在焉的处理可能导致潜伏的问题, 这些问题可能隐藏多年, 然后在最令人困惑的时候给你一个"惊喜" .

令人困惑的副作用

如前所述, 当代码是引用透明的时, 惰性不是问题. 如果可以自由地用求值结果替换求值过程而观察者没有任何变化, 那么惰性是可以的.

谢天谢地, Clojure 不强制引用透明性, 你可以在代码的任何地方添加副作用. 然后, 突然之间, 你目睹了这样的情况:

(defn sum-numbers [nums]
  (map println nums) ;; 让我们打印每个数字用于调试.
  (reduce + nums))

(sum-numbers (range 10))

;; => 45
;; 但 println 的输出在哪里?

经过十五分钟充满怀疑的调试, 谷歌搜索和AI提问后, 发现 map 调用由于惰性和没有消费其返回值而从未执行. 大约在那时, 你也学会了应该采取不同的做法, 并在余生中铭记这个教训:

  • 将打印形式包装在 dorundoall 中: (dorun (map println nums)).
  • 使用 run! 而不是 map: (run! println nums).

在我看来, 这是一个可控的问题. 当你想要触发副作用时, 记住你可能在处理惰性是可行的. 这并不是说你每次都能成功; 与副作用和惰性相关的 bug 也会发生在经验丰富的程序员身上.

复杂的基准测试和性能分析

无论一门编程语言在函数式纯度上有多高, 每个函数总会至少有一个副作用--执行它所花费的时间(还有内存分配, 磁盘 I/O 和任何其他资源使用). 通过将执行推迟到另一个时间点, 语言使得程序员更难理解那些 CPU 周期花在了哪里. 使用 Criterium 的简单例子:

(crit/quick-bench (map inc (range 10000)))

;;    Evaluation count : 34285704 in 6 samples of 5714284 calls.
;; Execution time mean : 16.188222 ns
;;                 ...

这个 16 纳秒的结果并不能证明 Clojure 速度惊人, 而是提醒你在对可能涉及惰性的代码进行基准测试时要保持警惕. 这才是你应该得到的结果:

(crit/quick-bench (doall (map inc (range 10000))))

;;    Evaluation count : 2088 in 6 samples of 348 calls.
;; Execution time mean : 299.631257 µs
;;                 ...

性能分析也是如此. 惰性以及随之而来的所有执行模糊性使得层次化的性能分析视图变得相当无用. 考虑这个例子和用 clj-async-profiler 获得的火焰图:

(defn burn [n]
  (reduce + (range n)))

(defn actually-slow-function [coll]
  (map burn coll))

(defn seemingly-fast-function [coll]
  (count coll))

(prof/profile
 (let [seq1 (repeat 10000 100000)
       seq2 (actually-slow-function seq1)]
   (seemingly-fast-function seq2)))

在火焰图上, 你可以看到大部分 CPU 时间都归因于 seemingly-fast-function, 而 actually-slow-function 却无处可寻. 到目前为止, 你应该很清楚发生了 什么--actually-slow-function 返回了一个惰性序列, 没有做任何工作, 而 seemingly-fast-function 通过调用无害的 count 触发了整个计算的执行.

这在一个简单示例程序中可能很容易解释, 但在现实生活中, 这样的执行迁移肯定会让人迷惑不解.

如果你不经常关心程序的执行时间, 那么这个缺点对你生活的影响不会太大. 对于性能有要求的场景中, 这也是个大麻烦, 也是避免惰性的另一个坚实理由.

使用序列 API 的低效迭代

这个问题不是由惰性直接引起的. 相反, Clojure 的序列 API 必须适应惰性集合等多种情况, 因此它能提供的功能相当有限. 基本上, Clojure 的序列接口 ISeq 定义了 carcdr 的人类可读的替代品. 你可以用这个抽象来迭代几乎任何东西, 但除了链表之外, 它对于任何东西都远非高效. 让我们用 time+ 来测量一下:

;;;; 经典的 loop 手动迭代.

(let [v (vec (range 10000))]
  (time+
   (loop [[c & r] v]
     (if c
       (recur r)
       nil))))

;; Time per call: 238.92 us   Alloc per call: 400,080b


;;;; doseq

(let [v (vec (range 10000))]
  (time+ (doseq [x v] nil)))

;; Time per call: 41.50 us   Alloc per call: 20,032b


;;;; run!

(let [v (vec (range 10000))]
  (time+ (run! identity v)))

;; Time per call: 42.65 us   Alloc per call: 24b

在第一个代码片段中, 我们用 loop 进行了一次基本的, 最灵活的迭代. 在迭代场景中(当你需要一次性累积多个不同的结果或以不明显的方式遍历序列时)很常用.

我们看到, 仅仅遍历那个向量就花了 240 微秒, 并且在此过程中分配了价值 400KB 的对象. 第二个代码片段使用了 doseq, 它包含多个分块优化. 使用 doseq 的 迭代比 loop 快 6 倍, 在堆上产生的垃圾少 20 倍.

最后, 基于 reduce 的 run! 在这个例子中提供了与 doseq 相同的速度, 同时在运行时不分配任何东西.

是不是问题取决于对性能的关心程度. 对于 Clojure 的创造者来说, 它足够重要, 以至于越来越多的集合处理函数正在使用 IReduce 抽象而不是 ISeq.

性能开销

如我之前所说, 惰性不是免费的, 而且不便宜. 考虑一个例子[7]:

;;;; 惰性 map

(time+
 (->> (repeat 1000 10)
      (map inc)
      (map inc)
      (map #(* % 2))
      (map inc)
      (map inc)
      doall))

;; Time per call: 410.22 us   Alloc per call: 480,296b


;;;; 即时 mapv

(time+
 (->> (repeat 1000 10)
      (mapv inc)
      (mapv inc)
      (mapv #(* % 2))
      (mapv inc)
      (mapv inc)))

;; Time per call: 63.66 us   Alloc per call: 28,456b


;;;; Transducers

(time+
 (into []
       (comp (map inc)
             (map inc)
             (map #(* % 2))
             (map inc)
             (map inc))
       (repeat 1000 10)))

;; Time per call: 43.95 us   Alloc per call: 6,264b

在这个例子中, 惰性版本花了 410 微秒和 480KB 的垃圾来对一个序列进行几次映射. 使用 mapv 的即时版本快 6.5 倍, 为相同的结果分配的内存少 16 倍.

这还是在每一步都生成中间向量的情况下. transducer 版本更快, 为 44 微秒, 产生的垃圾更少, 因为它将所有映射融合成了一个步骤.

在如此大的性能差异下, 惰性没有带来任何好处😂. 惰性版本的性能分析主要被创建中间惰性序列和遍历它们所占据. mapv 版本主要是更新 TransientVectors.

惰性版本在较短的序列上会更高效吗? 让我们来看看:

(time+ (doall (map inc (repeat 3 10))))
;; Time per call: 181 ns   Alloc per call: 440b

(time+ (doall (mapv inc (repeat 3 10))))
;; Time per call: 159 ns   Alloc per call: 616b

如你所见, 当输入序列的大小小到 3 时, mapv 显示出与 map 相当的性能. 在不需要惰性的地方, 不要害怕使用 mapv.

惰性的这个缺点是显著的. 大量的 Clojure 代码涉及遍历和修改序列, 而其中 95% 根本不需要是惰性的, 所以这是在无缘无故地浪费性能.

无法强制所有东西

虽然 doall 确保你传递给它的惰性序列会被求值, 但它只作用于顶层. 如果序列元素本身也是惰性序列, 它们将不会被求值. 一个人为的例子:

(let [seq1 (map #(Thread/sleep %) (repeat 100 10))]
  (time (doall seq1)))

;; "Elapsed time: 1220.941875 msecs"
;; 如预期 - doall 强制了惰性求值.

(let [seq1 (map (fn [outer]
                  (map #(Thread/sleep %) (repeat 100 10)))
                [1 2])]
  (time (doall seq1)))

;; "Elapsed time: 0.139 msecs"
;; 因为惰性序列在另一个序列内部, doall 没有强制它们.

当惰性序列是其他数据结构(例如, 哈希映射)的一部分时, 情况也是如此.

(let [m1 {:foo (map #(Thread/sleep %) (repeat 100 10))
          :bar (map #(Thread/sleep %) (repeat 100 10))}]
  (time (doall m1)))

;; "Elapsed time: 0.01775 msecs"
;; Doall 不作用于哈希映射, 并且不是递归的.

这种情况发生时会非常烦. 想在这种情况下强制立即求值, 你唯一的选择是:

  1. 如果你能访问产生那些组成惰性序列的代码, 就在那里强制它们.
  2. 使用 clojure.walk 递归地遍历嵌套结构, 并对所有东西调用 doall.
  3. 调用 (with-out-str (pr my-nested-structure)) 并丢弃结果. 打印结构会为你遍历它并实现其中的任何惰性序列. 这是最脏, 最低效的方法.

这是一个中等大小的问题. 它不常发生, 但如果你确实遇到了, 它会毁了你的一天.

分块是不可预测的

我已经提到过, Clojure 以 32 个项为一块来求值惰性集合, 以分摊惰性的成本. 同时, 这也使得惰性序列不适用于那些你想要控制序列中每一个元素生产的情况.

是的, 你可以用 lazy-seq 手工创建一个序列, 然后确保永远不在它上面调用任何内部使用分块的函数. 对我来说, 这看起来是另一种让你的程序变得脆弱的方式.

老实说, 我不知道分块是如何以及何时工作的. 在写这篇文章时, 我偶然发现了这个:

(let [seq1 (range 10)
      seq2 (map #(print % " ") seq1)]
  (first seq2))

;; 0  1  2  3  4  5  6  7  8  9
;; 使用了分块.


(let [seq1 (take 10 (range))
      seq2 (map #(print % " ") seq1)]
  (first seq2))

;; 0
;; 没有使用分块.

在第一个例子中使用 (range 10) 来产生一个有界的惰性序列, 对其进行映射时使用了分块. 在第二个例子中, 我们用 (range) 创建了一个无限的数字序列, 用 take 取了它的一个有界切片, 在映射时却没有分块.

我相信如果我读了足够的文档和实现代码, 谜底就会揭晓. 但我没有欲望去做那件事. 相反, 我在任何分块可能产生影响的地方都不使用惰性, 所以这个问题不困扰我.

重复的函数

虽然 Clojure 的序列抽象大大减少了代码重复和对类型特定函数的需求, 但一些重复性仍然进入了语言. 在很大程度上, 我将其归因于惰性以及避免它的频繁需求.

要对一个序列进行映射, 有 mapmapv(还有 run!, 但它本身就有用, 超出了讨论惰性的范畴). 要过滤, 有 filterfilterv, 等等. Clojure 的后续 版本添加了一堆这些 v-后缀的函数, 因为显然, 程序员经常想要确保即时求值(并以 PersistentVector 的形式接收结果).

有两个列表推导宏: fordoseq. 是的, 它们在语义上是不同的(doseq 不形成结果序列, 只应用于副作用, 像 run!). 但我会说, 如果不是因为需要消费和 产生惰性序列的要求, 这两个宏本可以有一个共同且简单得多的实现.

必须知道并记住 doalldorun 也增加了心智负担.

这些都不是决定性的问题, 只是从一个完美主义者的角度来看, 有些轻微的烦人.

REPL 和最终程序之间的不匹配

为了获得有效的 REPL 体验, 程序员必须确信 REPL 和正常的程序执行行为相同, 这一点至关重要. 经典的 Clojure 工作流程假定程序员在 REPL 中进行探索, 测试和验证, 最后将其整合到程序中. 这是 Clojure 的主要特性是语言的 alpha 和 omega, 是基石. 而惰性, 损害到了这一点.

REPL 中惰性的问题在于你总是隐式地消费求值的结果. REPL 打印结果; 因此, 任何惰性序列, 即使是嵌套的, 在呈现之前都会被实现. 但将该表达式复制到最终程序中, 情况可能就不再如此.

在 REPL 中, 很容易忘记你可能在处理惰性序列--除非你将圆括号中的所有东西都视为潜在的危险(也许, 你应该这样做! ).

对我来说, 这是一个小问题, 你会逐渐习惯. 在将 REPL 代码过渡到最终程序时, 还有其他事情需要注意(脏的 REPL 状态, 定义的顺序等等), 你会学会接受它.

尽管如此, 每次必须告诉一个初学者: "在 REPL 中, 情况是不同的" --信任就被侵蚀了.

fordoseq 的巨大字节码足迹

这是我个人的愚蠢抱怨, 应该与其他人无关. 列表推导宏 fordoseq 有时对于映射一个集合非常实用, 即使没有过滤和拼接嵌套迭代等高级功能. 但因为它们必须处理 惰性和分块, 它们的展开是绝对庞大的. 通过使用 clj-java-decompilerdisassemble 工具, 我们可以验证这一点, 并比较一个 for 展开比一个手写的基于迭代器 的循环大多少. 或者, 我们可以通过手动启用 AOT 并比较文件大小来做到这一点.

(binding [*compile-files* true
          *compile-path* "/tmp/test/"]
  (eval '(defn using-for [coll]
           (for [x coll]
             (inc x)))))

;; /tmp/test/ 包含 4 个文件, 总计 6030 字节.


(binding [*compile-files* true
          *compile-path* "/tmp/test/"]
  (eval '(defn using-iterator [coll]
           (when coll
             (let [it (.iterator ^Iterable coll)]
               (loop [res (transient [])]
                 (if (.hasNext it)
                   (recur (conj! res (.next it)))
                   (persistent! res))))))))

;; /tmp/test/ 包含 1 个文件, 大小为 1832 字节.

那些额外的字节码最终会被 JIT 编译成本地代码, 进一步污染指令缓存并妨碍 iTLB. 再次强调, 与上面列出的所有问题相比, 这是一个极其微小的问题, 但它使我在那些本可以很好地使用 for 的情况下不愿意使用它.

如何与之共存 🤔

这篇文章已经比我写过的任何东西都长了, 我还需要提供下一步该怎么做的指导. 很明显, 我不喜欢惰性. 如果我成功地证明了我的观点, 那么请将以下建议作为我个人减少惰性负面影响的缓解策略.

最直接的建议是在不需要时避免惰性. 为此, 需要遵循以下步骤:

  • 优先使用带 v-后缀的函数(mapv, filterv)而不是它们的惰性对应物.
  • 对于复杂的多步处理, 使用transducers(into [] <xform> <coll>).
  • 如果仍然更喜欢 ->> 管道, 用一个即时的最后一步或 doallvec 来结束它.
  • 库的作者, 不要在你的公共函数中返回惰性序列. 如果想让用户控制和限制代码处理的数据量, 考虑提供一个 transducer arity 或返回一个 eduction.
  • 避免围绕惰性序列构建处理范式. 返回一个惰性序列, 想着用户可以通过不消费完整结果来节省一些执行时间, 这可能看起来很诱人. 但这几乎从未发生. 首先, 结果很少只被部分使用. 其次, 好的性能从来不是偶然的. 如果用户关心程序性能并进行测量, 他们总会找到减少不必要工作的方法.

在处理无限或超大序列的情况下, 无论你是在开发应用程序还是库, 都选择显式表示它们. 这可以是一个 eduction, 一个 Java stream, 甚至是一个 Iterator, 一个 cursor. 任何能更清晰地表明集合的非有限和分段性质的东西, 都能避开我们描述的大多数惰性问题.

Transducers 总的来说是惰性序列的完美替代品. 也许它们在交互式实验中不太方便, 但它们提供的好处是实实在在的. 如果需要, 你甚至可以用 sequence 从它们回到惰性的世界.

如果你同意这篇文章, 请与他人分享. 把它展示给你的同事, 讨论它, 改变普遍的看法. 调整你的代码质量标准, 在代码审查中剔除惰性. 承认在代码库中对抗惰性序列比因未能正确利用它们而自责更容易.

出于向后兼容性的原因, Clojure 永远不会放弃惰性序列, 这是件好事. 我们有能力和控制权不去忍受它们的存在, 而承认问题是克服它的第一步. 我希望我已经把我的观点说清楚了; 请告诉我你对此的看法, 以及我是否遗漏了什么(因为你可能懒得这样做, 我明确地请求反馈). 就这样.

脚注

  • [1] 对 Clojure 之道的讨论(Reddit)
  • [2] 作者此处可能指从叶节点向根节点计算, 或者指从依赖树的叶子(无依赖的值)开始求值.
  • [3] 对 Clojure 语言特性的讨论(Reddit)
  • [4] 对无限序列的讨论(Hacker News)
  • [5] 对 Java 异常处理的讨论(Hacker News)
  • [6] 作者可能在他的博客或某个地方详细描述过这个bug.
  • [7] 性能比较示例, 强调惰性的开销.
Tags: clojure performance