Java8になってたくさんの機能が追加されたッッッ!!!。Stream APIもその一つ!!!
lambda関数は理解できた!Stream APIの各機能も大体理解できた。。
だけど、collectメソッド、およびその引数に渡すCollectorがよく理解できなかったので、関数型の考え方に不慣れながら簡単なものを作ってみるところから勉強してみた!
collectとは?
ストリームの終端処理の1つ。ストリームから流れ出るデータをまとめて1つのデータを返す。
reduceみたいな動きと似ているけどもっと汎用的に記述ができるらしい。返すデータがまたもやストリームでもよい。
collect関数に渡す引数がCollector。つまりストリームからなにか新しいデータを作りだしたいときにCollectorを用意すればなんなりと作り出して左辺に代入できるのだ!
OreOreData ood = stream
.filter( ..... )
.map( ..... )
.collect(oreoreCollector); //なんだかんだのStream処理のあとに
//最終的に得たいデータを生み出すッ!!
Collectorについて
一般的なCollectorはCollectorsクラスのstaticメソッドで作れる。
(例えば、ストリームをリストに変換するCollectors#toList。intの合計値を得るためのCollectors#summingInt。などなど)
不足な場合は自分でCollectorを作ればよい!forEachで処理してしまうとその場限りの処理になってしまうが、Collectorを用意すれば再利用することができる!そして簡単に並列処理に対応できる!!
かんたんなCollectorを作ってみる
一から理解するためにCollectorインターフェースを実装するところからはじめてみる。関数型の流儀を知らないJavaおじさんなので、最初はインターフェースを実装したクラスをからっぽから作成してnewしないと落ち着かないんだ。本来であれば一から作成するとしてもCollector#ofで生み出せば十分なようだ。
※ちなみにここで作成しているサンプルプログラムはCollectorの中身を理解するために一から作成していますので、「そんなことしなくてもCollectors.joiningでできる!」とかはご容赦ください。。
作るもの
文字列をカンマ区切りで連結するCollectorを作ってみる。文字列のストリームに対して、collectした結果がカンマ区切りの1つの文字列を返すというわけだ。
// 文字列のStream
Stream<String> stream = Arrays.asList("Hello", "World", "Java8", "Stream", "API").stream();
// カンマ区切り文字列を得る => "Hello, Word, Java8, Stream, API"
String commaJoinedString = stream.collect(new MyStringJoinCollector());
こんな感じだ。MyStringJoinCollector
がこれから作成するCollectorだ!
forで実装してみる
Collectorの作成のまえに、比較のためにまずは従来Listとforで実装してみる。
List<String> list = Arrays.asList("Hello", "World", "Java8", "Stream", "API");
StringBuilder buff = new StringBuilder(); //StringBuilderに作成していく
for (String s: list){
if (buff.length() != 0){ //最初はカンマ不要
buff.append(", ");
}
buff.append(s);
}
String commaJoinedString = buff.toString(); //StringBuilder→文字列へ変換して完成
forでループしながらカンマ区切り文字列を組み立てていくので、ループ中の中間生成物としてStringBuilderを使用。
単純にカンマをappendすると最初にカンマついちゃうのでそれを避けてます。最初かどうか判断するだけならlengthを取得するのは無駄ですねハイ。
最後にStringBuilderから文字列に変換してあげて、カンマ区切り文字列の完成です。
Collectorの実装
いよいよCollectorで作ってみる!MyStringJoinCollectorはCollectorを実装。
private static class MyStringJoinCollector implements Collector<T, A, R>{
...
Collectorは3つの型引数を要求する。
最初のTは、対応するStreamからの要素の型。これは文字列Streamで使うのでString。
次のAは、中間生成物で使う型。ここではStringBuilderにする。なんでこんなことまで指定しなければならないのか?というとCollectorは並列処理で勝手に動いてくれたりするので、並列処理をさせる側に全部伝えて作ってもらわないとダメなんだからだと思う。
最後のRは、戻り値の型。カンマ区切り文字列を返すのでこれもString。
中間生成物Aは、カンマ区切り文字列をどのように作るかその実装によって変わってくるので必ずStringBuilderってわけではない。今回は前述のforの時の実装を参考に、文字列を連結して組み立てるのでStringBuilderを使用するものとする。
private static class MyStringJoinCollector
implements Collector<String, StringBuilder, String>{
...
ってことでこんな感じになりました!
実装するインターフェースを定義したので、「未実装のメソッドを実装しろ!」ってエラーになるのでIDEのショートカットキーを使って「未実装のメソッド」のひな形を作ってもらいます。
「えっ、なにこれ…わかんない…」ってくらい意味不明な5つのメソッドの実装(supplier
, accumulator
, combiner
, finisher
, characteristics
)が必要になる!
Collectorの構成要素
Collectorの各メソッドを実装するのだけど、中身はforのときと大体一緒。
- supplierは、forの前の処理にあたる。中間生成物を作成する処理
- accumulatorは、forの中の処理にあたる。各要素を処理し中間生成物に格納する処理
- finisherは、forの後ろの処理にあたる。中間生成物から目的の成果物を作る処理
supplierの実装
forの前で中間生成物を作成する処理に相当。ここに処理を記述するのではなく「StringBuilderをnewして返す関数」を返す。lambda式を使ってこのように書く。メソッドの参照で StringBuilder::new
と書いてもいい!
@Override
public Supplier<StringBuilder> supplier() {
return ()->new StringBuilder();
}
accumulatorの実装
forの中の処理に相当。中間生成物のStringBuilderと、Streamの要素1つが引数で呼び出される。これも処理を記述するのではなく関数を返す。
@Override
public BiConsumer<StringBuilder, String> accumulator() {
return (sb, s) -> { //型は省略されているがStringBuilder sb, String s
if (sb.length() != 0){
sb.append(",");
}
sb.append(s);
};
}
finisherの実装
forのあとで中間生成物から目的の成果物を創りだす処理に相当。これも関数を返す。
@Override
public Function<StringBuilder, String> finisher() {
return sb -> sb.toString();
}
ここまではforと同じ。あとの2つはCollector特有のこと。
- combinerは並列処理で動作した際に、それぞれの並列処理を結合する処理
- characteristicsはこのCollectorの特徴を返す
combinerの実装
forのときは並列処理で動くみたいなことは考えてなかった。しかしCollectorは並列処理でも動かせるのでこのような考慮が必要となる。collectを並列ストリームで動作させるとstreamを適当に分割し、それぞれカンマ区切りで連結してから、さらに中間生成物同士を連結することでfinisherにつなげるような動作をする。
例えば、1〜10までの足し算の時に、1〜5までの足し算と、6〜10までの足し算を2本並列に実行し、最後にその2本の結果を加算するみたいな動きが考えられる。この並列に実行した結果をガッチャンコするのがcombinerの処理。
中間生成物StringBuilder同士をくっつけて返せばいいのでこんな実装になった!
@Override
public BinaryOperator<StringBuilder> combiner() {
return (sb1, sb2) ->{
sb1.append(sb2);
return sb1;
};
}
characteristicsの実装
characteristicsは、CharacteristicsのenumをこのCollectorの特徴に合わせてSetで返す。並列処理のための対応は自前で実装済みだとか、finisherはいらないよとか、順序が決まってないとかを示すフラグを返すような。正直あんまりよくわかってない!
とりあえずEnumSetで空のSetを返しとく。
@Override
public Set<Characteristics> characteristics() {
return EnumSet.noneOf(Characteristics.class);
}
Streamのcollectで何が起きるのかは、こちらのスライドにあった図をみるとわかりやすかった!!
社内Java8勉強会 ラムダ式とストリームAPI 48枚目
仮に2本並列で実行されたとする。supplierが2回動作して2つのStringBuilderが生成される。その2つのStringBuilderを使って並列に2つの処理がaccumulatorを使って実行される。ストリームの要素が終了したあとに、combinerで2つのStringBuilderが結合されて1つになり、最終的にfinisherで文字列になって完成!!!
実行してみる
Stream<String> stream = Arrays.asList("Hello", "World", "Java8", "Stream", "API").stream();
String commaJoinedString = stream.collect(new MyStringJoinCollector());
System.out.println(commaJoinedString); //=> "Hello, World, Java8, Stream, API"
うまく動く!
じゃあこれを並列で動かす!stream.collect
の部分をstream.parallel().collect
にするだけ!
> HelloWorldJava8StreamAPI
あれ?おかしい。
これは並列で生成されたStringBuilder同士を連結する時に先頭のカンマをつける処理を忘れているから。要素数が少ないので1要素×5本の並列動作になっているもよう。バグなのでcombinerの実装でもカンマをつける処理が必要で
@Override
public BinaryOperator<StringBuilder> combiner() {
return (sb1, sb2) ->{
if (sb1.length() != 0){
sb1.append(", ");
}
sb1.append(sb2);
return sb1;
};
}
これで並列で動かした時も同じ結果になった!面白い!!!
まとめ
Collectorはforの前、中、後を抽象的に定義するだけだったんだ!あとは並列処理でも動作するようにcombinerを実装するくらいで、実装内容はそんなに変わらない!
forと比べて記述が増えるけど、普段はインターフェースから作らずに、Collector.ofメソッドでsupplier, accumulator, combiner, finisher, characteristicsを渡せばいいのでもっと楽です。中間生成物の型とfinisherで返される型が同じものならfinisherを省略できる書き方もできるようです。
作成したソース
https://gist.github.com/civic/10704406