【问题标题】:How Are Lazy Sequences Implemented in Clojure?Clojure 中如何实现惰性序列?
【发布时间】:2011-03-15 21:25:37
【问题描述】:

我喜欢 Clojure。语言困扰我的一件事是我不知道惰性序列是如何实现的,或者它们是如何工作的。

我知道惰性序列只评估序列中被要求的项目。它是如何做到的?

  • 是什么让惰性序列如此高效以至于消耗不多 堆栈?
  • 为什么你可以将递归调用包装在一个惰性序列中而没有 不再需要堆栈溢出来进行大型计算?
  • 惰性序列会消耗哪些资源来完成其工作?
  • 惰性序列在哪些情况下效率低下?
  • 在什么情况下惰性序列最有效?

【问题讨论】:

  • 我在去年八月开始学习 Clojure 时试图了解这篇文章中发生了什么——大约 5 个月前,我无法理解这篇文章的问题和答案。现在这篇文章中讨论的事情对我来说很有意义! :-) 所以只是想与其他 Clojure 学习者分享,在 Clojure 中学习概念可能需要时间,但不要放弃学习它。

标签: clojure lisp lazy-evaluation lazy-sequences


【解决方案1】:

最初,Clojure 中的惰性序列是根据需要逐项评估的。 Clojure 1.1 中添加了块序列以提高性能。不是逐项评估,而是一次评估 32 个元素的“块”。这减少了惰性求值带来的开销。此外,它还允许 clojure 利用底层数据结构。例如,PersistentVector 实现为由 32 个元素数组组成的树。这意味着要访问一个元素,您必须遍历树,直到找到合适的数组。使用分块序列,一次抓取整个数组。这意味着可以在需要重新遍历树之前检索 32 个元素中的每一个。

已经讨论过在需要完全惰性的情况下提供一种强制逐项评估的方法。但是,我认为它还没有被添加到语言中。

为什么你可以将递归调用包装在一个惰性序列中,而不再让堆栈溢出来进行大型计算?

你有一个例子来说明你的意思吗?如果你有一个惰性序列的递归绑定,it can definitely cause a stack overflow

【讨论】:

  • 查看我对递归惰性序列的回答。
  • 我想我有点不清楚。我了解递归惰性序列,但不确定为什么 mudge 担心它们会导致堆栈溢出。懒惰本质上可以防止发生堆栈溢出。
  • 啊,明白了——说得通。道歉。
【解决方案2】:

让我们这样做吧。

我知道惰性序列只评估序列中被请求的项目,它是如何做到的?

懒惰的序列(以下称为 LS,因为我是 LP,或懒惰的人)由部分组成。 头部或部分(s,实际上是一次评估 32 个元素,从 Clojure 1.1 开始,我认为是 1.2)已评估的序列,后面跟着一个称为 thunk 的东西,它基本上是等待调用的一大块信息(将其视为创建序列的函数的其余部分,未评估)。。当它被调用时,thunk 会评估对它的要求,并创建一个新的 thunk,并根据需要使用上下文(已经调用了多少,因此它可以从以前的位置恢复)。

所以你(take 10 (whole-numbers)) - 假设whole-numbers 是一个懒惰的整数序列。这意味着您要强制对 thunk 进行 10 次评估(尽管在内部这可能会根据优化有所不同。

是什么让惰性序列如此高效以至于它们不会消耗太多堆栈?

一旦您阅读了上一个答案(我希望),这一点就会变得更加清晰:除非您特别要求某些东西,否则不会评估任何内容。当您调用某项内容时,可以单独评估序列中的每个元素,然后将其丢弃。

如果序列不是惰性的,通常它会抓住它的头部,这会消耗堆空间。如果它是惰性的,则对其进行计算,然后丢弃,因为后续计算不需要它。

为什么您可以将递归调用包装在一个惰性序列中,而不再让堆栈溢出来进行大型计算?

查看上一个答案并考虑:lazy-seq 宏 (from the documentation) 将

will invoke the body only the first time seq
is called, and will cache the result and return it on all subsequent
seq calls.

查看 filter 函数,了解使用递归的酷 LS:

(defn filter
  "Returns a lazy sequence of the items in coll for which
  (pred item) returns true. pred must be free of side-effects."
  [pred coll]
  (let [step (fn [p c]
                 (when-let [s (seq c)]
                   (if (p (first s))
                     (cons (first s) (filter p (rest s)))
                     (recur p (rest s)))))]
    (lazy-seq (step pred coll))))

惰性序列会消耗哪些资源来完成它的工作?

我不太确定你在这里问什么。 LS 需要内存和 CPU 周期。他们只是不会不断地敲打堆栈,并用获取序列元素所需的计算结果来填充它。

惰性序列在哪些情况下效率低下?

当您使用计算速度快且不会大量使用的小型序列时,将其设为 LS 效率低下,因为它需要创建另外几个字符。

说真的,除非你试图让某些东西非常表现出色,否则 LS 是要走的路。

在什么情况下惰性序列最有效?

当您处理巨大的 seq 并且您只使用它们的零碎部分时,那就是您从使用它们中获得最大收益的时候。

真的,就方便性、易于理解(一旦掌握它们)、对代码的推理以及速度而言,使用 LS 总比使用非 LS 好得多。

【讨论】:

  • 抱头不消耗栈。它消耗堆。
  • 引用您上面的答案:“如果序列不是惰性的,通常它会抓住它的头部,这会消耗堆栈空间。” (请参阅“是什么让惰性序列如此高效以至于它们不会消耗太多堆栈”项目符号。)
  • “抱头”是指抱住一个lazy seq的头;你的第一个粘贴当然会产生一个 SO(因为控制上下文的增长),但你通常不会说它“抓住它的头”。因此我的评论。另外,对于第二次粘贴:你不应该混合尾递归和lazy-seq。这在这里并不重要——lazy-seq 只是没有增加任何价值,但也没有真正损害任何东西——但通常它会导致问题(或者更确切地说,lazy-seq 没有意识到它的潜力)。有关详细信息,请参阅我的答案(我发布它主要是为了讨论这个问题)。
  • 啊,很公平;感谢您的澄清。我的术语还没有完全跟上。至于多余的lazy-seq,我不应该将其作为示例发布,因为正如您所说,它没有任何作用。再次感谢 cmets!
  • 关于第一个问题的更多评论:任何严格的序列生产者都会“抓住”所生产序列的头部,因为它需要一次生产所有序列。这可能是为什么这个表达式通常不用于严格序列的上下文中的原因(因为它不添加任何信息)以及我误解你的意图的原因。
【解决方案3】:

我知道惰性序列只评估序列中被请求的项目,它是如何做到的?

我认为之前发布的答案已经很好地解释了这部分。我只会补充一点,惰性序列的“强制”是隐含的——没有括号! :-) -- 函数调用;也许这种思考方式会让一些事情变得更清楚。另请注意,强制延迟序列涉及隐藏的突变——被强制的 thunk 需要产生一个值,将其存储在缓存中(突变!)并丢弃其不再需要的可执行代码(再次突变!) .

