Java8 Stream APIの基本(7) - 終端操作2(Stream#collect)
今回はStream#collect
の内部構造の概要を紹介する。
以前の記事では、Stream
で加工したデータをList
等のデータ構造に変換する際にcollect(Collectors.toList())
のようなコードを書いた。
しかしStream#collect
はStream APIの汎用的な終端操作であるため、List
だけでなく様々なデータ構造への変換が可能である。
可変リダクション操作とは
Stream#collect
および、今回解説するCollector
のJavadocには、「可変リダクション操作(mutable reduction operation)」という言葉が登場するので、ここで簡単に説明しておく。
「リダクション操作」とは、一連の要素に特定の演算を適用して1つにまとめる操作である。
たとえば、数値のリストの合計値を求める処理は、数値リストに要素同士の加算という演算を適用した、リダクション操作の1つとみなせる。
(HaskellやScala, Lispなどでは同様の操作をfold
(折りたたみ、畳み込み)やreduce
という名前で定義している。)
Stream#collect
では、List
のような可変なオブジェクトに要素を収集するため、「可変リダクション操作」と呼んでいる。
Stream#collect
, Collector
Stream#collect
メソッドは、引数にCollector
インターフェースを取るメソッドである。
このcollect
メソッドは、collect(Collectors.toList())
のように、Collectors
クラスのstaticメソッドの呼び出しと併せて使うケースが多い。
Collectors
クラスには、Collector
インタフェースを実装したオブジェクトを生成する各種のユーティリティメソッドが用意されている。
では、Collector
インターフェースの中身を見ていこう。
Collector
インターフェースではsupplier
, accumulator
, combiner
, finisher
の4つのメソッドを定義しており、4つのメソッドの戻り値は全て関数オブジェクトになっている。
これらの関数は、Stream#collect
の内部で行われる4つの工程すなわち、前処理・集積・結合・後処理に該当する。
以降、これらの工程と4つの関数について解説する。
Collector
の4つの関数
Collector
が定義する4つのメソッドの戻り値の関数は以下の通りである。
以下の関数の内容と、型パラメータの意味を押さえておく必要がある。
メソッド名 | 戻り値の型 | ラムダ式表現 | 内容 |
---|---|---|---|
supplier |
Supplier<A> |
() -> A |
前処理 → 要素の集積に使うオブジェクトを生成する |
accumulator |
BiConsumer<A,T> |
(A, T) -> () |
集積 → 集積オブジェクト(A)と、Stream の1要素を引数に、集積オブジェクトに要素を集積する |
combiner |
BinaryOperator<A> |
(A,A) -> A |
結合 → 並列処理の結果2つから1つの結果にまとめる |
finisher |
Function<A,R> |
A -> R |
後処理 → 集積オブジェクトを最後に変換する |
なおStream
インターフェースには、上記の4つの関数を個別に指定するcollect
メソッドも定義されている。
型パラメータ
T
-Stream
で処理される要素の型A
-Stream
の要素を集積する型(List
など)。余談だが、型パラメータがAなのは、畳み込み処理で集積を行う変数をAccumulatorと呼ぶことに由来している。R
- 処理結果となる型(Resultの略)。 変換の必要がなければAと同じ型となる。
実装イメージ
Collector
インターフェースのJavadocにも記載されているが、上記4つの関数は、以下のように利用する。
supplier
,accumulator
は並列処理で分割されたスレッド毎の処理結果を次のようにまとめる。
// コメントは Collectors#toListの実装例 A acc = collector.supplier().get(); // () -> new ArrayList<T>(); for (T elem : st.iterator()) { collector.accumulator().accept(acc, elem); // acc.add(elem); } return acc;
上記の処理がスレッド毎に実行され、スレッド毎にA型の集積オブジェクトができるため、これらをcombiner
を使って1つの結合する。
(並列処理で無い場合は、この処理は行われない。)
// コメントは Collectors#toListの実装例 A acc = collector.combiner().apply(acc1, acc2); // acc1.addAll(acc2);return acc1;
最終的に1つになった集積オブジェクトを、 finisher
で変換してcollect
の戻り値とする。
return collector.finisher().apply(acc); // return acc; //変換不要で自分自身を返す。
Stream#collect
の内部処理のcombiner
は少々わかりづらい。
通常、Stream
の内部構造を他のデータ構造に変換するには、上記の supplier
, accumulator
があれば充分に思えるもしれない。
Stream APIは利用者が特別なコードを書かずとも、parallel
メソッドを実行するだけで並列での実行が可能だからだ。
しかし実際には、並列処理の結果をマージする方法をどこかに明示する必要があるためcombiner
が必要となる。
上記イメージが掴めれば、自身でCollector
を作成することが可能である。
また、これから紹介するCollectors
の各メソッドがどのように4つの関数を定義しているかを知ることで、Collector
の内部を理解するのに役立つだろう。
Collectors
前述したとおり、Collectors
はよく使われるであろう収集処理をまとめたユーティリティである。
そのうちのいくつかを抜粋して紹介する。
toList
メソッド、その他
List
への変換を行う。 他に Set
, Map
, ConcurrentMap
等各種コレクションへの変換メソッドがある。
joining
メソッド
文字列のStream
に限り、文字列を連結する。
オーバーロードされたメソッドが3つあり、 区切りの有無、接頭辞・接尾辞をつけるか・つけないかの違いがある。
従来はStringBuilder
を使って文字列を連結するループ処理を書く必要があったが、このjoining
メソッドを使うことで大幅に簡略できる。
また同様の処理を行うメソッドとして、String
クラスにもjoin
が追加されている。
System.out.println(Stream.of("A", "B", "C").collect(Collectors.joining())); // ABC System.out.println(Stream.of("A", "B", "C").collect(Collectors.joining("-"))); // A-B-C System.out.println(Stream.of("A", "B", "C").collect(Collectors.joining("-", "[", "]"))); // [A-B-C] // 2番目の形式の簡略形 System.out.println(String.join("-", "A", "B", "C")); // A-B-C
averaging...
, summing...
, summarizing...
メソッド
引数にTをプリミティブ値に変換する関数を指定し、平均値・合計値・統計を算出する。
T型のオブジェクトに例えば点数などプリミティブ型の属性がある場合、その属性を取得するような関数を記述するだけで集計が可能となり、
Stream#reduce
で同じ処理を記述するより、こちらを使った方が書きやすく、読み易い。
// Collectors. userList.stream().collect(Collectors.summingInt(user -> user.getPoint()));
(Stream#reduce
でこのような、T型からプリミティブ型への変換を伴う集計を行うには、accumulator
,combiner
を指定する必要がある。)
userList.stream().reduce(0, (acc, u) -> acc + u.getPoint(), (a1, a2) -> a1 + a2);
また、summarizing...
メソッドはSummaryStatistics
オブジェクトを結果として返す。
このオブジェクトには、 回数、平均、合計、最小値、最大値といった統計情報を取得するメソッドが含まれており、
1度の終端操作でまとめて様々な集計を行いたい場合に有用である。
partitioningBy
, groupingBy
メソッド
条件に沿って要素をグループ分けする処理であり、使いこなせば非常に強力である。
partitioningBy
は boolean
値を返す関数を引数とし、その関数を要素に適用して、true
となるものとfalse
となるものの2つにグループ分けする。
groupingBy
は 任意の値を返す関数を引数とし、その戻り値によって2つ以上の複数のグループに分割することができる。
通常戻り値は Map
であり、 キーは関数の戻り値であり、値はグループ分けされた要素のList
となる。
但し、 引数にCollector
を取るものもあり、その場合はグループ分けされた要素に対して更に、別の集計処理を適用できる。
List<Integer> list = Arrays.asList(1,2,3,4,5,6); // 偶数、奇数グループに分ける。 System.out.println(list.stream().collect(Collectors.partitioningBy(n -> n%2 == 0))); // 3の余剰ごとのグループに分け,要素の平均値を出す。 System.out.println(list.stream().collect( Collectors.groupingBy(n -> n%3, Collectors.averagingDouble(n -> n+0.0))));
また、引数にsupplier
を取るものもあり任意のMapを結果として使用できる。
例えば、要素の登場順にグループ分けをしたい場合は、LinkedHashMap
を使えばよい。
List<String> members = Arrays.asList("b0001", "a0001", "d0002", "c0004", "a0001"); // 先頭1文字でグループ分け Map<Character, List<String>> group = members.stream() .collect(Collectors.groupingBy(s -> s.charAt(0), LinkedHashMap::new, Collectors.toList())); // 要素の登場順(b,a,d,c) で出力。 group.forEach((k,v) -> System.out.println(k + " = " + v));
まとめ
Stream#collect
および Collectors
を使うと、Stream
の要素に対して様々な変換が可能となる。
特に、条件を指定してのグループ分けなど、既存の方法では共通化しにくく、かつ煩雑になりがちなコードを非常に簡潔に書くことができる。
Collectorts
のstaticメソッドでは実現できない場合、自身で独自の処理を拡張可能であるが、Stream#collect
メソッドに4つの関数を並べるのは可読性が悪いため、Collector
クラスを実装して拡張した方が、コードの見通し・共通性の観点でメリットがある。
[前多 賢太郎]