Collectorを征す者はStream APIを征す(部分的に)

cero_tさんが書かれたラムダ禁止について本気出して考えてみた - 9つのパターンで見るStream API は、とても良い記事です。Stream APIで実際に書いてしまいそうな、従来のfor/if文より読みにくいコード、予期せぬ不具合を起こしうるコードを例示し、いくつかの教訓を導き出しています。

何より、社内で「ラムダ式禁止、Stream API禁止」という悲しいことにならないように手を打とう! ラムダ式やStream APIの使い方やうれしさ、そして危ういところを社内の勉強会などで広げていこう! という、前向きな主張がすばらしいです。私も見習っていこうと思います。

さて本記事では、cero_tさんが挙げられている8の教訓のうち、次の2点(カッコ内は私の補足です)について、少し異論を述べたいと思います。

  • (4) collectをしたら一度ローカル変数に入れよう!(collectで作られたオブジェクトを、説明的変数に代入する)
  • (6) streamからのstreamとか、ネストしたstreamとかは避けよう!(Streamで使うCollectorなどで新たなStreamを作ることは避けよ。代わりにfor文などを用いよ)

これら教訓には一理あると思いますが、ちょっと引っかかるところがありました。

cero_tさんは、この2つの教訓を導いたサンプルコードを「禁止度A 業務では使わない方が良いレベル」としています。しかし、私の目には、サンプルコードにはまだまだ改善の余地があるように思えました。

そこで「こうすれば、より良くStream APIを使えるのでは?」という改善できる点を示した上で、「使うべきか、使わないべきか」を論じてみようと思います。

前置き

この記事を書くにあたっては、次の環境でコードのコンパイル&実行をしました。

  • Intellij IDEA 13.0.1 Ultimate
  • JDK8b120 for Windows 64 bit

使ったJDK8はベータ版です。というのも、Stream APIが導入されるJava SE 8(JDK8)は、まだ正式リリースされていないのです(正式リリースは2014年3月予定)。ひょっとすると、ベータ版の何らかのバグでたまたま動いている(あるいは動いていない)ことがあるかもしれません。もし見つかれば、教えていただけると幸いです。

なおcero_tさんのサンプルコードを引用するにあたって、意味が変わらない範囲で一部改変してます。主な改変内容は、

  1. 型推論を助けるために型引数を補ったり、
  2. 何らかのオブジェクトを説明用変数に抽出したり(これはcero_tさんの推奨する教訓にもありますね)
  3. メソッド参照を活用するため、Empクラスのフィールドをgetterメソッドでカプセル化したり
  4. Collectorsクラスが持つstaticメソッドを、staticインポートを使って短く書き直し

です。改変した理由は、最初の2つが当てはまるのですが、元のコードが当方の環境ではコンパイルできなかったためです。深くは追及していないのですが、JDK8で強化されるターゲット型推論のバグなのか限界なのか、あるいは筆者のコードのジェネリクス周りがダメダメなのか……。ラムダ式の型が、思ったように型推論されないことがありました。ターゲット型推論やジェネリクスに詳しい人に見ていただけると、うれしいのですが |д゚)チラッ

またユーティリティコードやそのテストコードは、GitHubレポジトリ exoego/StreamUtilities で公開しています。まだJDK8のお試し程度のものです。つたないですが、Stream APIのflatMapやcollectを使うことで何ができそうかについて、ちょっとしたサンプルになったかなぁ、と思います。

Collectorsのメソッドを組み合わせ、ニーズにあったCollectorを作ろう part1

cero_tさんが、最初に「禁止度A 業務では使わない方が良いレベル」として上げたコードを見てみましょう。"部署(dept)を、人数の少ない順に並べるという処理"です。

Collectorsクラスのstaticメソッド(groupingBy、counting、toList)が組み合わさった、collectの使い方の好例です。なおこれ以降、staticメソッドのクラス名修飾(Collectors.)は、読み流しやすくするため、staticインポートによって省略していきます。

List<Dept> sortDept1(List<Emp> list) {
    return list.stream()
               .collect(Collectors.groupingBy(Emp::dept, Collectors.counting()))
               .entrySet()
               .stream()
               .sorted(Comparator.comparingLong(Entry::getValue))
               .map(e -> e.getKey())
               .collect(Collectors.toList());
}

groupingByが作るCollectorによって、何らかのグループ別に(今回は部署)、複数の要素を集約(今回は個数)した結果のMapを、さらにstream処理したい……。とてもありそうな例です。

cero_tさんも指摘するように、Map<Dept,Long>からStream<Entry<Dept,Long>>を作り出す ".entrySet().stream()"が2行も必要で、ノイズになってしまっています。読みやすさのために、どの行も.を揃えて改行しているため、「.entrySet().stream()のところだけ改行しなければ1行に収まる」という問題ではありません。

