收集管線

收集管線是一種程式設計模式,您可以在其中將一些運算組織成一連串運算,這些運算透過將一個運算的集合輸出作為下一個運算的輸入來組合。(常見的運算有篩選、對應和簡化。) 這種模式在函數式程式設計中很常見,在具有 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 中進行更多程式設計,其語法讓設定集合管線變得更為容易,因為我不必將管線的早期階段包在括弧中

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 語言所缺乏的函式語言的強大功能。作為一個老 Smalltalker,我發現這相當令人討厭,因為 Smalltalker 廣泛地使用它們。人們說收集管線不是 OO 程式設計功能的原因是,像 C++、Java 和 C# 等流行的 OO 語言沒有採用 Smalltalk 使用 lambda,因此沒有支援收集管線模式的豐富收集方法陣列。因此,收集管線對大多數 OO 程式設計人員來說已經消失了。像我這樣的 Smalltalker 在 Java 成為城鎮中的大事時詛咒缺乏 lambda,但我們必須忍受它。有各種嘗試使用我們在 Java 中可以使用的東西來建立收集管線;畢竟,對於 OOer 來說,函式只是一個具有單一方法的類別。但產生的程式碼非常混亂,即使是熟悉這項技術的人也傾向於放棄。Ruby 對收集管線的舒適支援是我在 2000 年左右開始大量使用 Ruby 的主要原因之一。我從 Smalltalk 時代就非常想念那樣的東西。

現今,Lambda 已擺脫許多進階且難以使用的語言功能的惡名。在主流語言中,C# 已使用它們好幾年,甚至 Java 也終於加入。 [2] 因此,現在許多語言都可以使用集合管線。

定義收集管線

我認為集合管線是一種模組化和組合軟體的模式。像大多數模式一樣,它會出現在許多地方,但每次出現時表面上看起來都不同。不過,如果你了解底層模式,就能輕鬆找出在新的環境中想做的事。

集合管線會規劃一連串的運算,這些運算會在它們之間傳遞一個項目集合。每個運算會將集合視為輸入,並發出另一個集合(最後一個運算除外,它可能是發出單一值的終端)。個別運算很簡單,但你可以將各種運算串接在一起,就能創造出複雜的行為,就像你可能會在物理世界中將管道連接在一起一樣,因此有了管線的比喻。

集合管線是 Pipes and Filters 模式 的特例。Pipes and Filters 中的篩選器對應到集合管線中的運算,我將「篩選器」替換為運算,因為篩選器是集合管線中一種運算的常見名稱。從另一個角度來看,集合管線是組合高階函式的特定但常見情況,其中函式都作用於某種形式的序列資料結構。這個模式沒有既定的名稱,所以我認為有必要訴諸 新創詞

運算和在運算之間傳遞的集合會在不同的脈絡中採用不同的形式。

在 Unix 中,集合是一個文字檔,其項目是檔中的行。每行包含各種值,以空白字元分隔。每個值的意義由它在行中的順序決定。運算是 Unix 程序,集合則使用管線運算子組合,其中一個程序的標準輸出會導管到下一個程序的標準輸入。

在物件導向程式中,集合是一個集合類別(清單、陣列、集合等)。集合中的項目是集合內的物件,這些物件本身可能是集合或包含更多集合。運算是集合類別本身定義的各種方法,通常在某個高階超類別上。運算是透過方法鏈組合的。

在函式語言中,集合以類似於物件導向語言的方式成為集合。不過,這次的項目本身是泛型集合類型,其中物件導向集合管線會使用物件,函式語言會使用雜湊映射。頂層集合的元素本身可能是集合,雜湊映射的元素也可能是集合,因此就像物件導向的情況一樣,我們可以擁有任意複雜的階層結構。運算是函式,它們可以透過巢狀或運算子組合,例如 Clojure 的箭頭運算子,形成線性表示。

這個模式也會出現在其他地方。當關係模型首次定義時,它附帶了一個 關係代數,你可以將它視為一個集合管線,其中中間集合受到約束,必須是關係。但 SQL 沒有使用管線方法,而是使用類似於理解的方法(我稍後會討論)。

一系列此類轉換的概念是建構程式的一種常見方法 - 因此收集管道和篩選器架構模式。編譯器通常以這種方式運作,從原始碼轉換為語法樹,經過各種最佳化,然後輸出程式碼。收集管道的不同之處在於,各階段之間的共同資料結構是一個集合,這會導致一組特定的常見管道操作。

探索更多管線和操作

到目前為止,我使用過的一個範例管道只使用了收集管道中常見的幾個操作。因此,現在我將使用幾個範例來探討更多操作。我將堅持使用 Ruby,因為我最近比較熟悉這門語言,但通常可以在支援此模式的其他語言中形成相同的管道。

