【问题标题】:Efficient side-effect-only analogue of Clojure's map functionClojure 的 map 函数的有效的仅副作用模拟
【发布时间】:2014-01-30 06:42:05
【问题描述】:

如果mapdoseq 有了孩子怎么办?我正在尝试编写像 Common Lisp 的 mapc 这样的函数或宏,但在 Clojure 中。这基本上是map 所做的,但用于副作用,因此它不需要生成一系列结果,也不会偷懒。我知道可以使用doseq 迭代单个序列,但是 map 可以迭代多个序列,依次对所有序列的每个元素应用一个函数。我也知道可以将map 包装在dorun 中。 (注意:这个问题在经过多次 cmet 和非常彻底的回答后已经被广泛编辑。最初的问题集中在宏上,但结果证明这些宏问题是外围问题。)

这很快(根据标准):

(defn domap2
  [f coll]
  (dotimes [i (count coll)]
    (f (nth coll i))))

但它只接受一个集合。这接受任意集合:

(defn domap3
  [f & colls]
  (dotimes [i (apply min (map count colls))]
    (apply f (map #(nth % i) colls))))

但相比之下它非常慢。我也可以写一个像第一个一样的版本,但有不同的参数 case [f c1 c2][f c1 c2 c3] 等,但最后,我需要一个处理任意数量集合的 case,就像最后一个例子一样,反正更简单。我也尝试了许多其他解决方案。

由于第二个示例与第一个示例非常相似,只是在循环中使用了applymap,我怀疑摆脱它们会加快速度。我试图通过将 domap2 编写为宏来做到这一点,但处理 & 之后的全部变量的方式一直让我感到困惑,如上图所示。

其他示例(15 或 20 个不同版本中的)、基准代码和几年前 Macbook Pro 上的时间(完整源代码here):

(defn domap1
  [f coll]
  (doseq [e coll] 
    (f e)))

(defn domap7
  [f coll]
  (dorun (map f coll)))

(defn domap18
  [f & colls]
  (dorun (apply map f colls)))

(defn domap15
  [f coll] 
  (when (seq coll)
    (f (first coll))
    (recur f (rest coll))))

(defn domap17
  [f & colls]
  (let [argvecs (apply (partial map vector) colls)] ; seq of ntuples of interleaved vals
    (doseq [args argvecs]
      (apply f args))))

我正在开发一个使用 core.matrix 矩阵和向量的应用程序,但您可以在下面随意替换您自己的副作用函数。

(ns tst
  (:use criterium.core
        [clojure.core.matrix :as mx]))

(def howmany 1000)
(def a-coll (vec (range howmany)))
(def maskvec (zero-vector :vectorz howmany))

(defn unmaskit!
  [idx]
  (mx/mset! maskvec idx 1.0)) ; sets element idx of maskvec to 1.0

(defn runbench
  [domapfn label]
  (print (str "\n" label ":\n"))
  (bench (def _ (domapfn unmaskit! a-coll))))

根据 Criterium 的平均执行时间,以微秒为单位:

domap1:12.317551 [剂量]
domap2: 19.065317 [dotimes]
domap3: 265.983779 [dotimes with apply, map]
domap7: 53.263230 [带有dorun的地图]
domap18: 54.456801 [带有 dorun 的地图,多个集合]
domap15:32.034993 [重复]
domap17: 95.259984 [doseq,使用 map 交错的多个集合]

编辑:dorun+map 可能是为多个大型惰性序列参数实现 domap 的最佳方式,但对于单个惰性序列,doseq 仍然是王道。执行与上述unmask! 相同的操作,但通过(mod idx 1000) 运行索引,并迭代(range 100000000)doseq 在我的测试中大约是dorun+map 的两倍(即(def domap25 (comp dorun map)) )。

【问题讨论】:

  • 您的“如何编写将集合作为参数的可变参数 Clojure 宏”的实际问题完全丢失了。考虑只编辑与实际问题相关的部分。
  • 感谢@A.Webb。将主要问题信息移至顶部。我希望人们会像你一样试图让我远离这个问题。我不介意,但到目前为止,在我看来,我的案子值得回答。我现在在“附录”部分添加了额外版本的domap 和计时。如您所见,dorun+map(domap7domap8)比doseqdomap1)和dotimesdomap2domap3)慢得多。 (我求助于dotimes,因为我想不出一种更有效的方法来并行遍历集合(请参阅domap15domap17)。)
  • 我编辑并用domap18替换了domap8,并在新测试后替换了times。 domap8 使用了apply (partial map f)。你提醒我,我可以直接说apply map f
  • 还有一点:我同意——我认为dotimes 会很慢。也许我只是没有在足够长的收藏中尝试过。应该有一种方法可以制作出与单集合 doseq 版本一样快的多集合版本。
  • 自从我发布了这个问题并得到了回答,run! 已添加到核心语言中。它并没有达到我想要的效果,但它是相关的,值得了解。

标签: clojure


【解决方案1】:

你不需要宏,我不明白为什么宏在这里会有帮助。

user> (defn do-map [f & lists] (apply mapv f lists) nil)
#'user/do-map
user> (do-map (comp println +) (range 2 6) (range 8 11) (range 22 40))
32
35
38
nil

注意这里的 do-map 是急切的(感谢 mapv)并且只执行副作用

宏可以使用可变参数列表,正如(无用!)do-map 的宏版本所示:

user> (defmacro do-map-macro [f & lists] `(do (mapv ~f ~@lists) nil))
#'user/do-map-macro
user> (do-map-macro (comp println +) (range 2 6) (range 8 11) (range 22 40))
32
35
38
nil
user> (macroexpand-1 '(do-map-macro (comp println +) (range 2 6) (range 8 11) (range 22 40)))
(do (clojure.core/mapv (comp println +) (range 2 6) (range 8 11) (range 22 40)) nil)

附录: 解决效率/垃圾创建问题:
请注意,出于简洁的原因,我在下面截断了 criterium bench 函数的输出:

(defn do-map-loop
  [f & lists]
  (loop [heads lists]
    (when (every? seq heads)
      (apply f (map first heads))
      (recur (map rest heads)))))


user> (crit/bench (with-out-str (do-map-loop (comp println +) (range 2 6) (range 8 11) (range 22 40))))
...
            Execution time mean : 11.367804 µs
...

这看起来很有希望,因为它不会创建我们无论如何都不会使用的数据结构(与上面的 mapv 不同)。但事实证明它比以前慢(可能是因为两次 map 调用?)。

user> (crit/bench (with-out-str (do-map-macro (comp println +) (range 2 6) (range 8 11) (range 22 40))))
...
             Execution time mean : 7.427182 µs
...
user> (crit/bench (with-out-str (do-map (comp println +) (range 2 6) (range 8 11) (range 22 40))))
...
             Execution time mean : 8.355587 µs
...

由于循环仍然没有更快,让我们尝试一个专门处理 arity 的版本,这样我们就不需要在每次迭代时调用 map 两次:

(defn do-map-loop-3
  [f a b c]
  (loop [[a & as] a
         [b & bs] b
         [c & cs] c]
    (when (and a b c)
      (f a b c)
      (recur as bs cs))))

值得注意的是,虽然这样更快,但还是比刚刚使用 mapv 的版本慢:

user> (crit/bench (with-out-str (do-map-loop-3 (comp println +) (range 2 6) (range 8 11) (range 22 40))))
...
             Execution time mean : 9.450108 µs
...

接下来我想知道输入的大小是否是一个因素。更大的输入...

user> (def test-input (repeatedly 3 #(range (rand-int 100) (rand-int 1000))))
#'user/test-input
user> (map count test-input)
(475 531 511)
user> (crit/bench (with-out-str (apply do-map-loop-3 (comp println +) test-input)))
...
            Execution time mean : 1.005073 ms
...
user> (crit/bench (with-out-str (apply do-map (comp println +) test-input)))
...
             Execution time mean : 756.955238 µs
...

最后,为了完整起见,do-map-loop的时间安排(正如预期的那样比do-map-loop-3稍慢)

user> (crit/bench (with-out-str (apply do-map-loop (comp println +) test-input)))
...
             Execution time mean : 1.553932 ms

正如我们所见,即使输入更大,mapv 也更快。

(为了完整起见,我应该注意 map 比 mapv 稍微快一点,但不是很大)。

【讨论】:

  • 我认为这里有一个子问题是关于有效地对多个集合进行映射以获取副作用,并且正在研究基准优化版本(也不是宏)。
  • 这里有(太多)子问题,但还有一个是如何在不创建整个序列的情况下做到这一点。我认为您的mapv 确实如此,尽管您最后通过返回nil 将其丢弃。 (dorun (apply map f colls)) 不会。
  • 优秀的@noisesmith。这在我的测试中需要 23 毫秒,与 dotimes 版本大致相同,并且不会冒着因长时间收集而崩溃的风险。看起来宏版本避免了我在使用 varargs 时遇到的问题,因为 mapv 已经期望可变数量的集合。
  • 应用了初始版本,而不是取消引用拼接,这在这种情况下应该会有所帮助。当我完成基准测试后,我将发布非垃圾版本。
  • @noisesmith 该评论是关于最大空间消耗,而不是使用时间,甚至没有创建垃圾。使用mapv 将在丢弃之前实现整个序列O(n) 空间。使用(dorun (map ... )) 一次只能实现一块O(1),而不是抓住头部。我希望高度定制的循环表现更好。
猜你喜欢
  • 1970-01-01
  • 2015-11-11
  • 1970-01-01
  • 2011-10-17
  • 2021-11-10
  • 2022-12-20
  • 1970-01-01
  • 1970-01-01
  • 2011-10-02
相关资源
最近更新 更多