cero_tさんは、そこで "collectした後にローカル変数に代入すれば、まだしも読みやすくなるのでは" と、次の改善を示しています。

List<Dept> sortDept2(List<Emp> list) {
    Map<Dept, Long> map = list.stream().collect(groupingBy(emp -> emp.dept(), counting()));

    return map.entrySet()
              .stream()
              .sorted(Comparator.comparingLong(Entry::getValue))
              .map(e -> e.getKey())
              .collect(toList());
}

変数名をより説明的な名前にすれば、collectで何をしたかが分かりやすくなりそうです。この例だとmapという変数名が説明不足なのが、ちょっとイマイチですね。

しかし、それでも".entrySet().stream()"のノイズは残ります。どうすればいいのでしょうか?

こんなときには、Collectors.collectingAndThenを使うことができそうです。Collectors.collectingAndThenは、collectして得られた集約結果に「仕上げ処理」をほどこすCollectorを作り出すユーティリティです。例えば、toList()ではArrayListが作られるのですが、これを変更不能のListに変更するというような使い道があります(javadocより改変して引用)。

List<String> people = hoge.stream()
                          .collect(collectingAndThen(toList(), list -> Collections.umodifiableList(list)));

そんなcollectingAndThenを使うことで、次のように書き直せるでしょう。

List<Dept> sortDept3(List<Emp> list) {
    Collector<Emp, ?, Stream<Entry<Dept, Long>>> groupingThenStream =  
            collectingAndThen(groupingBy(Emp::dept, counting()), m -> m.entrySet().stream());
    return list.stream()
               .collect(groupingThenStream)
               .sorted(comparingLong(e -> e.getValue()))
               .map(e -> e.getKey())
               .collect(toList());
}

あれ? entrySet().stream()の位置が変わっただけで、やってることはまったく変わりませんね。むしろ、collectingAndThenを使うことで、さらにコードが増え、しかもgroupingThenStreamという変数まで使ってしまいました(変数に入れないと、型推論が思ったように動作してくれなかったので、やむを得ず)。collectingAndThenは必要なかったのでしょうか?

やっと、ここからが本題です。

"groupingByしたあと、さらにstream処理"は、とてもよくありそうな例です。そんなよくありそうな処理を、今回のように毎回書いていては面倒です。

そのためのCollectorを作るユーティリティメソッドを作ってしまいましょう。名前は、例えば、toGroupedEntriesなんてのは,どうでしょうか。

Collectorsが提供するcollectingAndThenを使うことで、次のようにサクッと書けてしまいました。

private static <T, K, A, V, M extends Map<K, V>> Collector<T, ?, Stream<Entry<K, V>>> toGroupedEntries(
        final Function<? super T, ? extends K> keyMapper,
        final Collector<? super T, A, V> downstream,
        final Supplier<M> mapFactory) {
    return Collectors.collectingAndThen(Collectors.groupingBy(keyMapper, mapFactory, downstream),
                                        map -> map.entrySet().stream());
}

public static <T, K, A, V> Collector<T, ?, Stream<Entry<K, V>>> toGroupedEntries(
        final Function<T, ? extends K> keyMapper, final Collector<T, A, V> downstream) {
    return toGroupedEntries(keyMapper, downstream, HashMap::new);
}

これを使うことで、次のように書き直すことができます。

List<Dept> sortDept4(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept, counting()))
               .sorted(comparingLong(Entry::getValue))
               .map(e -> e.getKey())
               .collect(toList());
}

いかがでしょうか? toGroupedEntriesという名前の良し悪しはともかく、「グループ化した結果から、EntryのStreamを作る」という意図がより明瞭になったと思います。Mapを入れる説明用変数も不要になり、コードもだいぶ短くできそうです。また今度は型推論も思うように動いてくれたので、Collectorを変数に入れる必要はなくなりました。

これくらい分かりやすくなれば、「禁止度B やや疑問を呈されるけど、積極的に使いたいレベル」まで下げられるのではないでしょうか?

「これはよくありそう」だけど「ありもの(標準ライブラリ)のクラスやメソッドじゃ足りない…」そんなときは、抽象化し、便利な道具を作るチャンスです。

せっかくJavaにもラムダ式が導入され、関数オブジェクト的なものの利用が用意になったのですから、Functionなどを注入して挙動を柔軟に制御するような道具をじゃんじゃん作っていきたいですね。

そして、標準ライブラリのクラスCollectorsなどには、そういった道具を作る材料となる、基本的なメソッドが揃っていると思います。ぜひ、JDK8をダウンロードし、Collectorsクラスのソースコードやjavadocをのぞいてみてください。

Collectorsのメソッドを組み合わせ、ニーズにあったCollectorを作ろう part2

先ほどの例には、まだ改善できるところがあります。