取得總字數 (map 和 reduce)

- 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 中的一般簡化函式會使用一個 lambda,其中包含兩個變數,每個元素的通常變數和另一個累加器。在簡化的每個步驟中,它會將累加器的值設定為使用新元素評估 lambda 的結果。然後,您可以像這樣對數字清單求和

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

有了這兩個選單上的操作,計算總字數是一個兩步驟的管道。

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

第一步是將文章清單轉換為字數清單的 map。第二步是在字數清單上執行簡化以建立總和。

您可以將函式傳遞到管道操作中,作為 lambda 或定義函式的名稱

在這個時候,值得一提的是,您可以使用幾種不同的方式來表示構成收集管道中步驟的函式。到目前為止,我已對每個步驟使用一個 lambda,但另一種方法是僅使用函式的名稱。在 Clojure 中撰寫此管道,撰寫它的自然方式如下

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

在這種情況下,僅相關函式的名稱就足夠了。傳遞到 map 的函式會在輸入集合的每個元素上執行,而簡化會使用每個元素和一個累加器來執行。您也可以在 Ruby 中使用相同的樣式,這裡的 words 是在集合中每個物件上定義的方法。 [3]

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

一般來說,使用函數名稱會簡潔一點,但你只能在每個物件上執行簡單的函數呼叫。使用 lambda 會給你更多彈性,但語法會稍微複雜一點。當我用 Ruby 程式設計時,我傾向於大部分時間使用 lambda,但如果我在 Clojure 中工作,我會更傾向於在可以的時候使用函數名稱。選擇哪一種方式並無大礙。[4]

取得每種類型的文章數目 (group-by)

- 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 範例後,通常會將雜湊映射視為鍵值對清單。

這個問題在我們超越簡單的 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
]

映射的結果是成對資料清單清單,每個文章有一個巢狀清單。那個巢狀清單會礙事,所以我使用 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
}

現在,我已將每個標籤的基本資料重新整理為文章,反轉多對多的關係。為了產生所需的結果,我只需要一個簡單的對應來萃取我需要的確切資料

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」在本地函式的脈絡中是有意義的,但作為集合類別中的通用方法,卻不太有意義。需要在集合類別中放置方法,可能是物件導向方法的嚴重缺點。有些平台根本不允許你將方法新增至函式庫類別,這排除了這種分解。其他平台允許你透過像這樣的猴子修補來修改類別,但這會對類別的 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 來說明這一點,但也可以使用 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 的「移除」方法才能按正確順序取得執行緒參數。

但我比較偏好 巢狀運算式表達式 形式,特別是在我可以使用中綴運算子時。而且較複雜的表達式可能會變成像管道一樣糾纏不清。

話雖如此,在管道中間插入集合運算通常很有用。讓我們想像一個案例,其中房間的顏色和位置是房間記錄的屬性,但已佔用的房間清單在一個獨立的集合中。

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 個。惰性化允許底層平台確定你只需要前 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

另一件有點棘手的事情是將列印陳述句偷渡到管道中的映射中。

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

在這種情況下,我需要在映射操作後轉換回雜湊

這確實需要在管道內部產生一些副作用,如果它逃脫到程式碼檢閱中,你應該會被告知,但它可以幫助視覺化程式碼在做什麼。

使用適當的偵錯程式,你通常可以在偵錯程式中評估表達式。這樣,你可以在管道中設定中斷點,然後根據管道的部分評估表達式,以查看正在發生什麼事。

何時使用

我將集合管道視為一種模式,而對於任何模式,都有你應該使用它的時候,也有你應該採取其他途徑的時候。如果我無法想到不使用我喜歡的模式的原因,我總是會產生懷疑。

避免它的第一個跡象是當語言支援不存在時。當我開始使用 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

傳回給定第一個和最後一個位置之間的清單子序列。

更多…

sort

輸出是根據所提供的比較器對輸入進行排序的副本

更多…

take

slice 的一種形式,傳回前 n 個元素

請參閱 slice

union

傳回此集合或所提供集合中的元素,並移除重複項

更多…


腳註

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 串流不同)。因此,您必須在開始時將文章集合轉換為串流,並在結束時轉換回清單。

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 中的一個選項是使用 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 日:新增相異和切片至運算目錄

2014 年 7 月 28 日:發布最終章節

2014 年 7 月 24 日:發布第四章節,包含替代方案

2014 年 7 月 23 日:發布第三章節,包含索引反轉範例

2014 年 7 月 22 日:發布第二章節,包含前兩個範例和目錄

2014 年 7 月 21 日:發布第一章節