コレクションパイプライン

コレクションパイプラインとは、ある計算を、コレクションを1つの操作の出力として受け取り、次の操作に供給することによって構成される一連の操作として編成するプログラミングパターンです。(一般的な操作は、filter、map、reduceです。)このパターンは、関数型プログラミングでは一般的であり、ラムダを持つオブジェクト指向言語でも一般的です。この記事では、パイプラインを形成する方法のいくつかの例を使用して、このパターンを説明します。これは、このパターンに慣れていない人に紹介するためと、人々がコアコンセプトを理解して、ある言語から別の言語にアイデアをより簡単に取り入れることができるようにするためです。

2015年6月25日



コレクションパイプラインは、ソフトウェアで最も一般的で、かつ楽しいパターンの1つです。これは、Unixコマンドライン、優れた種類のオブジェクト指向言語に存在し、最近は関数型言語で多くの注目を集めています。環境によって形式がわずかに異なり、一般的な操作の名前も異なりますが、このパターンに精通すれば、これなしではいられなくなります。

最初の出会い

私がコレクションパイプラインパターンに初めて出会ったのは、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メソッドは、単一の引数ラムダ(角括弧で定義され、smalltalkでは「ブロック」と呼ばれます)を受け取ります。これは、someArticlesのすべての要素に適用されるブール関数を定義し、ラムダがtrueと評価される記事のみのコレクションを返します。

そのコードの結果をソートするには、コードを展開します。

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

sortByメソッドは、ラムダを受け取る別のメソッドです。今回は、要素をソートするために使用されるコードです。 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を使用します。その後、式を展開してソートすることができます。ここでもラムダを使用します。