List<Dept> sortDept4(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept, counting()))
               .sorted(comparingLong(Entry::getValue))
               .map(e -> e.getKey())
               .collect(toList());
}

この ".map(e -> e.getKey()).collect(toList())" という「要素に何らかの変換をかましてListに集約」というのは、よくありそうです。もちろん、このままでもまったく問題はないのですが、ちょっと工夫をしておきましょう。

「変換してから集約」というニーズのために、Collectors.mappingというメソッドが用意されています。次のように使えます。

List<Dept> sortDept5(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept, counting()))
               .sorted(comparingLong(Entry::getValue))
               .collect(mapping(Entry::getKey, toList()));
}

このmapping(mapper, toList())も、よく使いそうなパターンです。より使いやすく、メソッド化してしまいましょう。名前は安直ですが、toListMappedとしておきましょう。実装は、GitHubレポジトリをご覧ください。

List<Dept> sortDept5(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept, counting()))
               .sorted(comparingLong(Entry::getValue))
               .collect(toListMapped(Entry::getKey));
}

これくらいだと、あえてメソッド化するほどではないかもしれません。

が、このtoListMapped、groupingByなどと組み合わせることで、本領を発揮するのです!

例えば、次のような例を考えてみましょう。空白文字をトリムしてから文字数でグループ化するか、文字数でグループ化したあとにトリムするか……。すなわち、mapしてからcollectするか、collect中にmapするかです。

@Test
public void use_case_difference_between_ToListMapped_and_ToList() {
    List<String> src = asList("a   @  on@ cat @tri @by".split("@"));

    Map<Integer, List<String>> mapThenCollect = src.stream()
                                                   .map(s -> s.trim())
                                                   .collect(groupingBy(s -> s.length(), toList()));
    assertThat(mapThenCollect.get(1), is(asList("a")));
    assertThat(mapThenCollect.get(2), is(asList("on", "by")));
    assertThat(mapThenCollect.get(3), is(asList("cat", "tri")));

    Map<Integer, List<String>> mapWhileCollecting = src.stream()
                                                       .collect(groupingBy(s -> s.length(),
                                                                           toListMapped(s -> s.trim())));
    assertThat(mapWhileCollecting.get(2), is(asList("by")));
    assertThat(mapWhileCollecting.get(3), is(nullValue()));
    assertThat(mapWhileCollecting.get(4), is(asList("a", "on", "tri")));
    assertThat(mapWhileCollecting.get(5), is(asList("cat")));
}

あまり実用的な例ではなかったかもしれませんが、こういうcollect中にmapしたときって、たまに出くわすことでしょう。

ところがCollectors.mappingは、標準ライブラリということで汎用的に作られているので、ちょっとクドくなります。こんなときにtoListMappedのようなメソッドを作っておくと、地味に便利です。気の利いたIDEなら、toListまで入力すれば、toListMappedを候補に出してくれて、staticインポートまで追加してくれちゃいますからね!

Streamの入れ子(ネスト)を書きやすく part1

次にcero_tさんが、「禁止度A 業務では使わない方が良いレベル」に上げたのは、Streamの入れ子です。入れ子の例として「Collectorで行う集計ロジックの中で、データをフィルタするために、集計結果をstreamを使う」というケースを挙げられています。

まず、元のコードを見てみましょう(型推論が思うように動かずコンパイルできなかったため、Collectorを変数に入れることで解決しています)。

Map<Dept, Long> groupByDeptAndFilter1(List<Emp> list) {
    Collector<Emp, ?, Long> countHighSalary = collectingAndThen(toList(),
                                                                emps -> emps.stream()
                                                                            .filter(e -> e.salary() > 1000)
                                                                            .count());
    return list.stream().collect(groupingBy(Emp::dept, countHighSalary));
}

筆者は、これはけっこう読みやすい方だと思います。Streamを入れ子にすること自体は、一概に「可読性が低い」と言えないと思います。

むしろ問題なのは、「1度Listに集約したのに、Streamにする必要が出てきた」でも「.stream()をかますのが冗長」です。これって、先ほどのmap.entrySet().stream()と同じですね。

そこで、Streamに集約するtoStream()を用意してしまいましょう。なぜ標準ライブラリにtoStreamがないかは、不思議です。ニーズがありそうなものですが……。

Map<Dept, Long> groupByDeptAndFilter1_2(List<Emp> list) {
    Collector<Emp, ?, Long> countHighSalary = collectingAndThen(toStream(),
                                                                emps -> emps.filter(e -> e.salary() > 1000)
                                                                            .count());
    return list.stream().collect(groupingBy(Emp::dept, countHighSalary));}

これで、empsがListではなくStreamになったので、すぐさまフィルタなどのStreamのメソッドを活用できるようになりました。

