译者 | TalkingData 李冰心
原文 | https://martinfowler.com/articles/collection-pipeline
前一篇文章中,我们了解了集合管道:集合管道是一种编程模式,将一些计算转化为一系列操作,通常情况下每个操作的输出结果是一个集合,同时该结果作为下一个操作的输入,常见的操作主要有filter、map和reduce。今天我们继续了解集合管道模式的定义等。
二、定义
我认为集合管道是一种指导我们如何模块化和构建软件的模式。和多数模式一样,它经常出现在各种场景中,虽然对此我们习以为常,但是这种模式却别具一格。模式可以解决特定的设计问题,帮助设计者将新的设计建立在以往工作的基础上,复用以往成功的设计方案。
集合管道展示了一系列彼此间传递集合的操作,这些操作的输入输出都是集合,但是其中不包括终端操作,因为终端操作只会输出单个结果。个别的操作可能非常简单,但是你可以使用各种简单操作构造复杂的行为,想象一下现实世界中纵横交错的管道。
集合管道是管道过滤器模式的一个特例,管道过滤器中的过滤相当于集合管道中的操作,之所以没有使用 "过滤" 这个词语,因为 "过滤" 是一种常用的管道操作名称。从另一个角度看,集合管道是一种组成高阶函数的特殊方式,其中涉及的所有函数均作用于某种形式的数据结构,该模式没有确切的名称,需要使用一个新的术语。
操作彼此间传递的信息在不同的环境中有着不同的形式:
在 Unix 中集合是一个由多行文本组成的文件,各种值通过空格连接组成了其中的行,每个值具体表示的含义依赖于行中的排序。管道操作符可以将某个操作的输出重定向到下个操作的输入,集合由管道操作符组成,操作在 Unix 中表示进程。
在面向对象程序中集合用集合类表示,例如 list、array 和 set 等。集合中的每个元素都是对象,对象可以是普通类或集合类的实例。操作是集合类本身(或基类)中定义的各种方法,可以由方法链组成。
在函数式语言中集合与面向对象语言有些类似,集合元素可以定义复杂的层次结构,操作是函数,可以通过嵌套或者使用形成线性表示的运算符组成,例如 Clojure 的箭头运算符。
这种模式也会出现在其它地方。当关系模型首次定义时,其假定所有数据都表示为数学上的关系,就是说n个集合的笛卡儿积的一个子集,数据可以通过关系演算和关系代数的一种方式来操作,你可以将其视作一个集合管道,操作中产生的中间集合被约束为关系。SQL最初作为关系数据库的标准语言而提出,而在实际上总是违背它。所以SQL DBMS实际上不是真正的RDBMS,并且当前ISO SQL标准不提及关系模型或者使用关系术语或概念,SQL使用了一种类似于推导的方式(稍后我会讨论)。
这样一系列转换的概念是软件构建中常见的方法,这也是管道过滤器模式的设计意图。编译器工作原理相似,将源码转换为语法树,途经各种优化,最后输出目标代码。 关于集合管道的区别:各阶段公用的数据结构是集合,最后限定一组特定的公共管道操作。
三、探索更多管道和操作之 map 和 reduce
到目前为止,涉及的是一些常用的管道操作,接下来通过 Ruby 事例代码,让我们来探索更多的操作。诚然使用其它支持该模式的语言,也会构造相同形式的管道。
统计单词总数(map 和 reduce)
Markdown
通过统计所有文章单词总数的例子,让我来介绍下两个最重要的管道操作。
第一个 map:使用给定的 lambda 表达式,作用于输入集合的每个元素,将 lambda 表达式结果以集合的方式返回。
[1, 2, 3].map{|i| i * i} # => [1, 4, 9]
通过使用 map 将文章列表转换为每篇文章单词总数列表。
第二个 reduce:输入集合经过累计运算,最终输出单个结果。具有类似功能的任何函数都可以称作 Reduction,Reduction 在集合管道中总是以终结者的身份最后登场。通常情况下,可以使用两个入参的 lambda 表达式来定义 Ruby 中的 reduce 函数,一个入参是集合元素,一个是累加器。 在 reduce 的过程中,使用 lambda 表达式作用于每个元素,累加器会累计每次 lambda 的返回结果。接下来你可以这样求和:
[1, 2, 3].reduce {|acc, each| acc + each} # => 6
之后使用 map 和 reduce 构造两步操作的管道来统计单词总数。
some_articles .map{|a| a.words} .reduce {|acc, w| acc + w}
第一步使用 map 将文章列表转换为每篇文章单词数列表,第二步使用 reduce 累计求和。
在这点上,值得一提的是管道上的操作你可以使用不同的方式定义,上面使用的是 lambda,其实仅使用函数名称也是可以的,例如在 Clojure 中:
(->> (articles) (map :words) (reduce +))
该场景中,你只需要关注函数名称,对于 Ruby 也可以使用同样的风格:
some_articles .map(&:words) .reduce(:+)
通常情况下使用函数名称看上去更精炼,但是你会受限于函数的声明和调用方式。lambdas 可以提供更大的灵活性,但是你需要了解更多的语法。关于使用何种语言构造管道,如果 Ruby,我倾向于使用 lambda,如果 Clojure,则是函数名称。具体使用何种方式,你可以自由选择。
四、探索更多管道和操作之 group-by
统计每种类型的文章数(group-by)
Markdown
接下来我们会统计每种类型的文章数,依据统计结果输出的形式,需要使用一个键是类型值是文章数的 hashmap 。
为了解决这个问题,首先我们需要根据类型对所有文章进行分组,这里使用的集合操作就是 group-by,通过使用该操作,会将所有元素射到 hash 中,而索引值依据在此元素上执行给定代码的返回结果。 让我们来看看具体使用的细节:
some_articles .group_by {|a| a.type}
然后需要统计每种类型下的文章数。你很可能这样认为,不就是一个简单的 map 操作吗?但实则不然,因为这里需要返回两种维度:分组和数量。这和我们之前介绍 map 的例子有些许联系,但是此时需要使用 group-by 输出 hashmap。
想想开篇中 Unix 的命令行,这个问题在 Unix 中是很常见。集合通常以 list 形式出现,但有时却是 hash,有时候需要在二者之间来回转换。有个取巧的做法是将 hash 视作键值对列表,其中键值对是一种独立结构。关于如何定义 hash 每种语言略有差异,但通常是这样:[key, value]。
Ruby 提供了 to_h 方法可以将数组集合转为 hash:
some_articles .group_by {|a| a.type} .map {|pair| [pair[0], pair[1].size]} .to_h
在管道中 list 和 hash 经常这样互转,但是访问 hash 却需要使用数组下标的方式访问,多少有些怪异,而 Ruby 可以将其解构为两个独立的变量:
some_articles .group_by {|a| a.type} .map {|key, value| [key, value.size ]} .to_h
在函数式编程语言中解构是一种常见的技术,但是传递这些 list-of-hash 数据结构性能上势必会有所损耗。Ruby 的解构语法非常简单,而且足以达到这个简单目的。
同样 Clojure 更是如此:
(->> (articles) (group-by :type) (map (fn [[k v]] [k (count v)])) (into {}))