集合管道

集合管道是一种编程模式,您将某些计算组织为一系列操作,这些操作通过将一个操作的输出集合作为输入传递给下一个操作来组合。(常见的操作包括过滤、映射和归约。)这种模式在函数式编程中很常见,在具有 lambda 的面向对象语言中也很常见。本文通过几个关于如何形成管道的示例来描述这种模式,既是为了向不熟悉这种模式的人介绍它,也是为了帮助人们理解核心概念,以便他们能够更容易地将想法从一种语言迁移到另一种语言。

2015 年 6 月 25 日



集合管道是软件中最常见、最令人愉快的模式之一。它存在于 Unix 命令行、更好的 OO 语言中,并且近年来在函数式语言中备受关注。不同的环境有略微不同的形式,常见的操作也有不同的名称,但一旦您熟悉这种模式,您就不想再没有它了。

初次邂逅

我第一次接触集合管道模式是在我开始使用 Unix 时。例如,假设我想找到所有在文本中提到“nosql”的 bliki 条目。我可以使用 grep 来做到这一点

grep -l 'nosql' bliki/entries

然后我可能想找出每个条目中有多少个单词

grep -l 'nosql' bliki/entries/* | xargs wc -w 

也许按词数对它们进行排序

grep -l 'nosql' bliki/entries/* | xargs wc -w | sort -nr

然后只打印前 3 个(删除总数)

grep -l 'nosql' bliki/entries/* | xargs wc -w | sort -nr | head -4 | tail -3

与我之前(或之后)遇到的其他命令行环境相比,这非常强大。

后来,当我开始使用 Smalltalk 时,我发现了相同的模式。假设我有一个文章对象的集合(在 someArticles 中),每个对象都有一个标签集合和一个词数。我可以使用以下代码选择只有那些带有 #nosql 标签的文章

 someArticles select: [ :each | each tags includes: #nosql]

select 方法接受一个参数 Lambda(由方括号定义,在 Smalltalk 中称为“块”),它定义了一个布尔函数,该函数应用于 someArticles 中的每个元素,并返回一个仅包含 lambda 解析为 true 的文章的集合。

要对该代码的结果进行排序,我需要扩展代码。

(someArticles
      select: [ :each | each tags includes: #nosql]) 
      sortBy: [:a :b | a words > b words]

sortBy 方法是另一个接受 lambda 的方法,这次是用于对元素进行排序的代码。与 select 一样,它返回一个新的集合,因此我可以继续管道

((someArticles 
      select: [ :each | each tags includes: #nosql])
      sortBy: [:a :b | a words > b words]) 
      copyFrom: 1 to: 3

与 Unix 管道的核心相似之处在于,每个涉及的方法(selectsortBycopyFrom)都对记录集合进行操作并返回记录集合。在 Unix 中,该集合是一个流,记录是流中的行,在 Smalltalk 中,该集合是对象,但基本概念是相同的。

如今,我在 Ruby 中进行更多编程,Ruby 的语法使设置集合管道变得更加容易,因为我不必将管道的早期阶段括在括号中

some_articles
  .select{|a| a.tags.include?(:nosql)}
  .sort_by{|a| a.words}
  .take(3)

在使用面向对象编程语言时,将集合管道形成方法链是一种自然的方法。但同样的想法也可以通过嵌套函数调用来实现。

为了回到一些基本知识,让我们来探讨一下如何在 Common Lisp 中设置类似的管道。我可以将每篇文章存储在一个名为 articles 的结构中,这允许我使用像 article-wordsarticle-tags 这样的函数来访问字段。some-articles 函数返回我开始使用的那些文章。

第一步是只选择 nosql 文章。

(remove-if-not
   (lambda (x) (member 'nosql (article-tags x)))
   (some-articles))

与 Smalltalk 和 Ruby 的情况一样,我使用了一个名为 remove-if-not 的函数,它接受要操作的列表和一个定义谓词的 lambda。然后我可以扩展表达式来对它们进行排序,同样使用一个 lambda

(sort
   (remove-if-not
      (lambda (x) (member 'nosql (article-tags x)))
      (some-articles))
   (lambda (a b) (> (article-words a) (article-words b))))

然后我使用 subseq 选择前 3 个。

(subseq
   (sort
      (remove-if-not
         (lambda (x) (member 'nosql (article-tags x)))
         (some-articles))
      (lambda (a b) (> (article-words a) (article-words b))))
 0 3)

管道就在那里,您可以看到它随着我们一步步进行而很好地构建起来。但是,一旦您查看最终表达式 [1],它的管道性质是否清晰就值得怀疑了。Unix 管道以及 Smalltalk/Ruby 管道具有与执行顺序匹配的函数的线性排序。您可以轻松地可视化数据从左上角开始,并通过各种过滤器向右和/或向下工作。Lisp 使用嵌套函数,因此您必须通过从最深的函数向上读取来解析排序。

流行的最新 Lisp,Clojure,避免了这个问题,允许我这样写。

(->> (articles)
     (filter #(some #{:nosql} (:tags %)))
     (sort-by :words >)
     (take 3))

->> 符号是一个线程宏,它使用 Lisp 强大的语法宏功能将每个表达式的结果线程到下一个表达式的参数中。只要您在库中遵循约定(例如,使主题集合成为每个转换函数的最后一个参数),您就可以使用它将一系列嵌套函数转换为线性管道。

然而,对于许多函数式程序员来说,使用像这样的线性方法并不重要。这些程序员很好地处理嵌套函数的深度排序,这就是为什么像 ->> 这样的操作符花了这么长时间才进入流行的 Lisp 中。

如今,我经常听到函数式编程的粉丝赞扬集合管道的优点,说它们是函数式语言的强大功能,而 OO 语言则缺乏这种功能。作为一个老 Smalltalk 程序员,我对此感到相当恼火,因为 Smalltalk 程序员广泛使用它们。人们说集合管道不是 OO 编程的特性,原因是像 C++、Java 和 C# 这样的流行 OO 语言没有采用 Smalltalk 对 lambda 的使用,因此没有支持集合管道模式的丰富的集合方法数组。因此,对于大多数 OO 程序员来说,集合管道消失了。像我这样的 Smalltalk 程序员在 Java 成为城里的大事时诅咒了 lambda 的缺乏,但我们不得不忍受它。我们尝试使用 Java 中我们可以使用的任何东西来构建集合管道;毕竟,对于 OO 程序员来说,函数只是一个只有一个方法的类。但最终的代码非常混乱,即使是熟悉这种技术的人也倾向于放弃。Ruby 对集合管道的舒适支持是我在 2000 年左右开始大量使用 Ruby 的主要原因之一。我从 Smalltalk 时代开始就非常怀念这些东西。

如今,lambda 已经摆脱了它们作为高级且难以使用的语言特性的声誉。在主流语言中,C# 已经拥有它们好几年了,甚至 Java 也终于加入了进来。 [2] 因此,现在许多语言都支持集合管道。

定义集合管道

我认为集合管道是一种关于如何模块化和组合软件的模式。像大多数模式一样,它出现在很多地方,但表面上看起来却有所不同。但是,如果您理解了底层模式,那么它将使您更容易弄清楚在新的环境中想要做什么。

集合管道列出了一系列操作,这些操作在它们之间传递项目集合。每个操作都接受一个集合作为输入,并输出另一个集合(最后一个操作除外,它可能是一个输出单个值的终端)。单个操作很简单,但您可以通过将各种操作串联起来来创建复杂的行为,就像您在物理世界中将管道连接在一起一样,因此得名管道隐喻。

集合管道是 管道和过滤器模式 的特例。管道和过滤器中的过滤器对应于集合管道中的操作,我用操作替换了“过滤器”,因为过滤器是集合管道中的一种常见操作名称。从另一个角度来看,集合管道是组合高阶函数的一种特殊情况,但很常见,其中所有函数都作用于某种形式的序列数据结构。这种模式没有确定的名称,因此我觉得有必要使用一个 新词

在不同的上下文中,操作和在操作之间传递的集合采用不同的形式。

在 Unix 中,集合是一个文本文件,其项目是文件中的行。每行包含各种值,用空格隔开。每个值的含义由它在行中的顺序给出。操作是 Unix 进程,集合是使用管道运算符组合的,一个进程的标准输出被管道连接到下一个进程的标准输入。

在面向对象程序中,集合是一个集合类(列表、数组、集合等)。集合中的项目是集合中的对象,这些对象本身可能也是集合,或者包含更多集合。操作是集合类本身定义的各种方法 - 通常是在某个高级超类上。操作是通过方法链组合的。

在函数式语言中,集合与面向对象语言中的集合类似。但是,这次的项目本身是通用的集合类型 - 其中 OO 集合管道将使用对象,函数式语言将使用哈希映射。顶层集合的元素本身可能也是集合,哈希映射的元素也可能也是集合 - 因此,与面向对象的情况一样,我们可以拥有任意复杂的层次结构。操作是函数,它们可以通过嵌套或能够形成线性表示的操作符(例如 Clojure 的箭头操作符)来组合。

这种模式也出现在其他地方。当关系模型首次被定义时,它带有一个 关系代数,您可以将其视为一个集合管道,其中中间集合被限制为关系。但 SQL 没有使用管道方法,而是使用了一种类似于推导的方法(我将在后面讨论)。

像这样的一系列转换的概念是构建程序的常见方法 - 因此收获了管道和过滤器架构模式。编译器通常以这种方式工作,从源代码转换到语法树,再经过各种优化,最后转换为输出代码。集合管道的区别在于,阶段之间的通用数据结构是一个集合,这导致了一组特定的常见管道操作。

探索更多管道和操作

到目前为止,我使用过的一个示例管道只使用了一些在集合管道中常见的操作。所以现在我将通过一些示例探索更多操作。我将坚持使用 ruby,因为我最近对这种语言更熟悉,但相同的管道通常可以在支持这种模式的其他语言中形成。

获取总词数(映射和归约)

- title: NoDBA
  words: 561
  tags: [nosql, people, orm]
  type: :bliki
- title: Infodeck
  words: 1145
  tags: [nosql, writing]
  type: :bliki
- title: OrmHate
  words: 1718
  tags: [nosql, orm]
  type: :bliki
- title: ruby
  words: 1313
  tags: [ruby]
  type: :article
- title: DDD_Aggregate
  words: 482
  tags: [nosql, ddd]
  type: :bliki
5219

两个最重要的管道操作可以用一个简单的任务来解释——如何获取列表中所有文章的总词数。第一个操作是 map,它返回一个集合,其成员是将给定 lambda 应用于输入集合中每个元素的结果。

[1, 2, 3].map{|i| i * i} # => [1, 4, 9]

因此,如果我们使用它,我们可以将文章列表转换为每篇文章的词数列表。此时,我们可以应用一个更笨拙的集合管道操作:reduce。此操作将输入集合缩减为单个结果。通常,执行此操作的任何函数都称为缩减。缩减通常缩减为单个值,然后只能作为集合管道中的最后一步出现。ruby 中的通用 reduce 函数接受一个 lambda,它有两个变量,一个用于每个元素,另一个用于累加器。在缩减的每个步骤中,它将累加器的值设置为使用新元素评估 lambda 的结果。然后,您可以像这样对数字列表求和

[1, 2, 3].reduce {|acc, each| acc + each} # => 6

有了这两个操作,计算总词数就是一个两步管道。

some_articles
  .map{|a| a.words}
  .reduce {|acc, w| acc + w}

第一步是将文章列表转换为词数列表的映射。第二步对词数列表进行缩减以创建总和。

您可以将函数作为 lambda 或通过已定义函数的名称传递到管道操作中

此时,值得一提的是,您可以用几种不同的方式来表示构成集合管道中步骤的函数。到目前为止,我为每个步骤使用了一个 lambda,但另一种选择是只使用函数的名称。在 clojure 中编写此管道,自然的方式是

(->> (articles)
     (map :words)
     (reduce +))

在这种情况下,只需要相关函数的名称就足够了。传递给 map 的函数在输入集合的每个元素上运行,reduce 在每个元素和累加器上运行。您也可以在 ruby 中使用相同的风格,这里 words 是在集合中每个对象上定义的方法。 [3]

some_articles
  .map(&:words)
  .reduce(:+)

通常,使用函数的名称会更短一些,但您仅限于对每个对象进行简单的函数调用。使用 lambda 会让您更灵活一些,但语法也会更复杂一些。当我在 Ruby 中编程时,我倾向于大多数时候使用 lambda,但如果我在 Clojure 中工作,我会更倾向于在可以的情况下使用函数名称。您选择哪种方式并不重要。 [4]

获取每种类型的文章数量(分组)

- title: NoDBA
  words: 561
  tags: [nosql, people, orm]
  type: :bliki
- title: Infodeck
  words: 1145
  tags: [nosql, writing]
  type: :bliki
- title: OrmHate
  words: 1718
  tags: [nosql, orm]
  type: :bliki
- title: ruby
  words: 1313
  tags: [ruby]
  type: :article
- title: DDD_Aggregate
  words: 482
  tags: [nosql, ddd]
  type: :bliki
{bliki: 4, article: 1}

对于我们的下一个管道示例,让我们找出每种类型的文章有多少。我们的输出是一个单一的哈希映射,其键是类型,值是相应的文章数量。 [5]

为了实现这一点,我们首先需要按文章类型对文章列表进行分组。这里要使用的集合运算符是 group-by 操作。此操作将元素放入一个哈希中,该哈希按在其元素上执行其提供的代码的结果进行索引。我可以使用此操作根据文章的标签数量将文章分成组。

some_articles
  .group_by {|a| a.type}

我现在需要做的就是计算每组中的文章数量。从表面上看,这对 map 操作来说是一个简单的任务,只需对文章数量进行计数即可。但这里的问题是,我需要为每组返回两部分数据:组的名称和计数。一个更简单但相关的問題是,我们之前看到的 map 示例使用的是值列表,但 group-by 操作的输出是哈希映射。

将哈希映射视为键值对列表通常很有用。

这个问题在集合管道中很常见,一旦我们超越了简单的 unix 示例。我们可能传递的集合通常是列表,但也可能是哈希。我们需要轻松地在两者之间进行转换。这样做的诀窍是将哈希视为一对列表——其中每一对都是键和对应值。哈希的每个元素的具体表示方式因语言而异,但一种简单(且常见)的方法是将每个哈希元素视为一个包含两个元素的数组:[key, value]

Ruby 正是这么做的,并且还允许我们使用 to_h 方法将一对数组转换为哈希。因此,我们可以应用如下映射

some_articles
  .group_by {|a| a.type}
  .map {|pair| [pair[0], pair[1].size]}
  .to_h

这种在哈希和数组之间来回切换的方式在集合管道中很常见。使用数组查找访问对有点笨拙,因此 Ruby 允许我们像这样直接将对解构为两个变量。

some_articles
  .group_by {|a| a.type}
  .map {|key, value| [key, value.size ]}
  .to_h

解构是一种在函数式编程语言中很常见的技术,因为它们花费大量时间传递这些列表-哈希数据结构。Ruby 的解构语法非常简单,但足以满足这个简单的目的。

在 clojure 中执行此操作几乎相同: [6]

(->> (articles)
     (group-by :type)
     (map (fn [[k v]] [k (count v)]))
     (into {}))

获取每个标签的文章数量

- title: NoDBA
  words: 561
  tags: [nosql, people, orm]
  type: :bliki
- title: Infodeck
  words: 1145
  tags: [nosql, writing]
  type: :bliki
- title: OrmHate
  words: 1718
  tags: [nosql, orm]
  type: :bliki
- title: ruby
  words: 1313
  tags: [ruby]
  type: :article
- title: DDD_Aggregate
  words: 482
  tags: [nosql, ddd]
  type: :bliki
:nosql:
  :articles: 4
  :words: 3906
:people:
  :articles: 1
  :words: 561
:orm:
  :articles: 2
  :words: 2279
:writing:
  :articles: 1
  :words: 1145
:ruby:
  :articles: 1
  :words: 1313
:ddd:
  :articles: 1
  :words: 482

对于下一个管道,我们将生成列表中提到的每个标签的文章和词数。这样做涉及到对集合数据结构的相当大的重组。目前,我们的顶级项目是文章,它可能包含多个标签。要做到这一点,我们需要解开数据结构,以便我们的顶级项目是一个包含多个文章的标签。思考这个问题的一种方式是,我们正在反转多对多关系,以便标签是聚合元素而不是文章。

此示例反转了多对多关系

这种对集合层次结构的重组,从启动管道开始,使得管道更加复杂,但仍然在这个模式的能力范围内。对于这样的事情,重要的是将其分解成小的步骤。当您将完整的转换分解成小片段并将其串联在一起时,这种转换通常更容易推理——这就是集合管道模式的全部意义。

第一步是关注标签,展开数据结构,以便我们为每个标签-文章组合创建一个记录。我认为这有点像您在关系数据库中使用关联表来表示多对多关系的方式。为此,我创建了一个 lambda,它接受一篇文章并为每个标签和文章发出一个对(包含两个元素的数组)。然后,我将此 lambda 映射到所有文章中。

some_articles
  .map {|a| a.tags.map{|tag| [tag, a]}}   

这将产生如下结构

[
  [ [:nosql, Article(NoDBA)]
    [:people, Article(NoDBA)]
    [:orm, Article(NoDBA)]
  ]
  [ [:nosql, Article(Infodeck)]
    [:writing, Article(Infodeck)]
  ]
  # more rows of articles
]

map 的结果是一个包含对的列表的列表,每个文章有一个嵌套列表。该嵌套列表是多余的,因此我使用 flatten 操作将其展平。

some_articles
  .map {|a| a.tags.map{|tag| [tag, a]}}   
  .flatten 1

产生

[
  [:nosql, Article(NoDBA)]
  [:people, Article(NoDBA)]
  [:orm, Article(NoDBA)]
  [:nosql, Article(Infodeck)]
  [:writing, Article(Infodeck)]
  # more rows of articles
]

生成一个具有不必要嵌套级别的列表,需要将其展平,这是一个非常常见的任务,大多数语言都提供了 flat-map 操作,可以在一步中完成此操作。

some_articles
  .flat_map {|a| a.tags.map{|tag| [tag, a]}}    

一旦我们有了这样的对列表,那么按标签对其进行分组就是一个简单的任务

some_articles
  .flat_map {|a| a.tags.map{|tag| [tag, a]}}
  .group_by {|pair| pair.first}

产生

{
  :people:
    [ [:people, Article(NoDBA)] ]
  :orm:
    [ [:orm, Article(NoDBA)]
      [:orm, Article(OrmHate)]
    ]
  :writing:
    [  [:writing, Article(Infodeck)] ]
  # more records
}

但与我们的第一步一样,这引入了令人讨厌的额外嵌套级别,因为每个关联的值是一个键/文章对列表,而不是仅仅是一个文章列表。我可以通过将一个函数映射到用文章列表替换对列表来修剪它。

some_articles
  .flat_map {|a| a.tags.map{|tag| [tag, a]}}
  .group_by {|pair| pair.first}
  .map {|k,pairs| [k, pairs.map {|p| p.last}]}        

这将产生

{
  :people:    [ Article(NoDBA) ]
  :orm:       [ Article(NoDBA), Article(OrmHate) ]
  :writing:   [ Article(Infodeck) ]
  # more records
}

现在,我已经将基本数据重组为每个标签的文章,反转了多对多关系。要生成所需的结果,我只需要一个简单的 map 来提取我需要的确切数据

some_articles
  .flat_map {|a| a.tags.map{|tag| [tag, a]}}
  .group_by {|pair| pair.first}
  .map {|k,pairs| [k, pairs.map {|p| p.last}]}    
  .map {|k,v| [k, {articles: v.size, words: v.map(&:words).reduce(:+)}]}
  .to_h

这将产生最终的数据结构,它是一个哈希的哈希。

:nosql:
  :articles: 4
  :words: 3906
:people:
  :articles: 1
  :words: 561
:orm:
  :articles: 2
  :words: 2279
:writing:
  :articles: 1
  :words: 1145
:ruby:
  :articles: 1
  :words: 1313
:ddd:
  :articles: 1
  :words: 482

在 Clojure 中执行相同的任务采用相同的形式。

(->> (articles)
     (mapcat #(map (fn [tag] [tag %]) (:tags %)))
     (group-by first)
     (map (fn [[k v]] [k (map last v)]))
     (map (fn [[k v]] {k {:articles (count v), :words (reduce + (map :words v))}}))
     (into {}))

Clojure 的 flat-map 操作称为 mapcat。

构建一个更复杂的管道,比如这个,可能比我之前展示的简单的管道更困难。我发现最简单的方法是每次仔细地执行每个步骤,仔细查看每个步骤的输出集合,以确保它具有正确的形状。可视化此形状通常需要某种形式的漂亮打印,以使用缩进显示集合的结构。在滚动测试优先风格中执行此操作也很有用,首先编写测试,对数据的形状进行一些简单的断言(例如,第一步的记录数量),并在向管道添加更多阶段时逐步完善测试。

我在这里的管道在从每个阶段构建它方面是有意义的,但最终的管道并没有太清楚地揭示正在发生的事情。最初的阶段实际上都是关于按每个标签索引文章列表的,所以我认为通过将该任务提取到它自己的函数中,它会更容易阅读。

(defn index-by [f, seq]
    (->> seq
         (mapcat #(map (fn [key] [key %]) (f %)))
         (group-by first)
         (map (fn [[k v]] [k (map last v)]))))
(defn total-words [articles] 
    (reduce + (map :words articles)))
  
(->> (articles)
     (index-by :tags)
     (map (fn [[k v]] {k {:articles (count v), :words (total-words v)}}))
     (into {}))

我还觉得将词数分解到它自己的函数中是值得的。分解增加了行数,但我认为如果它更容易理解,我总是很乐意添加一些结构化代码。简洁、强大的代码很好——但简洁只有在清晰度的服务下才有价值。

要在像 Ruby 这样的面向对象语言中执行相同的分解,我需要将新的 index_by 方法添加到集合类本身,因为我只能在管道中使用集合自己的方法。在 Ruby 中,我可以使用猴子补丁对 Array 进行此操作

class Array
  def invert_index_by &proc
    flat_map {|e| proc.call(e).map {|key| [key, e]}}
      .group_by {|pair| pair.first}
      .map {|k,pairs| [k, pairs.map {|p| p.last}]}         
  end
end

我在这里更改了名称,因为简单的名称“index_by”在本地函数的上下文中是有意义的,但作为集合类上的通用方法却没有那么有意义。需要将方法放在集合类上可能是 OO 方法的一个严重缺点。有些平台根本不允许您向库类添加方法,这排除了这种分解。其他平台允许您使用猴子补丁来修改类,但这会导致对类的 API 进行全局可见的更改,因此您必须比本地函数更仔细地考虑它。这里最好的选择是使用 C# 的扩展方法或 Ruby 的细化等机制,这些机制允许您更改现有类,但仅在较小的命名空间的上下文中。但即使那样,与定义函数的简单行为相比,添加猴子补丁也需要很多仪式。

一旦我定义了该方法,我就可以以类似于 clojure 示例的方式分解管道。

total_words = -> (a) {a.map(&:words).reduce(:+)}
some_articles
  .invert_index_by {|a| a.tags}   
  .map {|k,v| [k, {articles: v.size, words: total_words.call(v)}]}
  .to_h

在这里,我还像 Clojure 案例一样分解了词数函数,但我发现分解在 ruby 中效果不佳,因为我必须使用显式方法来调用我创建的函数。这并不多,但确实会给可读性带来一些摩擦。当然,我可以将其变成一个完整的方法,这样就可以摆脱调用语法。但我倾向于在这里更进一步,添加一个类来包含摘要函数。

class ArticleSummary
  def initialize articles
    @articles = articles
  end
  def size
    @articles.size
  end
  def total_words
    @articles.map{|a| a.words}.reduce(:+)
  end
end

像这样使用它

some_articles
  .invert_index_by {|a| a.tags}   
  .map {|k,v| [k, ArticleSummary.new(v)]}
  .map {|k,a| [k, {articles: a.size, words: a.total_words}]}
  .to_h

许多人会觉得引入一个全新的类只是为了在一个使用中分解几个函数太繁重了。我毫不犹豫地为这样的本地工作引入一个类。在这种特殊情况下,我不会这样做,因为它实际上只有总词数函数需要提取,但我只需要在输出中添加一点点内容就可以使用该类。

替代方案

集合管道模式并不是完成我迄今为止讨论过的事情的唯一方法。最明显的替代方案是大多数人通常在这些情况下使用的方法:简单的循环。

使用循环

我将比较前 3 名 NoSQL 文章的 ruby 版本。

集合管道循环
some_articles
  .select{|a| a.tags.include?(:nosql)}
  .sort_by{|a| a.words}
  .take(3)
result = []
some_articles.each do |a|
  result << a if a.tags.include?(:nosql)   
end
result.sort_by!(&:words)
return result[0..2]

集合管道版本略短,在我看来更清晰,主要是因为管道概念是我熟悉且自然清晰的。也就是说,循环版本并没有差很多。

这是词数情况。

集合管道循环
some_articles
  .map{|a| a.words}
  .reduce {|acc, w| acc + w}
result = 0
some_articles.each do |a|
  result += a.words
end
return result

分组情况

集合管道循环
some_articles
  .group_by {|a| a.type}
  .map {|pair| [pair[0], pair[1].size]}
  .to_h
result = Hash.new(0)
some_articles.each do |a|
  result[a.type] += 1
end
return result

按标签计算文章数量

集合管道循环
some_articles
  .flat_map {|a| a.tags.map{|tag| [tag, a]}}
  .group_by {|pair| pair.first}
  .map {|k,pairs| [k, pairs.map {|p| p.last}]}    
  .map {|k,v| [k, {articles: v.size, words: v.map(&:words).reduce(:+)}]}
  .to_h
result = Hash.new([])
some_articles.each do |a|
  a.tags.each do |t|
    result[t] += [a]
  end
end
result.each do |k,v|
  word_count = 0
  v.each do |a|
    word_count += a.words
  end
  result[k] = {articles: v.size, words: word_count}
end
return result

在这种情况下,集合管道版本要短得多,尽管很难比较,因为无论哪种情况,我都会重构以在两种情况下都体现出意图。

使用推导

一些语言有用于理解的构造,通常称为列表理解,它反映了简单的集合管道。考虑检索所有超过一千字的文章的标题。我将用 coffeescript 来演示这一点,coffeescript 有一个理解语法,但也可以使用 Javascript 自己的创建集合管道的能力。

管道理解
some_articles
  .filter (a) ->
    a.words > 1000
  .map (a) ->
    a.title
(a.title for a in some_articles when a.words > 1000)

理解的具体功能因语言而异,但你可以将它们视为可以在单个语句中表达的特定操作序列。这种思考方式阐明了何时使用它们的第一个决定部分。理解只能用于管道操作的某些组合,因此它们从根本上来说灵活性较差。也就是说,理解是为最常见的情况定义的,因此在许多情况下它们仍然是一个选择。

理解通常可以放在管道本身中 - 本质上充当单个操作。因此,要获得所有超过 1000 字的文章的总字数,我可以使用

管道管道中的理解
some_articles
  .filter (a) ->
    a.words > 1000
  .map (a) ->
    a.words
  .reduce (x, y) -> x + y
(a.words for a in some_articles when a.words > 1000)
.reduce (x, y) -> x + y

那么问题是,对于理解适用的情况,理解是否比管道更好。理解的粉丝说它们是,其他人可能会说管道同样易于理解且更通用。(我属于后者。)

嵌套操作符表达式

集合的一个有用功能是使用集合操作对其进行操作。因此,假设我正在查看一家酒店,它具有返回红色、蓝色、位于酒店前部或已入住的房间的功能。然后,我可以使用表达式来查找酒店前部未入住的红色或蓝色房间。

ruby…
(front & (red | blue)) - occupied

clojure…
(difference
 (intersection
  (union reds blues)
  fronts)
 occ)

Clojure 在其集合数据类型上定义了集合操作,因此这里的所有符号都是集合。

我可以将这些表达式公式化为集合管道

ruby…
red
  .union(blue)
  .intersect(front)
  .diff(occupied)

我用猴子补丁修补了 Array 以将集合操作添加为常规方法

clojure…
(->> reds
     (union blues)
     (intersection fronts)
     (remove occ))

我需要 clojure 的“remove”方法,以便以正确的顺序获取参数以进行线程化。

但我更喜欢 嵌套运算符表达式 形式,尤其是在我可以使用中缀运算符时。更复杂的表达式可能会变得非常混乱,就像管道一样。

也就是说,在管道中间添加集合操作通常很有用。让我们想象一下,房间的颜色和位置是房间记录的属性,但已入住房间的列表在单独的集合中。

ruby…
rooms
  .select{|r| [:red, :blue].include? r.color}
  .select{|r| :front == r.location}
  .diff(occupied)

clojure…
(->> (rooms)
     (filter #( #{:blue :red} (:color %)))
     (filter #( #{:front} (:location %)))
     (remove (set (occupied))))

这里我展示了 (set (occupied)) 来展示如何在 clojure 中使用包裹在集合上的集合作为集合成员资格的谓词。

虽然中缀运算符适用于嵌套运算符表达式,但它们不适用于管道,这会导致一些令人讨厌的括号。

ruby…
((rooms
  .select{|r| [:red, :blue].include? r.color}
  .select{|r| :front == r.location}
  ) - occupied)
  .map(&:num)
  .sort

关于集合操作需要牢记的另一点是,集合通常是列表,它们是有序的并且允许重复。您必须查看库的具体情况,以了解集合操作的含义。Clojure 强制您在对列表使用集合操作之前将其转换为集合。Ruby 会将任何数组接受到其集合操作中,但在其输出中删除重复项,同时保留输入顺序。

惰性

惰性的概念来自函数式编程世界。动机可能是这样的代码

large_list
  .map{|e| slow_complex_method (e)}
  .take(5)

使用这样的代码,您将花费大量时间评估 slow_complex_method 上的许多元素,然后丢弃除前 5 个之外的所有结果。惰性允许底层平台确定您只需要前五个结果,然后只对需要的元素执行 slow_complex_method

实际上,这进一步深入到运行时使用,让我们想象一下 slow_complex_method 的结果被管道传输到 UI 上的滚动列表中。一个惰性管道只会对元素调用管道,因为最终结果滚动到视图中。

为了使集合管道变为惰性,集合管道函数必须以惰性为前提构建。一些语言,通常是函数式语言,如 Clojure 和 Haskell,从一开始就做到了这一点。在其他情况下,惰性可以构建到一组特殊的集合类中 - Java 和 Ruby 有一些惰性集合实现。

一些管道操作无法与惰性一起使用,必须评估整个列表。排序就是一个例子,没有整个列表,您就无法确定甚至一个最高值。认真对待惰性的平台通常会记录无法保留惰性的操作。

并行性

许多管道操作自然地与并行调用配合得很好。如果我使用 map,使用它对一个元素的结果不依赖于集合中的任何其他元素。因此,如果我在具有多个内核的平台上运行,我可以通过将 map 评估分布到多个线程来利用这一点。

许多平台都包含以这种方式并行分布评估的能力。如果您在一个大型集合上运行一个复杂的函数,这可以通过利用多核处理器来显著提高性能。

然而,并行化并不总是能提高性能。有时,设置并行分布所需的时间比从并行性中获得的时间还要长。因此,大多数平台提供明确使用并行性的替代操作,例如 Clojure 的 pmap 函数是如何 map 的并行版本。与任何性能优化一样,您应该使用性能测试来验证使用并行化操作是否确实提供了任何性能改进。

不可变性

集合管道自然地适合不可变数据结构。在构建管道时,自然会将每个操作视为为其输出生成一个新的集合。如果这样做很天真,就会涉及大量的复制,这会导致大量数据出现问题。但是,大多数情况下,在实践中这不是问题。通常,复制的是较小的指针集,而不是大量数据。

当它确实成为问题时,您可以使用专为以这种方式转换而设计的数据结构来保留不可变性。函数式编程语言倾向于使用可以以这种方式有效地操作的数据结构。

如果需要,您可以通过使用更新集合而不是替换集合的操作来牺牲可变性。非函数式语言中的库通常提供集合管道运算符的破坏性版本。我强烈建议您只将它们用作 有纪律的性能调整练习 的一部分。从使用非变异操作开始,只有在管道中存在已知的性能瓶颈时才使用其他操作。

调试

我收到了一些关于调试集合管道难度的疑问。考虑一下在 ruby 中看起来像这样的管道

def get_readers_of_books1(readers, books, date)
  data = @data_service.get_books_read_on(date)
  return data
    .select{|k,v| readers.include?(k)}
    .select{|k,v| !(books & v).empty?}
    .keys
end

现代 IDE 可以做很多事情来帮助调试,但让我们假设我正在 ruby 中这样做,只使用 emacs 并且我嘲笑调试器。

我可能想在管道中间提取一个中间变量

def get_readers_of_books2(readers, books, date)
  data = @data_service.get_books_read_on(date)
  temp = data
    .select{|k,v| readers.include?(k)}
    .select{|k,v| !(books & v).empty?}
  pp temp
  return temp
    .keys
end

另一个有点棘手的做法是将打印语句偷偷地塞入管道中的 map 中。

def get_readers_of_books(readers, books, date)
  data = @data_service.get_books_read_on(date)
  return data
    .select{|k,v| readers.include?(k)}
    .select{|k,v| !(books & v).empty?}
    .map {|e| pp e; e}.to_h
    .keys
end

在这种情况下,我需要在 map 操作之后转换回哈希

这确实需要管道内部的一些副作用,如果它逃脱了代码审查,你会被告知,但这可以帮助可视化代码在做什么。

使用适当的调试器,您通常可以在调试器中评估表达式。这样,您就在管道中设置一个断点,然后根据管道的一部分评估表达式,以查看发生了什么。

何时使用它

我认为集合管道是一种模式,对于任何模式,都有应该使用它的时间,也有应该采取其他路线的时间。如果我无法想到不使用我喜欢的模式的理由,我总是会感到怀疑。

第一个避免它的迹象是当语言支持不存在时。当我开始使用 Java 时,我非常想念使用集合管道,所以像许多其他人一样,我尝试创建可以形成这种模式的对象。您可以通过创建类并使用匿名内部类等来创建管道操作,以接近 lambda。但问题是语法太乱了,掩盖了使集合管道如此有效的清晰度。所以我放弃了,而是使用了循环。从那时起,各种函数式风格的库出现在 java 中,许多库使用在早期语言中不存在的注释。但我的感觉仍然是,如果没有对干净的 lambda 表达式提供良好的语言支持,这种模式通常会比它值得的麻烦更多。

另一个反对的论点是,当您有理解时。在这种情况下,理解通常更容易用于简单的表达式,但您仍然需要管道来获得更大的灵活性。我个人觉得简单的管道和理解一样易于理解,但这是一种团队必须在其编码风格中决定的问题。

每当代码块的做什么怎么做之间存在差异时,就提取一个方法

即使在合适的语言中,您也可能会遇到另一个限制 - 管道的规模和复杂性。我在本文中展示的管道很小且线性。我的习惯是编写小函数,如果它们超过六行,我就会感到不安,管道也有类似的规则。较大的管道需要分解成单独的方法,遵循我的惯例:每当代码块的做什么怎么做之间存在差异时,就提取一个方法。

管道在它们是线性的情况下效果最好,每一步都有一个单一的集合输入和一个单一的输出。可以将它们分叉到单独的输入和输出,尽管我在本文中没有给出任何这样的例子。但是,再次注意这一点 - 分解成单独的函数通常是控制任何较长行为的关键。

也就是说,集合管道是一个很好的模式,所有程序员都应该了解它,尤其是在 Ruby 和 Clojure 等如此好地支持它的语言中。它们可以清楚地捕获原本需要长而复杂的循环的内容,并有助于使代码更易读,从而更便宜、更容易增强。

操作目录

以下是您经常在集合管道中找到的操作目录。每种语言在哪些操作可用以及它们叫什么方面都有不同的选择,但我试图从它们的共同功能方面来考察它们。

collect

map 的替代名称,来自 Smalltalk。Java 8 将“collect”用于完全不同的目的:一个将元素从流收集到集合中的终端。

参见 map

concat

将集合连接成一个单一的集合

更多…

difference

从管道中删除提供的列表的内容

更多…

distinct

删除重复元素

更多…

drop

slice 的一种形式,返回除前 n 个元素之外的所有元素

参见 slice

filter

对每个元素运行一个布尔函数,并将通过的元素放入输出。

更多…

flat-map

对集合上的函数进行映射,并将结果展平一级

更多…

flatten

从集合中删除嵌套

更多…

fold

reduce 的替代名称 有时被称为 foldl(fold-left)和 foldr(fold-right)。

参见 reduce

group-by

对每个元素运行一个函数,并根据结果对元素进行分组。

更多…

inject

reduce 的替代名称,来自 Smalltalk 的 inject:into: 选择器。

参见 reduce

intersection

保留也存在于提供的集合中的元素

更多…

map

将给定函数应用于输入的每个元素,并将结果放入输出

更多…

mapcat

flat-map 的替代名称

参见 flat-map

reduce

使用提供的函数组合输入元素,通常组合成一个单一的输出值

更多…

reject

filter 的反向,返回不匹配谓词的元素。

参见 filter

select

filter 的替代名称。

参见 filter

slice

返回列表中给定第一个和最后一个位置之间的子序列。

更多…

排序

输出是基于提供的比较器对输入进行排序后的副本

更多…

获取

一种返回前 n 个元素的 **切片** 形式

参见 slice

并集

返回此集合或提供的集合中的元素,并删除重复项

更多…


脚注

1: 更惯用的 Lisp 管道

这里的一个问题是 Lisp 示例并不那么惯用,因为通常使用命名函数(可以使用 #'some-function 语法轻松引用) - 针对特定情况创建小函数,根据需要使用它们。这可能是对该示例更好的分解。

(defun nosqlp (article)
  (member 'nosql (article-tags article)))

(subseq
 (sort
  (remove-if-not #'nosqlp (some-articles))
  #'< :key #'article-words)
 0 3)

2: Java 管道

这是 Java 中的初始管道

articles.stream()
  .filter(a -> a.getTags().contains("nosql"))
  .sorted(Comparator.comparing(Article::getWords).reversed())
  .limit(3)
  .collect(toList());

正如您所料,Java 在几个方面都显得格外冗长。Java 中集合管道的一个特点是,管道函数不是在集合类上定义的,而是在 Stream 类上定义的(这与 IO 流不同)。因此,您必须在开头将 articles 集合转换为流,并在结尾将其转换回列表。

3: 而不是将代码块传递给 Ruby 方法,您可以通过在函数名称(它是一个符号)前面加上 "&" 来传递命名函数 - 因此 &:words。但是,对于 reduce,有一个例外,您可以直接传递函数名称,因此不需要 "&"。我更倾向于在 reduce 中使用函数名称,因此我欣赏这种不一致。

4: 使用 lambda 或函数名称

至少从我这个业余爱好者的角度来看,在使用 lambda 和函数名称之间进行选择,存在着有趣的语言历史。Smalltalk 扩展了其用于 lambda 的最小语法,使其易于编写,而调用字面方法则更加笨拙。然而,Lisp 使调用命名函数变得容易,但需要额外的语法来使用 lambda - 这通常会导致使用宏来消除这种语法。

现代语言试图使两者都变得容易 - Ruby 和 Clojure 都使调用函数或使用 lambda 变得非常简单。

5: 这里存在多义词,因为 "map" 可能指的是 map 操作或数据结构。在这篇文章中,我将使用 "hashmap" 或 "dictionary" 来表示数据结构,并且只使用 "map" 来表示函数。但在一般对话中,您经常会听到将 hashmap 称为 map。

6: 使用 Juxt

Clojure 中的一个选项是在 map 中使用 juxt 来运行多个函数

(->> (articles)
     (group-by :type)
     (map (juxt first (comp count second)))
     (into {}))

我发现使用 lambda 的版本更清晰,但我只是 Clojure(或一般函数式编程)的业余爱好者。

致谢

感谢我的同事对本文的早期草稿发表评论:Sriram Narayanan、David Johnston、Badrinath Janakiraman、John Pradeep、Peter Gillard-Moss、Ola Bini、Manoj Mahalingam、Jim Arnold、Hugo Corbucci、Jonathan Reyes、Max Lincoln、Piyush Srivastava 和 Rebecca Parsons。

重大修订

2015 年 6 月 25 日: 添加了调试部分

2014 年 10 月 22 日: 添加了并集运算符

2014 年 10 月 17 日: 添加了交集运算符

2014 年 10 月 15 日: 添加了嵌套运算符表达式和差运算符部分

2014 年 9 月 12 日: 将 distinct 和 slice 添加到操作目录

2014 年 7 月 28 日: 发布了最终部分

2014 年 7 月 24 日: 发布了第四部分,其中包含替代方案

2014 年 7 月 23 日: 发布了第三部分,其中包含索引反转示例

2014 年 7 月 22 日: 发布了第二部分,其中包含前两个示例和目录

2014 年 7 月 21 日: 发布了第一部分