このように「収集されたStreamを立て続けに操作」というのは、これまた非常によくありそうです。ですので、collectAndThen(toStream(), finisher)を、メソッドとして抽出してみましょう。名前は、またまた安直ですが、toStreamThenとしておきましょう。

Map<Dept, Long> groupByDeptAndFilter1_3(List<Emp> list) {
    return list.stream()
               .collect(groupingBy(Emp::dept,
                                   toStreamThen((Stream<Emp> emps) -> emps.filter(e -> e.salary() > 1000)
                                                                          .count())));
}

Emp::deptでグループ化し、グループ別にStreamに集約してフィルタしたた件数、というのがわかりやすくなったと思います。型推論も思ったとおりに動くようになり、一時変数も不要になりました。

これくらい意図が明確になって読みやすくなれば、CollectorなどでのStreamの入れ子は許容してもよいと思います。

Streamの入れ子(ネスト)を書きやすく part2 別のアプローチ

それからcero_tさんは、Collectors.collectingAndThenを使わないアプローチで、次のような例も示しています。結果は同じになるのですが、だいぶ複雑になっています。

Map<Dept, Long> groupByDeptAndFilter2(List<Emp> list) {
    return list.stream()
               .collect(groupingBy(Emp::dept))
               .entrySet()
               .stream()
               .collect(toMap(entry -> entry.getKey(),
                              entry -> entry.getValue()
                                            .stream()
                                            .filter(emp -> emp.salary() > 1000)
                                            .count()));
}

なぜcero_tさんがこんな例を示されたのかは分かりません。たしかに、collectingAndThenを知らないとなかなか最初の例のようには書けませんが、コードレビューで「collectingAndThenを使って」と指摘し、最初のように書きなおさせればいいことのように思います。

ともかく、あえてこの読みにくい、長ったらしいコードを改善してみましょう。

まず「groupingByして、結果のMapからEntryをStream」です。これには、先程作ったユーティリティtoGroupedEntriesを使えますね。

Map<Dept, Long> groupByDeptAndFilter2_2(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept, toList()))
               .collect(toMap(entry -> entry.getKey(),
                              entry -> entry.getValue()
                                            .stream()
                                            .filter(emp -> emp.salary() > 1000)
                                            .count()));
}

toMapの中で、toListで集約されたリストをStreamにしてフィルタ処理&計数しています。ここには、先ほど作ったユーティリティtoStreamThenが使ってみましょう。

Map<Dept, Long> groupByDeptAndFilter2_3(List<Emp> list) {
    return list.stream()
               .collect(toGroupedEntries(Emp::dept,
                                         toStreamThen((Stream<Emp> emps) -> emps.filter(emp -> emp.salary() > 1000)
                                                                                .count())))
               .collect(toMap(entry -> entry.getKey(), entry -> entry.getValue()));
}

Mapにグループ化する方法(グループ分けの条件を決めるEmp::dept、どのようにグループ化するかを決めるtoStreamThen)が1箇所に集まり、分かりやすくなりました。また、toMapの中がだいぶシンプルになりました。

さて、先に示した実装のとおり、toGroupedEntriesは、groupingByによって得られたMap<Dept, Long>をStream<Entry<Dept,Long>>に変換するだけのシンプルなCollectorを作っているだけです。ということは、Map→Stream→Mapと、箱を移し替えて、また戻しているようなムダをしてしまっています。

ということで、toGroupedEntriesの代わりに、groupingByを使って書き直しましょう。

Map<Dept, Long> groupByDeptAndFilter2_4(List<Emp> list) {
    return list.stream()
               .collect(groupingBy(Emp::dept,
                                   toStreamThen((Stream<Emp> emps) -> emps.filter(emp -> emp.salary() > 1000)
                                                                          .count())));
}

最初のアプローチで示した改善結果と同じものになりました。

まとめ

cero_tさんが提言された問題と教訓について、Collectorを積極的に作っていくという別なアプローチで改善案を示してまいりました。

  • 適切なCollectorを作ることで、collectをより読みやすく、便利にしよう!
  • Collectorをかんたんに組み上げるための様々なユーティリティが、Collectorsには揃っている!
  • 今回作ってみたtoGroupedEntries、toStreamThenは、わりと便利!
  • Collectorsの使用例やtoGroupedEntriesは、exoego/StreamUtilities を見てね!

蛇足

よくStream APIとの比較に出されるC#のLINQでは、ずばり、GroupByメソッドというのがあります。今回示したtoListMappedやtoStreamThenのように、変換しながら集約したり、集約結果をいじるためのオーバーロードが、GroupByには用意されています。

ところが、JavaのCollectors.groupingByには、そのようなオーバーロードはありません。その代わりに、mappingやcollectingAndThenなどを組わせることで、応用ができます。

Javaの標準ライブラリって、「原始的・汎用的・基礎的なものだけ提供する、あとは組み合わせて使えよ」という精神なのかなぁ。

人気の投稿