JavaSE8 Goldへの道(Upgrade to Java SE 8 Programmer 1Z0-810 試験対策)6回目です。
一連の記事は「JavaSE8Gold」ラベルを付けていきます。
ストリームAPIとパイプライン(続き)
collectメソッドとCollectorクラス
前回紹介しきれなかったStream#collect
メソッドとCollector
クラスについてもう少し解説を加えます。Stream#collect
メソッドは終端操作で、引数の違いで2種類あります。どちらも最終的にListなどのコンテナに要素を格納して返すのをイメージしてもらえれば良いと思います。<R,A> R collect(Collector<? super T,A,R> collector) <R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)
2つめの、引数を3つ取る方が本体というべきものです。関数型インタフェースを3つも取るので分かりづらいですが、
- supplier:コンテナの生成
- accumulator:コンテナへの格納
- combiner:複数のコンテナを結合する
List
とStringBuilder
を生成する例が載っています。List<String> asList = stringStream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll); String concat = stringStream.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append) .toString();
combinerが何やってんの?という感じと思いますが、並列ストリームで複数スレッドが別々に処理していた場合に複数のコンテナを結合する処理となります。
List
の場合はaddAl
lメソッドですが、StringBuilder
はappend
メソッドでStringBuilder
同士を結合できるためaccumulatorと同じになっています。並列ストリームでなければ使われないようです。
しかし、標準APIに含まれているコンテナへ格納するのであれば引数1つのメソッドを使った方が楽です。
こちらは
Collector
インタフェースを引数に取ります。Collector
自身の生成(インタフェースなので正確には実装したインスタンス)にはofメソッドにやはり3つの処理を渡す必要があるのですが、Collectors
クラスによく使いそうなCollector
を得るメソッドが用意されています。//Listへ格納する List<String> list = people.stream() .map(Person::getName) .collect(Collectors.toList()); //TreeSetへ格納する Set<String> set = people.stream() .map(Person::getName) .collect(Collectors.toCollection(TreeSet::new)); //文字列にした後カンマ区切りで結合する String joined = things.stream() .map(Object::toString) .collect(Collectors.joining(",")); //部署でグルーピングしてMapへ格納する Map<Department, List<Employee>> byDept = employees.stream() .collect(Collectors.groupingBy(Employee::getDepartment));
1つ目は単純に
List
へ格納して返します。Stream
にはtoArray
は用意されていますが、コレクションへはcollect
を使わないといけません。他にもtoSet
やtoMap
もあります。ただしこれらは一般的な
ArrayList
やHashSet
を返します。他のコレクションに格納したい場合は2つ目のtoCollection
が使えます。3つ目の
Colletors#joining
は結構便利と思います。引数を取らないのもあり、そちらは単純に1つの文字列にします。よくCSVを生成しようとすると、ループで結合して最後のカンマをどうするかが悩みどころだったと思いますが、もう悩む必要はありません。
(同じ事ができる
StringJoiner
クラスやString#join
というstaticメソッドもJava8で追加されています)4つ目はSQLのgroup byのように、引数の
Function
が返した値をキーとして、同じキーのものをList
へ入れてMap
へまとめてくれます。ストリームを使わずに書くと結構大変そうですが、非常に簡潔になります。
ラムダ式を使用してコレクションをフィルタリングする
Stream#filter
は中間操作です。引数で与えられた述語(Predicate)に基づいてストリームの各要素をフィルタリングし、新たなストリームオブジェクトを返します。「フィルタリングし、」と書いてしまうと即座に実行されてる印象を与えてしまいますが実際は異なり、終端操作を呼ぶまでは何も行われません。これは次の項で書きます。
メソッド定義は以下の通りです。
Stream<T> filter(Predicate<? super T> predicate)
Predicate
は関数型インタフェースでしたね。引数に対し何らかの判定を行って結果をbooleanで返す関数です。public class Employee { public String name; public double salary; public Employee(String n, double s) { name = n; salary = s; } public String toString() { return name + " : " + salary; } } public static void main(String[] args) { List<Employee> emps = new ArrayList<>(); emps.add(new Employee("John", 120000.0)); emps.add(new Employee("Daniel", 112000.0)); emps.add(new Employee("Dzmitry", 36000.0)); emps.add(new Employee("Steven", 150000.0)); emps.stream() .filter(emp -> emp.salary > 100000.0) .forEach(System.out::println); }
出力結果
John : 120000.0 Daniel : 112000.0 Steven : 150000.0
複数の
filter
メソッドを連結させることで、複数の条件でフィルタリングすることができます。emps.stream() .filter(emp -> emp.salary > 100000.0) .filter(emp -> emp.salary < 150000.0) .forEach(System.out::println);
出力結果
John : 120000.0 Daniel : 112000.0
わーめでたしめでたしと終わりたいところですが・・・。
これって条件式をまとめて一つにしたのとどう違うでしょうか。
emps.stream() .filter(emp -> emp.salary > 100000.0 && emp.salary < 150000.0) .forEach(System.out::println);
少なくともこの例では同じです。
ものすごく厳密に言えば、
filter
を2回呼び出すことで内部でStream
オブジェクト(実際はもっと複雑)が2回生成されていることになるし、ラムダ式も2種類呼び出すことになるのでパフォーマンスは遅くなるとは思います。しかしこのような単純な例ではほとんど変わらないでしょう。
- 要素がものすごい数あって
- 複数の
Predicate
がそれぞれ独立していて - 並列ストリーム
・・・って、そんなに
Predicate
が複雑だったら1つのラムダ式で書くとぐちゃぐちゃになりますが汗ストリームAPIのソースは非常に複雑で、さくっと中身を見て「こうです」と示すことができなくて申し訳ないです(単に力不足とも言える(;´∀`))。
その分高度に最適化されているので、変にあれこれやらずに問題をできるだけ小さく分割して
Stream
の各メソッドを呼んでおいたほうがいいと思います。ストリーム上での遅延処理
これまで書いてきたとおり、ストリームの操作には中間操作と終端操作があります。中間操作の戻り値は全てストリームなのでそのままでは利用できず、終端操作を呼んで何かしらの結果を得るか副作用(コンソール出力など)を生成する必要があります。
中間操作は全て遅延処理されます。具体的には、
filter
やmap
などの中間操作メソッドは、呼び出し時点では渡されたラムダ式を処理せずに、やるべきことを保持した新しいストリームを返します。終端操作を呼び出したときに初めて中間操作を含む一連の処理が行われます。
以下のプログラムでは、
peek
メソッドで要素の出力を行っていますが、終端操作を呼んで無いので何も出力されません。Stream<String> words = Stream.of("lower", "case", "text"); List<String> list = words .peek(s -> System.out.println(s)) .map(s -> s.toUpperCase());
また間にコンソール出力を挟んでから終端操作を呼ぶと、先にメッセージが出力されてからストリームの処理による出力が行われます。
Stream<String> words = Stream.of("lower", "case", "text"); Stream<String> stream = words .peek(s -> System.out.println(s)) .map(s -> s.toUpperCase()); System.out.println("終端操作呼ぶ前"); stream.forEach(System.out::println);
出力結果
終端操作呼ぶ前 lower LOWER case CASE text TEXT
それで何が嬉しいのかというと、ストリームAPIはプログラマが指定した1つのソース~0個以上の中間操作~1つの終端操作の組み合わせに応じて、できるだけ中間操作の処理が少なくなるようにうまいこと処理を組み立てて実行してくれます。
例えば以下の例では、終端操作が
findFirst
なので条件を満たすものが1つでも見つかれば要件を満たせます。そのため、StreamAPIはとにかくフィルタを通過する要素を見つけようと動きます。一つ目の"Volha"はフィルタリングの条件を満たしませんが、二つ目の"Ivan"が条件を満たしているので、すぐに次の
map
へ移って要素を変換し、それを最初の1つとして返します。List<String> names = Arrays.asList("Volha", "Ivan", "John", "Mike", "Alex"); String name = names.stream() .filter(s -> { System.out.println("filtering " + s); return s.length() == 4; }) .map(s -> { System.out.println("uppercasing " + s); return s.toUpperCase(); }) .findFirst() .get(); System.out.println(name);
出力結果
filtering Volha filtering Ivan uppercasing Ivan IVAN
※(
findFirst
はOptional
型を返すので、実際の値を得るのにget
などのメソッドを呼ぶ必要があります。Optional
については別途記事を書きます。従来のforループだと全要素のフィルタリングを行ったあと、残った要素に対して大文字に変換したあとに最初の要素を得ることになります。
当然ながら、終端操作が
forEach
など全ての要素が必要になる場合は無理ですが、基本的には内部で効率的に実行してくれるというわけです。このことは注意しておかないと、本来やって欲しいと思ってた処理が動かないということになりかねません。要は副作用がある処理をストリーム内で書いていると、必ずしも全ての要素で実行されるとは限らず、また並列ストリームにした場合は要素の順番が不定になることもあるので注意が必要です。
リダクション操作
どこで触れようか迷ってましたがここで書いておきます(;´∀`)
APIドキュメントを見ていると、終端操作のいくつかには「これはリダクション操作です」という記述が出てきます。
要素に対して何らかの結合処理を行って結果を返すものをこのように呼んでいるようです。
特に
それがどうしたという感じですが、試験で用語が出てきたときに分からないと困るので、意味は覚えておきましょう。APIドキュメントのstreamパッケージのページに説明があります。
reduce
、collect
や、sum
、max
、count
などがそうです。特に
reduce
、collect
は単一の値でなく可変結果コンテナ(CollectionやStringBuilderなど)を返すため、可変リダクションと呼ばれています。それがどうしたという感じですが、試験で用語が出てきたときに分からないと困るので、意味は覚えておきましょう。APIドキュメントのstreamパッケージのページに説明があります。
おしまいのひとこと
今回はこの辺にしておきたいと思います。以下の記事や最後に載せた参考書を元に書いています。
Java9ももうすぐリリースされそうですが、Java8はかなりの新機能が追加されたので、試験を受けるつもりのない方でも新機能を抑えておきたい方はお付き合いいただければと思います。一連の記事は「JavaSE8Gold」ラベルを付けていきます。
それではみなさまよきガジェットライフを(´∀`)ノ