2017年2月12日日曜日

[JavaSE8 Goldへの道] その6 ラムダ式を使用したコレクション・ストリームの操作(2)


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:複数のコンテナを結合する
となっています。APIドキュメントにはListStringBuilderを生成する例が載っています。
List<String> asList = stringStream.collect(ArrayList::new,
                                ArrayList::add,
                                ArrayList::addAll);

String concat = stringStream.collect(StringBuilder::new,
                                    StringBuilder::append,
                                    StringBuilder::append)
                            .toString();

 combinerが何やってんの?という感じと思いますが、並列ストリームで複数スレッドが別々に処理していた場合に複数のコンテナを結合する処理となります。
Listの場合はaddAllメソッドですが、StringBuilderappendメソッドで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を使わないといけません。他にもtoSettoMapもあります。

ただしこれらは一般的なArrayListHashSetを返します。他のコレクションに格納したい場合は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がそれぞれ独立していて
  • 並列ストリーム
のときは、複数のfilterに分けておいたほうが内部でいい感じにやってくれると思うので良さそうです。後述する遅延処理によって必要最小限の呼び出しで済むようにしてくれるからです。
・・・って、そんなにPredicateが複雑だったら1つのラムダ式で書くとぐちゃぐちゃになりますが汗

ストリームAPIのソースは非常に複雑で、さくっと中身を見て「こうです」と示すことができなくて申し訳ないです(単に力不足とも言える(;´∀`))。
その分高度に最適化されているので、変にあれこれやらずに問題をできるだけ小さく分割してStreamの各メソッドを呼んでおいたほうがいいと思います。


ストリーム上での遅延処理

これまで書いてきたとおり、ストリームの操作には中間操作と終端操作があります。
中間操作の戻り値は全てストリームなのでそのままでは利用できず、終端操作を呼んで何かしらの結果を得るか副作用(コンソール出力など)を生成する必要があります。

中間操作は全て遅延処理されます。具体的には、filtermapなどの中間操作メソッドは、呼び出し時点では渡されたラムダ式を処理せずに、やるべきことを保持した新しいストリームを返します。
終端操作を呼び出したときに初めて中間操作を含む一連の処理が行われます。

以下のプログラムでは、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

※(findFirstOptional型を返すので、実際の値を得るのにgetなどのメソッドを呼ぶ必要があります。Optionalについては別途記事を書きます。

従来のforループだと全要素のフィルタリングを行ったあと、残った要素に対して大文字に変換したあとに最初の要素を得ることになります。
当然ながら、終端操作がforEachなど全ての要素が必要になる場合は無理ですが、基本的には内部で効率的に実行してくれるというわけです。

このことは注意しておかないと、本来やって欲しいと思ってた処理が動かないということになりかねません。要は副作用がある処理をストリーム内で書いていると、必ずしも全ての要素で実行されるとは限らず、また並列ストリームにした場合は要素の順番が不定になることもあるので注意が必要です。



リダクション操作

どこで触れようか迷ってましたがここで書いておきます(;´∀`)

APIドキュメントを見ていると、終端操作のいくつかには「これはリダクション操作です」という記述が出てきます。
要素に対して何らかの結合処理を行って結果を返すものをこのように呼んでいるようです。
reducecollectや、summaxcountなどがそうです。

特にreducecollectは単一の値でなく可変結果コンテナ(CollectionやStringBuilderなど)を返すため、可変リダクションと呼ばれています。

それがどうしたという感じですが、試験で用語が出てきたときに分からないと困るので、意味は覚えておきましょう。APIドキュメントのstreamパッケージのページに説明があります。


おしまいのひとこと

今回はこの辺にしておきたいと思います。以下の記事や最後に載せた参考書を元に書いています。
Java9ももうすぐリリースされそうですが、Java8はかなりの新機能が追加されたので、試験を受けるつもりのない方でも新機能を抑えておきたい方はお付き合いいただければと思います。一連の記事は「JavaSE8Gold」ラベルを付けていきます。

それではみなさまよきガジェットライフを(´∀`)ノ



▼こちらの記事もどうぞ

▼ブログを気に入っていただけたらRSS登録をお願いします!
▼ブログランキング参加中!応援よろしくお願いします。

スポンサーリンク
-->