2016年11月6日日曜日

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

JavaSE8 Goldへの道(Upgrade to Java SE 8 Programmer 1Z0-810 試験対策)5回目です。
いよいよラムダ式を実際に使っていきます。またJava8での追加内容のもう一つの目玉、ストリーム(Stream)に触れていきます。


Iterable#forEachメソッドによるコレクションの反復処理

コレクションの反復処理は、古くはEnumerationインタフェース、その後Iteratorインタフェース、もしくは普通にインデックスを使ったfor/while文で行っていました。例を書くまでもないですね。
Java5でjava.lang.Iterableインタフェース(utilではない)と拡張for文が導入され、シンプルに記述できるようになりました。
List<string> names = new ArrayList<string>();
names.add("すず");
names.add("モモ");
names.add("ねね");
for (String name : names) {
    System.out.println(name);
}

Java8では、Iterable#forEachというデフォルトメソッドが追加されました。定義は以下の通りです。
default void forEach(Consumer<? super T> action);
IterableList,Set,Queue,Dequeといったおなじみのインタフェースが継承しており、これらを実装するサブクラスで利用できます。
また引数に関数型インタフェースのConsumerを取るので、ラムダ式を渡すことができます。

上のfor文は以下のように書けます。
names.forEach(name -> System.out.println(name));
//さらにこの例では前回説明したメソッド参照を使うと以下のように書ける
names.forEach(System.out::println);

キーと値からなるMapインタフェースはIterableのサブインタフェースではありませんが、一貫性のためにMap#forEachメソッドが実装されています。
default void forEach(BiConsumer<? super K, ? super V> action);

引数のBiConsumerインタフェースの部分が分かりづらいですが、引数を2つ、キーと値を取って戻り値voidの処理を渡すことができます。

Map<String, String> breed = new HashMap<>();
breed.put("すず", "茶トラ");
breed.put("モモ", "白茶トラ");
breed.put("ねね", "三毛");
breed.forEach((name, hair)->
  System.out.println(name + "の毛色は" + hair));
実行結果
モモの毛色は白茶トラ
ねねの毛色は三毛
すずの毛色は茶トラ

反復処理の制御が全て内部で行われ、プログラマは処理の記述に集中できます。
ちなみにbreakはどうすればいいのかというと、ラムダ式内でreturnすると次のループに移ります。ただフィルタリングをしたい場合はこの後説明するストリームAPIを使う方が良いでしょう。

ストリームAPIとパイプライン

ストリームAPIは、コレクション、配列、I/Oリソースなどのデータソースを元に各種の操作を行うAPIです。ストリームはある集計結果をまた次のデータソースとして渡すことができるので、処理をどんどん繋いで求める結果を得ることができます。

例えば「文字列の配列に対して、各要素を大文字にし、昇順にソートして出力する」という処理は以下のように書けます。

//ソース
List<String> list = Arrays.asList("bb", "aa", "cc");

//従来のやり方(配列のままやれというのは無しで)
List<String> result = new ArrayList<>();
for(String s : list){
  result.add(s.toUpperCase());
}
Collections.sort(result);
for(String s : result){
  System.out.println(s);
}

//ストリームAPI
list.stream()
  .map(s -> s.toUpperCase())
  .sorted()
  .forEach(System.out::println);
Collection#streamメソッドでコレクションからStreamを得ることができます。他にも様々なソースからStreamを得る方法が用意されています。

得られたStreamに対し、map,sorted,forEachと次々と操作を呼び出して処理を完了しています。これをストリームのパイプライン処理と呼びます。

パイプライン処理には、処理の対象となるデータソースが必要です。データソースからストリームを生成して処理を呼び出しますが、処理には加工やフィルタリングといった、さらに後続の処理を期待する中間操作と、何かしらのアクションを実行したり別のソースへ出力するなどの終端操作があります。

ストリームの生成

//Collection#stream
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream1 = list.stream();
//Arrays#stream
int[] intarray = {1, 2, 3};
IntStream stream2 = Arrays.stream(intarray);
//Stream#of
Stream<String> stream3 = Stream.of("abc", "def");
LongStream stream4 = LongStream.of(1L, 2L, 3L);

