懒惰的分区

时间:2014-07-14 13:58:28

标签: clojure lazy-evaluation lazy-sequences

我有一个项目来源,并希望单独处理它们具有相同键值功能的运行。在Python中,这看起来像

for key_val, part in itertools.groupby(src, key_fn):
  process(key_val, part)

此解决方案完全是懒惰的,即如果process未尝试存储整个part的内容,则代码将在O(1)内存中运行。

Clojure解决方案

(doseq [part (partition-by key-fn src)]
  (process part))

不那么懒:它完全实现了每个部分。问题是,src可能有很长的项目具有相同的key-fn值,并且意识到它们可能会导致OOM。

我发现this discussion声称以下函数(稍微针对帖子内部的命名一致性进行了修改)是否足够懒惰

(defn lazy-partition-by [key-fn coll]
  (lazy-seq
    (when-let [s (seq coll)]
      (let [fst (first s)
            fv (key-fn fst)
            part (lazy-seq (cons fst (take-while #(= fv (key-fn %)) (next s))))]
        (cons part (lazy-partition-by key-fn (drop-while #(= fv (key-fn %)) s)))))))

但是,我不明白为什么它不会受到OOM的影响:cons小区的两个部分都持有对s的引用,因此process消耗part,{ {1}}正在实现,但不是垃圾收集。仅当s遍历drop-while时,它才有资格使用GC。

所以,我的问题是:

  1. 我是否认为part不够懒?
  2. 是否存在具有保证内存要求的lazy-partition-by的实现,前提是我在开始实现下一个partition-by时没有对前一个part进行任何引用?
  3. 编辑: 这是Haskell中一个懒惰的实现:

    lazyPartitionBy :: Eq b => (a -> b) -> [a] -> [[a]]
    lazyPartitionBy _ [] = []
    lazyPartitionBy keyFn xl@(x:_) = let
      fv = keyFn x
      (part, rest) = span ((== fv) . keyFn) xl
      in part : lazyPartitionBy keyFn rest
    

    span implementation可以看出,partrest隐含地共享状态。我想知道这种方法是否可以翻译成Clojure。

3 个答案:

答案 0 :(得分:7)

我在这些场景中使用的经验法则(即,您希望单个输入序列生成多个输出序列的那些)是,在以下三个理想属性中,您通常只能有两个:

  1. 效率(仅输入一次输入序列,因此不能保持其头部)
  2. 懒惰(仅按需生产元素)
  3. 没有共享的可变状态
  4. clojure.core中的版本选择(1,3),但通过一次生成整个分区放弃(2)。 Python和Haskell都选择(1,2),虽然它并不是很明显:Haskell根本没有可变状态吗?好吧,它对所有内容(不仅仅是序列)的懒惰评估意味着所有表达式都是 thunks ,它们以空白平板开始,只有在需要它们的值时才会被写入;正如你所说,span的实现在它的两个输出序列中共享同一个span p xs'的thunk,所以无论哪个需要它,首先将它“发送”到另一个序列的结果,影响保持其他不错的属性所必需的距离的动作。

    如您所述,您链接的替代Clojure实现选择(2,3)。

    问题在于,对于partition-by,拒绝(1)(2)意味着您正处于某个序列的头部:输入或其中一个输出。因此,如果您想要一个可以处理任意大输入的任意大分区的解决方案,您需要选择(1,2)。在Clojure中有几种方法可以做到这一点:

    1. 采用Python方法:返回更像迭代器而不是seq的东西 - seqs对非变异做出更强的保证,并承诺你可以安全地遍历它们多次等等。如果不是seqs seqs而是返回迭代器的迭代器,然后从任何一个迭代器消耗项可以自由地改变或使其他迭代器无效。这可以保证消费按顺序发生,并且可以释放内存。
    2. 采用Haskell方法:通过大量调用delay手动打包所有内容,并要求客户端根据需要经常调用force以获取数据。这在Clojure中会更加丑陋,并且会大大增加您的堆栈深度(在非平凡的输入上使用它可能会破坏堆栈),但理论上这是可能的。
    3. 通过在输出序列之间协调一些可变数据对象来编写更多Clojure风格(但仍然非常不寻常),每个都在需要时从其中任何一个请求更新。
    4. 我很确定这三种方法中的任何一种都是可能的,但说实话,它们都很难,而且根本不自然。 Clojure的序列抽象只是不容易产生你想要的数据结构。我的建议是,如果你需要这样的东西,并且分区可能太大而不适合放置,你只需接受一种稍微不同的格式并自己做更多的簿记:避免(1,2,3)难以避免产生多重因素完全输出序列!

      因此,((2 4 6 8) (1 3 5) (10 12) (7))不是(partition-by even? [2 4 6 8 1 3 5 10 12 7])作为([::key true] 2 4 6 8 [::key false] 1 3 5 [::key true] 10 12 [::key false] 7)之类的输出格式,而是可以接受稍微丑陋的格式:(defn lazy-partition-by [f coll] (lazy-seq (when (seq coll) (let [x (first coll) k (f x)] (list* [::key k] x ((fn part [k xs] (lazy-seq (when (seq xs) (let [x (first xs) k' (f x)] (if (= k k') (cons x (part k (rest xs))) (list* [::key k'] x (part k' (rest xs)))))))) k (rest coll))))))) 。这既不难制作也不难消费,虽然写出来有点冗长乏味。

      以下是生产函数的一个合理实现:

      reduce-grouped

      以下是如何使用它,首先定义一个隐藏分组格式细节的通用count-partition-sizes,然后是一个示例函数(defn reduce-grouped [f init groups] (loop [k nil, acc init, coll groups] (if (empty? coll) acc (if (and (coll? (first coll)) (= ::key (ffirst coll))) (recur (second (first coll)) acc (rest coll)) (recur k (f k acc (first coll)) (rest coll)))))) (defn count-partition-sizes [f coll] (reduce-grouped (fn [k acc _] (if (and (seq acc) (= k (first (peek acc)))) (conj (pop acc) (update-in (peek acc) [1] inc)) (conj acc [k 1]))) [] (lazy-partition-by f coll))) user> (lazy-partition-by even? [2 4 6 8 1 3 5 10 12 7]) ([:user/key true] 2 4 6 8 [:user/key false] 1 3 5 [:user/key true] 10 12 [:user/key false] 7) user> (count-partition-sizes even? [2 4 6 8 1 3 5 10 12 7]) [[true 4] [false 3] [true 2] [false 1]] 来输出每个分区的密钥和大小而不保留任何分区记忆中的序列:

      reduce-grouped

      修改:再看一遍,我真的不相信我的(reduce f init (map g xs))lazy-partition-by更有用,因为它并没有真正让你明白指示键何时发生变化。因此,如果你确实需要知道一个组何时发生变化,你需要一个更智能的抽象,或者使用我原来的{{1}},而不是“聪明”地包装它。

答案 1 :(得分:3)

虽然这个问题引起了关于语言设计的非常有趣的思考,但实际问题是你想要在常量内存中的分区上进行处理。实际问题可以通过一点反转来解决。

不是处理返回分区序列的函数的结果,而是将处理函数传递给产生分区的函数。然后,您可以以包含的方式控制状态。

首先,我们将提供一种方法,将序列的消耗与尾部的状态融合在一起。

(defn fuse [coll wick]
  (lazy-seq 
   (when-let [s (seq coll)]
     (swap! wick rest)
     (cons (first s) (fuse (rest s) wick)))))

然后是partition-by

的修改版本
(defn process-partition-by [processfn keyfn coll] 
  (lazy-seq
    (when (seq coll)
      (let [tail (atom (cons nil coll))
            s (fuse coll tail)
            fst (first s)
            fv (keyfn fst)
            pred #(= fv (keyfn %))
            part (take-while pred s)
            more (lazy-seq (drop-while pred @tail))] 
        (cons (processfn part) 
              (process-partition-by processfn keyfn more))))))

注意:对于O(1),内存消耗processfn必须是热切的消费者!因此(process-partition-by identity key-fn coll)(partition-by key-fn coll)相同,因为{{} 1}}不消耗分区,内存消耗不是常数。


测试

identity

答案 2 :(得分:1)

  

我是否正确懒惰分区 - 不够懒惰?

嗯,懒惰和内存使用之间存在差异。序列可以是惰性的并且仍然需要大量内存 - 例如参见clojure.core/distinct的实现,其使用集合来记住序列中所有先前观察到的值。但是,您对lazy-partition-by的内存要求的分析是正确的 - 计算第二个分区的头部的函数调用将保留第一个分区的头部,这意味着实现第一个分区会导致它被保留在记忆中。这可以使用以下代码进行验证:

user> (doseq [part (lazy-partition-by :a
                      (repeatedly
                         (fn [] {:a 1 :b (long-array 10000000)})))]
        (dorun part))
; => OutOfMemoryError Java heap space

由于doseqdorun都不会保留头部,如果lazy-partition-by在内存中为O(1),则只会永远运行。

  

是否存在具有保证内存要求的分区实现,前提是在我开始实现下一个部分时,我不会对前一部分进行任何引用?

如果不是不可能的话,以纯粹功能性的方式编写这样的实现将是非常困难的,这将适用于一般情况。考虑到一般的lazy-partition-by实现不能对何时(或如果)实现分区做出任何假设。找到第二个分区的开始的唯一有保证的正确方法是,记住第一个分区开始的位置并在请求时向前扫描,而不是引入一些讨厌的有状态来跟踪第一个分区已经实现了多少。

对于您一次处理一个副作用记录并希望按键分组的特殊情况(正如您在上面使用doseq所暗示的那样),您可能会考虑沿着loop / recur的行,用于维护状态,并在密钥更改时重新设置状态。

相关问题