(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に登場するまでには、非常に長い時間がかかりました。

最近では、関数型プログラミングのファンがコレクションパイプラインの利点を称賛し、オブジェクト指向言語にはない関数型言語の強力な機能であると述べているのをよく耳にします。古くからのSmalltalkerとして、これはSmalltalkerがそれらを広く使用していたため、いささか迷惑に感じます。人々がコレクションパイプラインはオブジェクト指向プログラミングの機能ではないと言う理由は、C++、Java、C#のような一般的なオブジェクト指向言語がSmalltalkのラムダの使用を採用せず、したがってコレクションパイプラインパターンの基礎となる豊富なコレクションメソッドを持っていなかったためです。その結果、ほとんどのオブジェクト指向プログラマーにとってコレクションパイプラインは消滅しました。私のようなSmalltalkerは、Javaが大きな話題になったときにラムダがないことを呪いましたが、私たちはそれで我慢しなければなりませんでした。Javaでできることを使ってコレクションパイプラインを構築しようとする試みはさまざまありました。結局のところ、オブジェクト指向プログラマーにとって、関数は1つのメソッドを持つクラスにすぎません。しかし、結果のコードは非常に厄介だったため、この手法に精通している人でさえ諦める傾向がありました。 Rubyのコレクションパイプラインの快適なサポートは、2000年頃にRubyを本格的に使い始めた主な理由の1つでした。Smalltalk時代からそのようなものがたくさん恋しかったです。

最近では、ラムダは高度で使いにくい言語機能であるという評判をかなり払拭しています。主流言語のC#は、それらを数年間使用しており、ついにJavaも参加しました。 [2]そのため、現在、コレクションパイプラインは多くの言語で実行可能です。

コレクションパイプラインの定義

コレクションパイプラインは、ソフトウェアをモジュール化および構成する方法のパターンであると考えています。ほとんどのパターンと同様に、多くの場所で発生しますが、表面上は異なって見えます。ただし、基礎となるパターンを理解していれば、新しい環境で何をしたいかを簡単に理解できます。

コレクションパイプラインは、項目のコレクションをそれらの間で渡す一連の操作をレイアウトします。各操作は、コレクションを入力として受け取り、別のコレクションを出力します(最後の操作を除く。最後の操作は、単一の値を出力する端末である場合があります)。個々の操作は単純ですが、物理的世界でパイプを接続するのと同じように、さまざまな操作を連結することによって複雑な動作を作成できます。そのため、パイプラインのメタファーが使用されます。

コレクションパイプラインは、パイプとフィルターパターンの特殊なケースですが、一般的なケースです。パイプとフィルターのフィルターはコレクションパイプラインの操作に対応し、「フィルター」を操作に置き換えます。これは、フィルターがコレクションパイプラインの種類の操作の1つの一般的な名前であるためです。別の観点から見ると、コレクションパイプラインは、高階関数を構成する特定の、しかし一般的なケースであり、関数はすべて何らかの形式のシーケンスデータ構造で動作します。このパターンには確立された名前がないため、新語に頼る必要があると感じました。

操作と操作間で渡されるコレクションは、さまざまなコンテキストで異なる形式を取ります。

Unixでは、コレクションはテキストファイルであり、その項目はファイル内の行です。各行には、空白で区切られたさまざまな値が含まれています。各値の意味は、行内の順序によって決まります。操作はunixプロセスであり、コレクションはパイプライン演算子を使用して構成され、1つのプロセスの標準出力が次のプロセスの標準入力にパイプされます。

オブジェクト指向プログラムでは、コレクションはコレクションクラス(リスト、配列、セットなど)です。コレクション内の項目はコレクション内のオブジェクトであり、これらのオブジェクトはコレクション自体であるか、より多くのコレクションを含む場合があります。操作は、コレクションクラス自体に定義されたさまざまなメソッドです。通常は、ある高レベルのスーパー クラスにあります。操作はメソッドチェーンを介して構成されます。

関数型言語では、オブジェクト指向言語と同様にコレクションが扱われます。しかし、今回は項目がジェネリックなコレクション型自身である点が異なります。オブジェクト指向のコレクションパイプラインではオブジェクトを使用するのに対し、関数型言語ではハッシュマップを使用します。最上位コレクションの要素はコレクション自身である可能性があり、ハッシュマップの要素もコレクションである可能性があります。そのため、オブジェクト指向の場合と同様に、任意の複雑な階層構造を持つことができます。操作は関数であり、ネストによって、またはClojureの矢印演算子など、線形表現を形成できる演算子によって構成できます。

このパターンは他の場所にも現れます。リレーショナルモデルが最初に定義されたとき、中間コレクションがリレーションに制約されているコレクションパイプラインと考えることができる関係代数が付属していました。しかし、SQLはパイプラインアプローチを使用せず、代わりに内包表記(後で説明します)のようなアプローチを使用します。

このように一連の変換という概念は、プログラムを構造化する一般的なアプローチです。そのため、パイプとフィルタのアーキテクチャパターンが活用されています。コンパイラは、ソースコードから構文ツリー、さまざまな最適化を経て出力コードに変換する際に、この方法で動作することがよくあります。コレクションパイプラインの特徴は、ステージ間の共通データ構造がコレクションであることであり、特定の共通パイプライン操作につながります。

より多くのパイプラインと操作の探求

これまでに使用したパイプラインの例では、コレクションパイプラインで一般的な操作をいくつか使用しています。そこで、いくつかの例を使用して、より多くの操作を調べていきます。最近はRubyに慣れてきているので、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

最も重要なパイプライン操作の2つは、簡単なタスクで説明できます。リスト内のすべての記事の総単語数を取得する方法です。これらの操作の最初の1つはmapです。これは、入力コレクションの各要素に指定されたラムダを適用した結果であるメンバーを持つコレクションを返します。

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

したがって、これを使用すると、記事のリストを各記事の単語数のリストに変換できます。この時点で、より扱いにくいコレクションパイプライン操作の1つであるreduceを適用できます。この操作は、入力コレクションを単一の結果に縮小します。多くの場合、これを行う関数はすべて縮約と呼ばれます。縮約は多くの場合、単一の値に縮小され、コレクションパイプラインの最後のステップとしてのみ表示できます。Rubyの一般的なreduce関数は、各要素の通常の変数と別のアキュムレータの2つの変数を持つラムダを取ります。縮小の各ステップで、アキュムレータの値を新しい要素でラムダを評価した結果に設定します。その後、次のように数値のリストを合計できます。

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

これらの2つの操作をメニューに追加すると、総単語数の計算は2段階のパイプラインになります。

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

最初のステップは、記事のリストを単語数のリストに変換するmapです。2番目のステップは、単語数のリストで縮約を実行して合計を作成します。

関数をパイプライン操作にラムダとして、または定義された関数の名前で渡すことができます。

この時点で、コレクションパイプラインのステップを構成する関数を表現するには、いくつかの異なる方法があることに言及する価値があります。これまでのところ、各ステップにラムダを使用しましたが、代替手段は関数の名前を使用することです。このパイプラインをclojureで記述する場合、自然な記述方法は次のとおりです。

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

この場合、関連する関数の名前だけで十分です。 mapに渡された関数は入力コレクションの各要素で実行され、reduceは各要素とアキュムレータで実行されます。 Rubyでも同じスタイルを使用できます。ここで、`words`はコレクションの各オブジェクトで定義されているメソッドです。 [3]

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

一般的に、関数の名前を使用する方が少し短くなりますが、各オブジェクトで単純な関数呼び出しに制限されます。ラムダを使用すると、少し多くの構文に対して、もう少し柔軟性が得られます。 Rubyでプログラムを作成する場合、ほとんどの場合ラムダを使用することを好みますが、Clojureで作業している場合は、可能な場合は関数名を使用する傾向があります。どちらの方法でも大きな違いはありません。 [4]

各タイプの artyku数le の数を取得する(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操作の簡単なタスクであり、記事数をカウントするだけです。しかし、ここでの問題は、各グループについて2つのデータを返す必要があることです。グループの名前とカウントです。より単純ですが、関連する問題は、前に示したmapの例では値のリストを使用していますが、group-by操作の出力はハッシュマップであることです。

ハッシュマップをキーと値のペアのリストとして扱うと便利なことがよくあります。

この問題は、単純なunixの例を過ぎると、コレクションパイプラインでよく発生します。やり取りするコレクションは多くの場合リストですが、ハッシュにすることもできます。 2つの間を簡単に変換する必要があります。そうする秘訣は、ハッシュをペアのリストと考えることです。各ペアはキーと対応する値です。ハッシュの各要素がどのように表現されるかは言語によって異なりますが、単純な(そして一般的な)アプローチは、各ハッシュ要素を2要素配列:`[key、value]`として扱うことです。

Rubyはまさにこれを行い、`to_h`メソッドを使用してペアの配列をハッシュに変換することもできます。そのため、次のようにマップを適用できます。

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

ハッシュと配列の間のこのようなバウンスは、コレクションパイプラインではかなり一般的です。配列ルックアップでペアにアクセスするのは少し面倒なので、Rubyでは次のようにペアを2つの変数に直接分解できます。

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

次のパイプラインでは、リストに記載されている各タグの記事数と単語数を生成します。これを行うには、コレクションのデータ構造を大幅に再編成する必要があります. 現時点では、最上位項目は記事であり、多くのタグが含まれている可能性があります。これを行うには、データ構造を解明して、最上位レベルが複数の記事を含むタグになるようにする必要があります。これを考える1つの方法は、多対多の関係を反転させて、タグが記事ではなく集約要素になるようにすることです。

この例では、多対多の関係を反転させています。

パイプラインを開始するコレクションの階層構造をこのように再編成すると、パイプラインはより複雑になりますが、このパターンの機能の範囲内です。このような場合は、小さなステップに分割することが重要です。このような変換は通常、変換全体を小さな部分に分割してそれらをまとめると、はるかに簡単に推論できます。これがコレクションパイプラインパターンの要点です。

最初のステップはタグに焦点を当て、データ構造を展開して、各タグと記事の組み合わせに1つのレコードを作成することです。これは、リレーショナルデータベースで関連付けテーブルを使用して多対多の関係を表す方法と似ていると思います。これを行うために、記事を取り、各タグと記事のペア(2要素配列)を出力するラムダを作成します。次に、このラムダをすべての記事にマップします.

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の結果は、ペアのリストのリストであり、各記事に1つのネストされたリストがあります。そのネストされたリストは邪魔なので、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
]

平坦化する必要のある不要なレベルのネストを持つリストを生成するというこのタスクは非常に一般的であるため、ほとんどの言語は、これを1つのステップで実行するための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と呼ばれます。

このようにより複雑なパイプラインを構築することは、前に示した単純なものよりも苦労する可能性があります。各ステップを慎重に1つずつ実行し、各ステップからの出力コレクションを注意深く調べて、正しい形状になっていることを確認するのが最も簡単だと思います。この形状を視覚化するには、通常、コレクションの構造をインデント付きで表示するためにある程度のプリティプリントが必要です。また、ローリングテストファーストスタイルでこれを行うことも役立ちます。最初にデータの形状(最初のステップのレコード数など)について簡単なアサーションを使用してテストを記述し、パイプラインにステージを追加するにつれてテストを進化させます。

ここで示したパイプラインは、各ステージから構築することには意味がありますが、最終的なパイプラインでは何が起こっているのかが明確にわかりません。最初のステージはすべて、各タグで記事のリストにインデックスを付けることなので、そのタスクを独自の関数に抽出することで、読みやすくなると考えています。

(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

多くの人は、1回の使用で2つの関数を分割するためだけに、まったく新しいクラスを導入するのは重すぎると感じるでしょう。このような局所的な作業のためにクラスを導入することに問題はありません。この特定のケースでは、抽出する必要があるのは合計単語関数だけなので、導入しませんが、出力に少し追加するだけで、そのクラスに手を伸ばすでしょう。

代替案

コレクションパイプラインパターンは、これまでに説明してきたようなことを達成する唯一の方法ではありません。最も明白な代替手段は、ほとんどの人が通常これらのケースで使用してきたものです。単純なループです。

ループの使用

上位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

この場合、コレクションパイプラインバージョンははるかに短くなりますが、どちらの場合でも意図を引き出すためにリファクタリングするため、比較するのは難しいです。

内包表記の使用

一部の言語には、通常リスト内包表記と呼ばれる、単純なコレクションパイプラインを反映する内包表記の構文があります。1000語を超えるすべての記事のタイトルを取得することを考えてみましょう。内包表記構文を持つ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

問題は、内包表記が機能するケースでは、パイプラインよりも優れているかどうかです。内包表記のファンは優れていると言い、パイプラインは理解しやすく、より一般的であると言う人もいます。(私は後者のグループに属します。)

ネストされた演算子式

コレクションでできる便利なことの1つは、集合演算で操作することです。赤い部屋、青い部屋、ホテルの正面にある部屋、または占有されている部屋を返す関数を持つホテルを考えてみましょう。式を使用して、ホテルの正面にある空いている赤または青の部屋を見つけることができます。

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

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

Clojureは集合データ型に集合演算を定義しているため、ここでのすべての記号は集合です。

これらの式をコレクションパイプラインとして formulation することができます

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

集合演算に関して覚えておくべきもう1つのポイントは、コレクションは通常、順序付けられ、重複が許可されるリストであるということです。集合演算にとってそれが何を意味するのかを知るには、ライブラリの詳細を確認する必要があります。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には、遅延コレクションの実装がいくつかあります。

一部のパイプライン操作は遅延では機能せず、リスト全体を評価する必要があります。ソートはその一例であり、リスト全体がないと、1つの上位値さえ決定できません。遅延を真剣に受け止めているプラットフォームは、通常、遅延を維持できない操作を文書化します。

並列処理

多くのパイプライン操作は、並列呼び出しで自然にうまく機能します。map を使用する場合、1つの要素に使用した結果は、コレクション内の他の要素に依存しません。そのため、複数のコアを持つプラットフォームで実行している場合は、マップ評価を複数のスレッドに分散することで、その利点を利用できます。

多くのプラットフォームには、このように並列に評価を分散する機能が含まれています。大規模な集合に対して複雑な関数を実行している場合、マルチコアプロセッサを活用することで、パフォーマンスが大幅に向上する可能性があります。

ただし、並列化は必ずしもパフォーマンスを向上させるわけではありません。並列処理によるメリットよりも、並列分散のセットアップに時間がかかる場合があります。その結果、ほとんどのプラットフォームでは、並列処理を明示的に使用する代替操作を提供しています。たとえば、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

もう1つの、ややトリッキーなことは、パイプラインのマップにprintステートメントを密輸することです。

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

この場合、マップ操作後にハッシュに変換し直す必要があります

これには、パイプライン内にいくつかの副作用が必要です。コードレビューにエスケープした場合、叱責される可能性がありますが、コードの動作を視覚化するのに役立ちます。

適切なデバッガを使用すると、通常、デバッガで式を評価できます。このようにして、パイプにブレークポイントを設定し、パイプの一部に基づいて式を評価して、何が起こっているかを確認します。

いつ使用するか

コレクションパイプラインを1つのパターンとして捉えています。どのパターンにも言えることですが、使うべき時と、別の方法を取るべき時があります。自分が好きなパターンを使わない理由を思いつかない時は、常に疑念を抱きます。

避けるべき最初の兆候は、言語によるサポートがない場合です。Javaを使い始めた頃は、コレクションパイプラインを使えないことを非常に残念に思っていたので、他の人たちと同じように、このパターンを形成できるオブジェクトの作成を試しました。クラスを作成し、ラムダ式に近づけるために匿名内部クラスのようなものを使用することで、パイプライン操作を形成できます。しかし、問題は構文があまりにも煩雑で、コレクションパイプラインを効果的にする明瞭さを損なってしまうことです。そこで私は諦めて、代わりにループを使用しました。それ以来、Javaにはさまざまな関数型スタイルのライブラリが登場し、その多くは初期の頃には言語に存在しなかったアノテーションを使用しています。しかし、クリーンなラムダ式を適切にサポートする言語機能がなければ、このパターンは結局、手間がかかるだけでメリットがないというのが私の感想です。

反対するもう1つの議論は、内包表記がある場合です。その場合、単純な式では内包表記の方が扱いやすいことが多いですが、より柔軟性を高めるためには、依然としてパイプラインが必要です。個人的には、単純なパイプラインは内包表記と同じくらい理解しやすいと思いますが、それはチームがコーディングスタイルで決定すべきことです。

コードブロックの*何をするか*と*どのようにするか*に違いがある場合は常にメソッドを抽出する

適切な言語であっても、別の制限、つまりパイプラインのサイズと複雑さに直面することがあります。この記事で示したものは、小さく直線的なものです。私は一般的に小さな関数を書く習慣があり、6行を超えると落ち着かなくなりますが、パイプラインにも同様のルールがあります。大きなパイプラインは、私の通常のルールに従って、個別のメソッドに分割する必要があります。コードブロックの何をするかとどのようにするかに違いがある場合は常にメソッドを抽出します。

パイプラインは、各ステップに単一のコレクション入力と単一の出力がある直線的な場合に最適に機能します。この記事ではそのような例を挙げていませんが、別々の入力と出力に分岐することも可能です。しかし、これも注意が必要です。個別の関数に分割することが、長い動作を制御するための鍵となることがほとんどです。

とはいえ、コレクションパイプラインは優れたパターンであり、すべてのプログラマーが知っておくべきものです。特にRubyやClojureのように、このパターンをうまくサポートしている言語ではそうです。コレクションパイプラインは、そうでなければ長く複雑なループが必要となるものを明確に捉えることができ、コードを読みやすくし、ひいてはコストを削減し、拡張を容易にすることができます。

操作カタログ

コレクションパイプラインでよく見られる操作のカタログを以下に示します。どの操作が利用可能で、どのように呼ばれるかは言語によって異なりますが、私はそれらを共通の機能を通して見てみました。

collect(収集)

Smalltalk由来の**map**の別名。Java 8では「collect」は全く異なる目的、つまりストリームから要素をコレクションに収集する終端操作に使用されます。

mapを参照

concat(連結)

複数のコレクションを単一のコレクションに連結します

詳細…

difference(差分)

指定されたリストの内容をパイプラインから削除します

詳細…

distinct(重複排除)

重複する要素を削除します

詳細…

drop(削除)

最初のn個の要素を除くすべてを返す**slice**の一種です

sliceを参照

filter(フィルタ)

各要素に対してブール関数を適用し、条件を満たすものだけを出力に配置します。

詳細…

flat-map(フラットマップ)

コレクションに関数を適用し、結果を1レベルフラット化します

詳細…

flatten(フラット化)

コレクションからネストを削除します

詳細…

fold(畳み込み)

**reduce**の別名。foldl(左畳み込み)とfoldr(右畳み込み)として見られることもあります。

reduceを参照

group-by(グループ化)

各要素に関数を適用し、結果によって要素をグループ化します。

詳細…

inject(注入)

Smalltalkの`inject:into:`セレクタに由来する**reduce**の別名。

reduceを参照

intersection(積集合)

指定されたコレクションにも存在する要素を保持します

詳細…

map(マップ)

入力の各要素に指定された関数を適用し、結果を出力に配置します

詳細…

mapcat(マップ連結)

**flat-map**の別名

flat-mapを参照

reduce(リデュース)

指定された関数を使用して、入力要素を結合します。多くの場合、単一の出力値に結合します。

詳細…

reject(拒否)

**filter**の逆で、述語に一致しない要素を返します。

filterを参照

select (選択)

**filter**の別名。

filterを参照

slice(スライス)

指定された開始位置と終了位置の間のリストの部分シーケンスを返します。

詳細…

sort(ソート)

出力は、指定された比較関数に基づいてソートされた入力のコピーです

詳細…

take(取得)

最初のn個の要素を返す**slice**の一種です

sliceを参照

union(和集合)

重複を削除し、このコレクションまたは指定されたコレクションの要素を返します

詳細…


脚注

1: より慣用的なLispパイプライン

ここでの1つの問題は、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: ラムダ式または関数名を使用する

少なくとも私の経験から言うと、ラムダ式と関数名のどちらを使用するかという選択には、興味深い言語の歴史があります。Smalltalkはラムダ式の最小限の構文を拡張し、記述を容易にしました。一方、リテラルメソッドの呼び出しはより面倒でした。しかし、Lispは名前付き関数の呼び出しを容易にしましたが、ラムダ式を使用するには追加の構文が必要でした。多くの場合、その構文を修正するためのマクロが必要でした。

最新の言語は、両方を容易にしようとします。RubyとClojureはどちらも、関数の呼び出しとラムダ式の使用を非常に簡単にします。

5: 「map」は操作mapまたはデータ構造を指す場合があるため、多義性があります。この記事では、データ構造には「ハッシュマップ」または「辞書」を使用し、「map」は関数にのみ使用します。ただし、一般的な会話では、ハッシュマップはマップと呼ばれることがよくあります。

6: Juxtの使用

Clojureでの1つの選択肢は、juxtを使用して、マップ内で複数の関数を実行することです

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

ラムダ式を使用するバージョンの方が分かりやすいと思いますが、私は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日: 代替案を含む第4版を公開

2014年7月23日: インデックス反転の例を含む第3版を公開

2014年7月22日: 最初の2つの例とカタログを含む第2版を公開

2014年7月21日: 第1版を公開