我知道惰性序列只评估序列中被请求的项目,它是如何做到的?

是什么让惰性序列如此高效以至于它们不会消耗太多堆栈?

惰性序列会消耗哪些资源来完成它的工作?

它们不消耗堆栈,因为它们消耗的是堆。惰性序列是一种存在于堆上的数据结构,其中包含一小部分可执行代码,如果需要,可以调用这些代码来生成更多数据结构。

为什么你可以将递归调用包装在一个惰性序列中,而不再让堆栈溢出来进行大型计算?

首先,正如 dbyrne 所提到的,如果 thunk 本身需要执行具有非常深嵌套的调用结构的代码,那么在使用惰性序列时您可以很好地获得 SO。

但是,在某种意义上,您可以使用惰性序列来代替尾递归,并且在这对您有用的程度上,您可以说它们有助于避免 SO。事实上,更重要的是,产生惰性序列的函数不应该是尾递归的;懒惰的 seq 生产者的堆栈空间保护源于上述堆栈 -> 堆转移,任何以尾递归方式编写它们的尝试只会破坏事情。

关键的见解是惰性序列是一个对象,它在首次创建时不包含任何项(严格序列总是如此);当一个函数返回一个惰性序列时,在任何强制发生之前,只有这个“惰性序列对象”被返回给调用者。因此,返回惰性序列的调用使用的堆栈帧在任何强制发生之前被弹出。让我们看一个示例生产者函数:

(defn foo-producer [] ; not tail recursive...
  (lazy-seq
    (cons :foo        ; because it returns the value of the cons call...
           (foo-producer)))) ; which wraps a non-tail self-call

这是可行的,因为lazy-seq立即返回,因此(cons :foo (foo-producer)) 也立即返回,并且被外部调用foo-producer 使用的堆栈帧立即弹出。对foo-producer 的内部调用隐藏在序列的rest 部分,这是一个thunk;如果/当该 thunk 被强制执行时,它会在堆栈上短暂地用完自己的帧,但随后立即返回,如上所述等。

Chunking(由 dbyrne 提到)稍微改变了这幅图,因为每一步都会产生更多的元素,但原理保持不变:当惰性 seq 的相应元素正在被处理时,每一步都会占用一些堆栈产生,然后在更多强制发生之前回收该堆栈。

惰性序列在什么场景下效率低?

在什么情况下惰性序列最有效?

如果您无论如何都需要一次抓住整个事物,那么懒惰是没有意义的。惰性序列在未分块时在每一步进行堆分配,或者在分块时在每个块(每 32 步一次)进行堆分配;在某些情况下,避免这种情况可以为您带来性能提升。

但是,惰性序列支持数据处理的流水线模式:

(->> (lazy-seq-producer)               ; possibly (->> (range)
     (a-lazy-seq-transformer-function) ;               (filter even?)
     (another-transformer-function))   ;               (map inc))

无论如何,以严格的方式执行此操作会分配大量堆,因为您必须保留中间结果才能将它们传递到下一个处理阶段。此外,您需要保留整个内容,这在(range) 的情况下实际上是不可能的——无限序列! -- 如果可能的话,它通常是低效的。

【讨论】:

    最近更新 更多