9

我有一个项目来源,并希望单独处理它们的运行,它们具有相同的键函数值。在 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。

我发现了这个讨论,它声称以下函数(为了帖子内部的命名一致性而稍作修改)足够懒惰

(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消费时parts正在实现但没有被垃圾收集。drop-while它只有在遍历时才有资格进行 GC part

所以,我的问题是:

  1. lazy-partition-by不够懒惰是正确的吗?
  2. 是否有保证内存要求的实现,前提是在我开始实现下一个时我partition-by没有对前一个的任何引用?part

编辑:这是 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实现中可以看出,part并且rest隐式共享状态。我想知道这个方法是否可以翻译成 Clojure。

4

3 回答 3

8

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

  1. 效率(仅遍历输入序列一次,因此不会保持领先)
  2. 懒惰(仅按需生成元素)
  3. 没有共享的可变状态

clojure.core 中的版本选择(1,3),但放弃了(2),一次生成了整个分区。Python 和 Haskell 都选择了 (1,2),尽管这不是很明显:Haskell 根本就没有可变状态吗?好吧,它对所有内容(不仅仅是序列)的惰性求值意味着所有表达式都是thunk,它们一开始是空白的,只有在需要它们的值时才被写入;span正如您所说, 的实现span p xs'在其两个输出序列中共享相同的 thunk ,因此无论哪个需要它,它首先将其“发送”到另一个序列的结果,从而在保持必要距离的距离处执行操作其他不错的属性。

正如您所指出的,您链接到的替代 Clojure 实现选择 (2,3)。

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

  1. 采用 Python 方法:返回更像迭代器而不是 seq 的东西 - seqs 对非突变做出更强有力的保证,并承诺您可以安全地多次遍历它们,等等。如果您返回的不是 seq 的 seq,而是迭代器,然后使用来自任何一个迭代器的项目可以自由地改变或使其他迭代器无效。这保证了消费按顺序进行,并且可以释放内存。
  2. 采用 Haskell 方法:手动 thunk 一切,大量调用delay,并要求客户端force尽可能频繁地调用以获取数据。这在 Clojure 中会更难看,并且会大大增加你的堆栈深度(在非平凡的输入上使用它可能会破坏堆栈),但理论上是可能的。
  3. 通过在输出 seq 之间协调一些可变数据对象来编写更多 Clo​​jure 风格(但仍然很不寻常)的东西,每个输出 seq 都根据需要更新,当它们中的任何一个请求某些内容时。

我很确定这三种方法中的任何一种都是可能的,但老实说,它们都非常难,而且一点也不自然。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]]

编辑:再看一遍,我真的不相信 myreduce-grouped比 有用得多(reduce f init (map g xs)),因为它并没有真正为您提供任何关于密钥何时更改的明确指示。因此,如果您确实需要知道组何时发生更改,您将需要更智能的抽象,或者使用我的原始lazy-partition-by内容,而没有任何“聪明”的包装。

于 2014-07-14T20:40:29.260 回答
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必须是急切的消费者!所以while(process-partition-by identity key-fn coll)同理(partition-by key-fn coll),因为identity不消耗分区,所以内存消耗不是恒定的。


测试

(defn heavy-seq [] 
  ;adjust payload for your JVM so only a few fit in memory
  (let [payload (fn [] (long-array 20000000))]
   (map #(vector % (payload)) (iterate inc 0))))

(defn my-process [s] (reduce + (map first s)))

(defn test1 []
  (doseq [part (partition-by #(quot (first %) 10) (take 50 (heavy-seq)))]
    (my-process part)))

(defn test2 []
  (process-partition-by 
    my-process #(quot (first %) 20) (take 200 (heavy-seq))))

so.core=> (test1)
OutOfMemoryError Java heap space  [trace missing]

so.core=> (test2)
(190 590 990 1390 1790 2190 2590 2990 3390 3790)
于 2014-07-16T19:32:53.247 回答
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

由于既不doseq也不保留头部,如果内存中为 O(1) dorun,这将永远运行。lazy-partition-by

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

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

对于您一次处理一个记录的副作用并希望它们按键分组的特殊情况(正如您在doseq上面的使用所暗示的那样),您可能会考虑类似于loop/的东西,recur它保持状态并重新- 当键改变时设置它。

于 2014-07-14T17:03:40.853 回答