ストリームの生成にはいくつかのやり方が用意されていますが、基本は Collection#streamStream#ofだと思います。通常の配列からストリームを生成するArrays#streamもあります。

関数型インタフェースと同じように、ストリームにもプリミティブ用のIntStream,LongStream,DoubleStreamが用意されています。オートボクシングによる性能劣化を防ぐため、プリミティブ型を扱う場合は専用のストリームを使いましょう。

中間操作

ストリームに対し何らかの手を加えた上で新しいストリームを生成する操作です。
「手を加えた上で」としてますが実際には即時に実行されるわけではないのですが、それについては後述します。

・filter
文字通り何かしらの条件によって要素を抽出します。引数にはbooleanを返す関数型インタフェースのPredicateを取ります。
Stream<T> filter(Predicate<? super T> predicate);
stream.filter(s -> !s.startsWith("n"));

・distinct
重複する要素を取り除きます。
Stream<T> distinct();
List<String> list = Arrays.asList("aA","AA","Aa", "Aa", "AA");
list.stream()
    .distinct()
    .forEach(System.out::println);
実行結果
aA
AA
Aa

・map
引数にFunction を取り、戻り値からなる新しいストリームを返します。
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
//Stream<String> → Stream<String>
Stream<String> s1 = Stream.of("naoki", "akiko", "ami");
Stream<String> s2 = s1.map(s -> s.toUpperCase());

//Stream<String> → Stream<Integer>
Stream<String> s3 = Stream.of("naoki", "akiko", "ami");
Stream<Integer> s4 = s3.map(s -> s.length());

//ストリームの型変換
Stream<String> s5 = Stream.of("naoki", "akiko", "ami");
IntStream is = s5.mapToInt(s -> s.length());

Functionなので戻り値の型は何でもよく、異なるデータを持つストリーム(型パラメータが異なる)にすることもできます。
ただし型自体は変換できないので、StreamからIntStreamへ変換する場合は専用のメソッドを使います。例に挙げたStream#mapToInt、逆はIntStream#mapToObjといったものがあります。さらにIntStreamStream<Integer>のようにラッパークラスのストリームへ変換するboxedメソッドなんてのもあります。

