我有一个项目来源,并希望单独处理它们具有相同键值功能的运行。在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。
所以,我的问题是:
part
不够懒?lazy-partition-by
的实现,前提是我在开始实现下一个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
implementation可以看出,part
和rest
隐含地共享状态。我想知道这种方法是否可以翻译成Clojure。
答案 0 :(得分:7)
我在这些场景中使用的经验法则(即,您希望单个输入序列生成多个输出序列的那些)是,在以下三个理想属性中,您通常只能有两个:
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中有几种方法可以做到这一点:
delay
手动打包所有内容,并要求客户端根据需要经常调用force
以获取数据。这在Clojure中会更加丑陋,并且会大大增加您的堆栈深度(在非平凡的输入上使用它可能会破坏堆栈),但理论上这是可能的。我很确定这三种方法中的任何一种都是可能的,但说实话,它们都很难,而且根本不自然。 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
由于doseq
和dorun
都不会保留头部,如果lazy-partition-by
在内存中为O(1),则只会永远运行。
是否存在具有保证内存要求的分区实现,前提是在我开始实现下一个部分时,我不会对前一部分进行任何引用?
如果不是不可能的话,以纯粹功能性的方式编写这样的实现将是非常困难的,这将适用于一般情况。考虑到一般的lazy-partition-by
实现不能对何时(或如果)实现分区做出任何假设。找到第二个分区的开始的唯一有保证的正确方法是,记住第一个分区开始的位置并在请求时向前扫描,而不是引入一些讨厌的有状态来跟踪第一个分区已经实现了多少。
对于您一次处理一个副作用记录并希望按键分组的特殊情况(正如您在上面使用doseq
所暗示的那样),您可能会考虑沿着loop
/ recur
的行,用于维护状态,并在密钥更改时重新设置状态。