エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

Java8 Stream APIの基本(7) - 終端操作2(Stream#collect)

今回はStream#collectの内部構造の概要を紹介する。

以前の記事では、Streamで加工したデータをList等のデータ構造に変換する際にcollect(Collectors.toList())のようなコードを書いた。
しかしStream#collectはStream APIの汎用的な終端操作であるため、Listだけでなく様々なデータ構造への変換が可能である。

可変リダクション操作とは

Stream#collect および、今回解説するCollectorJavadocには、「可変リダクション操作(mutable reduction operation)」という言葉が登場するので、ここで簡単に説明しておく。

「リダクション操作」とは、一連の要素に特定の演算を適用して1つにまとめる操作である。
たとえば、数値のリストの合計値を求める処理は、数値リストに要素同士の加算という演算を適用した、リダクション操作の1つとみなせる。
HaskellScala, 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 クラスを実装して拡張した方が、コードの見通し・共通性の観点でメリットがある。

[前多 賢太郎]