・flatMap
多分とても分かりづらいのがflatMapです。
<R> Stream<R> flatMap(Function<? super T,? extends Stream<? extends R>> mapper)
もう定義からして複雑ですが(;´∀`)
flatMapは後の回でもう少し詳しくとりあげますが、簡単に説明しておきます。

ポイントはFunctionの2つ目の型パラメータ=戻り値の型がStreamになっているところです。
ストリームの各要素に対して何らかの処理をした結果、得られるのがストリーム、つまり複数の値を持つ集合になる場合に使います。

そのままだとストリームがたくさんできてしまいますが、flatMapは最終的に得られた全てのStreamを展開して1つのStreamに平坦化してくれます。それがflatと付いている意味です。

List<Integer> list1 = Arrays.asList(1, 2, 3);
List<Integer> list2 = Arrays.asList(4);
List<Integer> list3 = Arrays.asList(5, 6);
List<List<Integer>> src = Arrays.asList(list1, list2, list3);
src.stream()
  .flatMap(list -> list.stream())
  .forEach(System.out::print);
実行結果
123456

・peek
デバッグ用のメソッドです。ストリームの要素に対し引数のConsumerを実行しますが、これは値を返さないので、peek自体の戻りは元のストリームの内容と同じです。
Stream<T> peek(Consumer<? super T> action)

要素がパイプラインを通過する際にその内容を確認するといった使い方をします。
Stream.of("one", "two", "three", "four")
  .filter(e -> e.length() > 3)
  .peek(e -> System.out.println("フィルタ後: " + e))
  .map(String::toUpperCase)
  .peek(e -> System.out.println("マップ後  : " + e))
  .forEach(System.out::println);
実行結果
フィルタ後: three
マップ後  : THREE
THREE
フィルタ後: four
マップ後  : FOUR
FOUR

実行結果がちょっと想像と異なるかもしれません。もし各中間操作が順番に実行されていくなら、フィルタ後の全要素→マップ後の全要素→最後の出力の順になるところですが、そうなっていませんね。

これはストリームAPIの処理は遅延実行※するように作られているためで、受け取ったラムダ式はfiltermapなどの呼び出し時点では実行されず、何を行うかという情報だけをパイプラインで繋いでいきます。そしてこの後説明する終端操作が呼ばれた時に初めて実行されます。

上記の例で、forEachをコメントアウトするとpeek内のラムダ式は実行されず、コンソールには何も出力されず終わります。

なぜそうなっているかというと、効率とか性能とか、これはまた別の回で説明する並列ストリーム実現のためで、内部ではかなり複雑なことをやりながら、いい感じに結果を出してくれるというわけです。

※遅延評価と書かれていることも多いですが、あまりよろしくないようなので遅延実行としています。

・limit,skip
limitは要素を引数の個数に制限、skipは要素の先頭から引数の個数分スキップすします。コードは略!


終端操作

だいぶ長くなってきましたが、もう少し行きます。
前述の通り、終端操作が呼ばれて初めてストリームに対する操作が実行されます。
終端操作は、すでに例として出している受け取ったConsumerを実行するだけで何も返さないforEachの他は、Stream以外の何かしらの値を返すものとなっています。

・count
まず単純なものから。countは要素の個数を返します。特に例も不要でしょう。
long count()

・max,min
引数で渡されたComparatorに従って、最大/最小の要素を返します。
Optional<T> max(Comparator<? super T> comparator);
Optional<T> min(Comparator<? super T> comparator);

ここで初出となるjava.util.Optionalですが、これはJava8で導入されたクラスで、「nullかもしれない値を格納するコンテナ」です。詳細また別の回に(多いな・・・)。
max,minでは、要素が0だった場合に空の(emptyな)Optionalが返されます。

・allMatch,anyMatch,noneMatch
それぞれ引数で渡されたPredicateの条件に対し、全て一致/いずれかが一致/全て一致しないかどうかをbooleanで返します。
boolean allMatch(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)

・findFirst,findAny
要素の中で最初の要素、任意の要素を取得します。
最初の要素は分かりやすいと思いますが、「任意の要素」ってなんぞ?と思われるでしょう。実際「こうだ!」という事例を示すことができなくて申し訳ないのですが(;´∀`)

両者の結果が変わってくるのは、まだ説明していない並列ストリームを使用した場合です。
OptionalInt result =  IntStream.range(0, 100)
  .parallel()  //並列ストリーム化
  .filter(n -> n%2==0)
  .findAny();
System.out.println(result);

この結果は実行する度に変わり、自分の環境ではOptionalInt[26]OptionalInt[50]になります。filterを挟んでいるのは処理を入れることでばらつきやすくするためで、filterをコメントアウトしても何度も実行すればばらけます(ほとんど50だがたまに25になりました)。
内部でストリームが分割されて並列に処理されているためと思われます。

例えば、intの要素に対して「50以上のもののうちどれか1つを得る」という状況でfindAnyを使った場合、順次(sequential)ストリームでは最初に見つかった要素(findFirstと同じ)となりますが、並列(parallel)ストリームでは、分割されて並行に処理されたストリームの中で一番最初に見つかったものが返されます。

並列の度合いは環境によって異なるので結果は不定になりますが、順序を気にしなくて良いのであれば並列化ストリームを利用しつつfindAnyを使った方が速く結果を得られるということでしょう。

・toArray
ストリームの要素を配列に変換します。IntFunctionを取る方は、配列コンストラクタ参照を渡すことで簡潔に記述できます。
Object[] toArray()
<A> A[] toArray(IntFunction<A[]> generator)
Person[] men = people.stream()
    .filter(p -> p.getGender() == MALE)
    .toArray(Person[]::new);

