有人能用简单的语言解释一下Clojure Transducers吗?

116

我已经尝试阅读相关内容,但我仍然不理解它们的价值或者它们所替代的内容。它们是否能够使我的代码更简洁易懂?

更新

很多人已经回答了,但是如果能举出具有简单性质的使用和不使用转换器的例子将会更好,这样即使像我这样的白痴也能够理解。除非转换器需要某种高层次的理解,否则我将永远无法理解它们 :(

12个回答

81

变压器是一种针对数据序列的处理方案,不需要知道底层的数据结构(如何处理)。可以适用于任何序列、异步通道或者可观察对象。

它们具有可组合性和多态性。

好处在于,每次添加新的数据源时都不必再次实现所有标准合成器,反复操作。结果,您作为用户能够在不同的数据源上重用这些方案。

在 Clojure 版本 1.7 之前,您有三种编写数据流查询的方法:

  1. 嵌套调用

(reduce + (filter odd? (map #(+ 2 %) (range 0 10))))
  • 函数组合

  • (def xform
      (comp
        (partial filter odd?)
        (partial map #(+ 2 %))))
    (reduce + (xform (range 0 10)))
    
  • 线程宏

  • (defn xform [xs]
      (->> xs
           (map #(+ 2 %))
           (filter odd?)))
    (reduce + (xform (range 0 10)))
    

    使用转换器,您将像这样编写:

    (def xform
      (comp
        (map #(+ 2 %))
        (filter odd?)))
    (transduce xform + (range 0 10))
    

    它们都是一样的。区别在于你从未直接调用转换器,而是将它们传递给另一个函数。转换器知道该做什么,获取转换器的函数知道如何操作。组合器的顺序就像使用线程宏(自然顺序)编写它一样。现在,您可以将 xform 与通道重复使用:

    (chan 1 xform)
    

    4
    我更希望你能提供一个含有实例的答案,以展示给我如何使用变压器来节省时间。 - yazzapps.com
    6
    这不是一个技术决策,我们只使用基于商业价值的决策。仅仅“使用它们”会让我丢掉工作。 - yazzapps.com
    1
    如果你等到Clojure 1.7发布后再尝试使用transducers,可能会更容易保住你的工作。 - user100464
    10
    变压器似乎是一种很有用的抽象方式,可以处理各种形式的可迭代对象。这些对象可以是非消耗性的,比如Clojure中的序列(seqs),也可以是可消耗性的(例如异步通道)。因此,如果你从一个基于seq的实现转换到使用通道的core.async实现,你将受益于使用变压器。使用变压器应该能够让你保持核心逻辑不变。如果使用传统的基于序列的处理方法,你需要将其转换为使用变压器或一些core.async的类似物。这就是商业案例。 - Nathan Davis
    1
    我想知道是否存在基于“Transducer”的理论。这是函数式语言中的常见定义,还是Clojure作者发明的?它似乎将问题视为类似于“chan”、“数据流”等的东西。 - user2219372
    显示剩余8条评论

    52

    变压器(Transducers)可以提高效率,让您以更模块化的方式编写高效的代码。

    这是一个不错的演示

    与组合调用旧的mapfilterreduce等相比,使用变压器能够实现更好的性能,因为您无需在每个步骤之间构建中间集合,并反复遍历这些集合。

    reducers或手动组合所有操作成为单个表达式相比,使用变压器可以获得更易于使用的抽象、更好的模块化以及处理功能的重复使用。


    2
    只是好奇,你上面说:“在每个步骤之间构建中间集合”。但“中间集合”不是听起来像反模式吗?.NET提供了惰性可枚举,Java提供了惰性流或Guava驱动的可迭代对象,惰性Haskell也一定有懒惰的东西。这些都不需要map/reduce使用中间集合,因为它们都构建了一个迭代器链。我错在哪里了? - Lyubomyr Shaydariv
    3
    Clojure的mapfilter在嵌套时会创建中间集合。 - noisesmith
    4
    至少在Clojure中,惰性的版本上述问题并不相关。是的,map和filter是惰性的,当你将它们链接在一起时,它们也生成惰性值的容器。如果你不保留第一个元素,就不会构建不需要的大型惰性序列,但是你仍然为每个惰性元素构建那些中间抽象。 - noisesmith
    一个例子会很好。 - yazzapps.com
    9
    “中间集合”并不意味着“迭代/实例化整个集合,然后迭代/实例化另一个整个集合”。 noisesmith 的意思是,当您嵌套返回顺序集合的函数调用时,每个函数调用都会创建一个新的顺序集合。实际迭代仍然只发生一次,但由于嵌套的顺序集合,会有额外的内存消耗和对象分配。 - erikprice

    29

    假设你想使用一系列函数来转换数据流。Unix shell 允许您使用管道运算符来执行此类操作,例如:

    cat /etc/passwd | tr '[:lower:]' '[:upper:]' | cut -d: -f1| grep R| wc -l
    

    (上述命令会计算在用户名中字母r的用户数量,不区分大小写)。这是作为一组进程实现的,每个进程从前一个进程的输出中读取数据,因此有四个中间流。你可以想象一个不同的实现方式,将这五个命令组合成一个单一的聚合命令,它将从其输入读取数据并仅写入其输出一次。如果中间流很昂贵而组合很便宜,这可能是一个不错的折衷方案。

    对于Clojure也是同样的道理。有多种方法来表达转换的流水线,但根据你的实现方式,你可能会得到从一个函数传递到下一个函数的中间流。如果您有大量数据,则将这些函数组合成单个函数会更快。Transducers使这变得容易。一个早期的Clojure创新,reducers也可以做到这一点,但有一些限制。Transducers消除了某些限制。

    所以回答你的问题,transducers不一定会使你的代码更短或更易懂,但你的代码可能也不会变得更长或不易懂,如果你正在处理大量的数据,transducers可以使你的代码更快。

    是一个关于transducers的很好的概述。


    1
    啊,所以你的意思是转换器主要是一种性能优化,对吗? - yazzapps.com
    @Zubair 是的,没错。请注意,优化不仅仅是消除中间流;您还可以并行执行操作。 - user100464
    2
    值得一提的是 pmap,它似乎没有得到足够的关注。如果您正在对一个序列进行昂贵的函数映射,那么将操作并行化只需添加 "p" 即可。无需更改代码中的任何其他内容,而且现在就可以使用——不是 alpha 版本,也不是 beta 版本。(如果该函数创建中间序列,则转换器可能会更快,我猜测。) - Mars
    1
    并行计算可以最大化核心使用,但如果处理的是大型/无限数据,则会耗尽内存,因此惰性/流式计算会有所帮助。 - HaveAGuess

    25

    传输器是一种组合方式,用于缩减函数。

    例如: 缩减函数是将两个参数作为输入的函数:到目前为止的结果和一个输入。它们返回一个新的结果(到目前为止)。例如+:使用两个参数,您可以将第一个参数视为到目前为止的结果,将第二个参数视为输入。

    现在,传输器可以接收+函数并将其变成两倍加函数(在添加之前将每个输入值加倍)。这是该传输器的基本形式:

    (defn double
      [rfn]
      (fn [r i] 
        (rfn r (* 2 i))))
    

    为了举例说明,将rfn替换为+,以查看如何将+转换为两个加号:

    (def twice-plus ;; result of (double +)
      (fn [r i] 
        (+ r (* 2 i))))
    
    (twice-plus 1 2)  ;-> 5
    (= (twice-plus 1 2) ((double +) 1 2)) ;-> true
    

    那么

    (reduce (double +) 0 [1 2 3]) 
    

    现在会产生12。

    通过转换器返回的归约函数与结果如何累积无关,因为它们使用传递给它们的归约函数进行累积,而不知道累积方式。在这里,我们使用conj而不是+conj接受一个集合和一个值,并返回一个新的集合,该值已附加到其中。

    (reduce (double conj) [] [1 2 3]) 
    

    会产生[2 4 6]。

    它们也不受输入来源的影响。

    多个转换器可以被链接成一个(可链接的)配方,以转换减少函数。

    更新:由于现在有一个官方页面介绍它,我强烈建议阅读:http://clojure.org/transducers


    1
    解释很好,但很快就用了太多行话,“由转换器生成的缩减函数与结果如何累积无关”。 - yazzapps.com
    1
    你是对的,这里生成的词不合适。 - Leon Grapenthin
    1
    它们是一种组合的方式,用于减少函数。你还能在哪里找到这样的东西?这远不止是一种优化。 - Leon Grapenthin
    我觉得这个答案非常有趣,但是我不清楚它如何与传感器相连(部分原因是因为我仍然觉得这个主题很困惑)。doubletransduce之间的关系是什么? - Mars
    顺便提一下,你引用的页面http://clojure.org/transducers对我来说完全无法理解(我不会Clojure,只会Scheme)。它还错误地说“转换器在决定是否以及如何调用其包装的转换器之前执行其操作”,因为它包装的不是转换器而是缩减器。`(transducer reducer) --> new_reducer才是关键。这就是使(tr1 (tr2 (tr3 reducer)))成为可能的东西。在这里,(reducer elemt accumtor) --> accumtor`(或任何其他参数组合)。那个页面用“whatever”代替“accumtor”简直糟糕透了。 :) - Will Ness
    显示剩余3条评论

    12

    Rich Hickey在2014年奇怪的循环会议(45分钟)上发表了一场有关“Transducers”的演讲。

    他用真实世界的例子 - 机场处理行李来简单地解释了什么是Transducers。他清晰地区分了不同方面并将它们与当前方法进行对比。最后,他解释了它们存在的原因。

    视频:https://www.youtube.com/watch?v=6mTbuzafcII


    8

    我发现阅读来自transducers-js的示例有助于我以日常代码使用它们的具体方式理解它们。

    例如,考虑这个示例(摘自上面链接中的README):

    var t = require("transducers-js");
    
    var map    = t.map,
        filter = t.filter,
        comp   = t.comp,
        into   = t.into;
    
    var inc    = function(n) { return n + 1; };
    var isEven = function(n) { return n % 2 == 0; };
    var xf     = comp(map(inc), filter(isEven));
    
    console.log(into([], xf, [0,1,2,3,4])); // [2,4]
    

    首先,使用xf比使用下划线的常规替代方案更加简洁。

    _.filter(_.map([0, 1, 2, 3, 4], inc), isEven);
    

    为什么传感器示例要长得多?下划线版本看起来更简洁。 - yazzapps.com
    1
    @Zubair 不是真的 t.into([], t.comp(t.map(inc), t.filter(isEven)), [0,1,2,3,4]) - Juan Castañeda

    8
    转换器是(据我理解!)接受一个 reducing 函数并返回另一个函数的功能。一个 reducing 函数是指:

    例如:

    user> (def my-transducer (comp count filter))
    #'user/my-transducer
    user> (my-transducer even? [0 1 2 3 4 5 6])
    4
    user> (my-transducer #(< 3 %) [0 1 2 3 4 5 6])
    3
    

    在这种情况下,my-transducer接受一个输入过滤函数,它将该函数应用于0,然后如果该值是偶数?在第一种情况下,过滤器将该值传递给计数器,然后过滤下一个值。而不是先过滤,然后将所有这些值传递给计数器。
    在第二个示例中,它检查一个值,如果该值小于3,则允许计数器加1。

    1
    我喜欢这个简单的解释。 - Ignacio

    7
    一个传感器的明确定义如下:
    Transducers are a powerful and composable way to build algorithmic transformations that you can reuse in many contexts, and they’re coming to Clojure core and core.async.
    

    为了理解它,让我们考虑以下简单的例子:
    ;; The Families in the Village
    
    (def village
      [{:home :north :family "smith" :name "sue" :age 37 :sex :f :role :parent}
       {:home :north :family "smith" :name "stan" :age 35 :sex :m :role :parent}
       {:home :north :family "smith" :name "simon" :age 7 :sex :m :role :child}
       {:home :north :family "smith" :name "sadie" :age 5 :sex :f :role :child}
    
       {:home :south :family "jones" :name "jill" :age 45 :sex :f :role :parent}
       {:home :south :family "jones" :name "jeff" :age 45 :sex :m :role :parent}
       {:home :south :family "jones" :name "jackie" :age 19 :sex :f :role :child}
       {:home :south :family "jones" :name "jason" :age 16 :sex :f :role :child}
       {:home :south :family "jones" :name "june" :age 14 :sex :f :role :child}
    
       {:home :west :family "brown" :name "billie" :age 55 :sex :f :role :parent}
       {:home :west :family "brown" :name "brian" :age 23 :sex :m :role :child}
       {:home :west :family "brown" :name "bettie" :age 29 :sex :f :role :child}
    
       {:home :east :family "williams" :name "walter" :age 23 :sex :m :role :parent}
       {:home :east :family "williams" :name "wanda" :age 3 :sex :f :role :child}])
    

    如果我们想知道村庄里有多少孩子,可以使用以下 reducer 轻松找到答案:

    ;; Example 1a - using a reducer to add up all the mapped values
    
    (def ex1a-map-children-to-value-1 (r/map #(if (= :child (:role %)) 1 0)))
    
    (r/reduce + 0 (ex1a-map-children-to-value-1 village))
    ;;=>
    8
    

    这里有另一种方法来完成它:
    ;; Example 1b - using a transducer to add up all the mapped values
    
    ;; create the transducers using the new arity for map that
    ;; takes just the function, no collection
    
    (def ex1b-map-children-to-value-1 (map #(if (= :child (:role %)) 1 0)))
    
    ;; now use transduce (c.f r/reduce) with the transducer to get the answer 
    (transduce ex1b-map-children-to-value-1 + 0 village)
    ;;=>
    8
    

    此外,当考虑到子组时,它也非常强大。例如,如果我们想知道布朗家族有多少孩子,我们可以执行以下操作:
    ;; Example 2a - using a reducer to count the children in the Brown family
    
    ;; create the reducer to select members of the Brown family
    (def ex2a-select-brown-family (r/filter #(= "brown" (string/lower-case (:family %)))))
    
    ;; compose a composite function to select the Brown family and map children to 1
    (def ex2a-count-brown-family-children (comp ex1a-map-children-to-value-1 ex2a-select-brown-family))
    
    ;; reduce to add up all the Brown children
    (r/reduce + 0 (ex2a-count-brown-family-children village))
    ;;=>
    2
    

    我希望这些例子能对你有所帮助。你可以在这里找到更多内容。
    祝你好运。
    Clemencio Morales Lucas.

    3
    “转换器是一种强大且可组合的方式,用于构建算法变换,您可以在许多上下文中重复使用它们,并且它们将被添加到Clojure核心和core.async。”这个定义几乎可以适用于任何东西? - yazzapps.com
    1
    对于几乎任何Clojure Transducer,我都会这么说。 - Clemencio Morales Lucas
    6
    这更像是一份使命宣言而不是定义。 - Mars

    4
    我用ClojureScript的示例介绍了这个问题,解释了序列函数是如何通过替换reducing function来实现扩展性的。
    据我所知,这就是transducers的目的。如果你考虑像map、filter等操作中硬编码的cons或conj操作,那么reducing function将无法访问。
    通过使用transducers,reducing function被分离出来,我可以像在本机JavaScript数组中使用push一样替换它。
    (transduce (filter #(not (.hasOwnProperty prevChildMapping %))) (.-push #js[]) #js [] nextKeys)
    
    < p > filter 和其他相关函数现在有一个新的一元操作,它将返回一个转换函数,您可以使用它来提供自己的 reducing 函数。


    4
    以下是我的(大部分)非技术性和代码性答案。
    将数据想象成两种方式,一种是流数据(随着时间而发生的值,例如事件),另一种是结构化数据(在某个时间点存在的数据,例如列表、向量、数组等)。
    您可能希望对流数据或结构化数据执行某些操作。其中一个操作是映射。映射功能可以逐个数据项(假设它是数字)增加1,您可以想象这如何应用于流数据或结构化数据。
    映射函数只是一类有时被称为“缩减函数”的函数之一。另一个常见的缩减函数是过滤器,它删除与谓词匹配的值(例如,删除所有偶数值)。
    Transducers让您“封装”一个或多个缩减函数的序列,并生成一个“包”(本身是一个函数),该函数适用于流数据或结构化数据。例如,您可以“封装”一系列缩减函数(例如过滤偶数值,然后将结果数字映射到增加1),然后在值(或两者)的流数据或结构化数据上使用该转换器“包”。
    那么这有什么特别之处呢?通常,缩减函数无法有效地组合以处理流数据和结构化数据。
    因此,您的好处是可以利用您对这些函数的知识,并将它们应用于更多用例。您要付出的代价是,必须学习一些额外的机制(即转换器)来为您提供此额外的功能。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接