・reduce
引数のBinaryOperatorによって要素を集約し、結果を返します。このreduceと次のcollectはちょっと分かりづらいです(;´∀`)
T reduce(T identity, BinaryOperator<T> accumulator)
Optional<T> reduce(BinaryOperator<T> accumulator)
<U> U reduce(U identity,
             BiFunction<U,? super T,U> accumulator,
             BinaryOperator<U> combiner)
引数の違いで3つあります。1つめの例は以下の通り。
int result = IntStream.of(10, 20, 30)
          .reduce(0, (a, b) -> a + b);
          //これでも同じ
          //.reduce(0, Integer::sum);
System.out.println(result);
実行結果
60

最初は初期値と1つめの要素、次はその結果と2つめの要素・・・というふうに順番にBinaryOperatorを呼び出していき、最終的に1つの結果を返します。
要素が無い場合は初期値をそのまま返します。

引数が1つのものは初期値を与えないバージョンで、要素が無い場合に対応するため戻り値の型がOptional<T>となっています。

問題は3つめですが、引数にBinaryOperator<U> combinerが追加されています。これは並列ストリームの時に意味を持つものなので、別の回で詳しく触れます。
簡単に説明すると、並列ストリームの場合は要素をいくつかに分解して並列にaccumulatorを呼び出していきますが、その結果は最終的に1つにまとめる必要があります。その時にcombinerが使われます。

・collect
一番ややこしいのがこのcollectです。2種類用意されています。
<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)

1つめからしてまず大変なのですが(;´∀`)
引数はjava.util.stream.Collector型です。これは関数型インタフェースではないのでラムダ式は渡せませんが、様々な実装を取得できるjava.util.stream.Collectorsクラスが用意されているのでこれを使います。

String str = Stream.of("すず", "もも", "まお", "ねね")
  .collect(Collectors.joining("/"));
System.out.println(str);
実行結果
すず/もも/まお/ねね

Collectors#joiningはストリームがStringの時のみ利用可能で、要素の文字列を連結します。オーバーライドされていて3種類あり、例は引数をデリミタとして連結します。
その他のメソッドはAPIドキュメント→Collectors (Java Platform SE 8 )をご覧下さい。
長くなってきているので回を分けて説明します。

そして引数3つ取る方ですが、reduceと同じく並列ストリームの場合に生きてくるので、並列ストリームの回に会わせて説明します。


【注意】ストリームを使い回してはいけない

mapの例であげたコードでは、毎回Stream#ofでストリームを作り直していました。これを無駄だからと、以下のようにすると例外が発生します。

Stream<String> s1 = Stream.of("naoki", "akiko", "ami");
Stream<String> s2 = s1.map(s -> s.toUpperCase());
Stream<Integer> s4 = s1.map(s -> s.length());

Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
  at java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)
  at java.util.stream.ReferencePipeline.<init>(ReferencePipeline.java:94)
  at java.util.stream.ReferencePipeline$StatelessOp.<init>(ReferencePipeline.java:618)
  at java.util.stream.ReferencePipeline$3.<init>(ReferencePipeline.java:187)
  at java.util.stream.ReferencePipeline.map(ReferencePipeline.java:186)
  at test.Main.main(Main.java:14)

ストリームの各操作では毎回Streamオブジェクトを返しますが、すでに何かしらの操作を行ったオブジェクトにもう一度操作を行うとIllegalStateExceptionが発生します。
まあメソッドチェーンで文字通り流れるように書いていくのがストリームだと思うので、いちいち変数に入れて・・・とやらなければ問題無いでしょうが、念のため。


おしまいのひとこと

今回からOracleの公式サイトにあるテスト内容チェックリストと、その英語版の解説記事を元にしてみました。
「ラムダ式を使用してコレクションをフィルタリングする」「ストリーム上での遅延操作」は、collectの補足と合わせて次回にします。

2016年中の試験代割引キャンペーンには間に合わなそうですが、必ず記事を完遂して試験を受けるので、Java8の新機能に興味のある方はお付き合いいただければと思います。一連の記事は「JavaSE8Gold」ラベルを付けていきます。

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



▼こちらの記事もどうぞ

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

スポンサーリンク