Javaストリーム操作 groupingByで更に集約する。(Set,Map,LinkedHashMap,TreeMap)

groupingBy の後の集約について

groupingByしたあとに、ただのListではなく、Setや、Map、さらにはLinkedHashMapにしたい場合もあるでしょう。
この記事では、groupingByしたあとに、それぞれの形に集約するサンプルをいくつか紹介していきます。

groupingByとは

groupingByとは、ストリームの集約関数でCollectorsというクラスに用意されています。
特定のキー値でStreamに流れてきた要素をグルーピングするための集約関数となります。

引数がいくつか省略できますが、キーだけを指定する場合、
集約された結果は、Map<K, List> という形に集約されます。

このListの部分を今回は、Setや、Map、LinkedHashMap、TreeMapなどを返せるようにしてみましょう。

Map<K, List<V>> デフォルト

サンプルデータとして、
下記リストの要素をグルーピングしてみます。
名前と年齢、性別を表現した要素のリストです。

List<Person> list = List.of(
 new Person(12, "Rose", "♀"),
 new Person(13, "Bobby", "♂"),
 new Person(13, "Abby", "♀"),
 new Person(14, "Lewis", "♂"),
 new Person(14, "Hannah", "♀"),
 new Person(15, "Dexter", "♂"),
 new Person(16, "Andrew", "♂"),
 new Person(17, "Noah", "♀"),
 new Person(18, "Iris", "♀"),
 new Person(18, "Tommy", "♂"),
 new Person(21, "Thomas", "♂"),
 new Person(25, "Leon", "♂"),
 new Person(28, "Norris", "♂"),
 new Person(20, "Emma", "♀"),
 new Person(24, "Kara", "♀"),
 new Person(28, "Vanessa", "♀"),
 new Person(29, "Celia", "♀"),
 new Person(33, "Ken", "♂"),
 new Person(36, "Cody", "♂"),
 new Person(36, "Mey", "♀"));

Collectors.groupingBy を使用して、年代ごとにグルーピングします。

// 年代ごとにグルーピングする。
// 10毎にグルーピング
Map<Integer, List<Person>> map = list.stream()
        .collect(Collectors.groupingBy(o -> (o.age / 10) * 10)); 

デフォルトのgroupoingByでは、指定のキー毎に List<V>が作られます。

10=[Rose(12:♀), Bobby(13:♂), Abby(13:♀), Lewis(14:♂), Hannah(14:♀), Dexter(15:♂), Andrew(16:♂), Noah(17:♀), Iris(18:♀), Tommy(18:♂)]
20=[Thomas(21:♂), Leon(25:♂), Norris(28:♂), Emma(20:♀), Kara(24:♀), Vanessa(28:♀), Celia(29:♀)]
30=[Ken(33:♂), Cody(36:♂), Mey(36:♀)]

donwstream

デフォルトのListから、Set,Mapなどに集約したい場合、
groupingByメソッドのdownstreamに、集約関数を指定します。

groupingByのシグネチャはこうなっています。

public static <T, K, A, D>
    Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
                                          Collector<? super T, A, D> downstream)

downstreamに渡すのは、Collectors.toSet() Collectors.toMap() などのすでに用意されている関数を渡す事ができますので、
ここの関数をうまく調整すれば、目的に合わせていろいろできるわけです。

Map<K, Set<V>>

// 年代ごとに年齢のSetを作る
// downstreamに Collectors.toSet() を指定するだけ
Map<Integer, Set<Integer>> map = list.stream()
        .map(o -> o.age)
        .collect(Collectors.groupingBy(o -> (o / 10) * 10, Collectors.toSet()));

年齢がSet<Integer>になっているのが確認できます。

10=[16, 17, 18, 12, 13, 14, 15]
20=[20, 21, 24, 25, 28, 29]
30=[33, 36]

Map<K, Map<L, V>>

// 年代ごとにnameでMapを作る
// downstreamに Collectors.toMap() を指定するだけ
// {年代 => {名前 =>  Person, ...}, ...}
Map<Integer, Map<String, Person>> map = list.stream()
        .collect(Collectors.groupingBy(o -> (o.age / 10) * 10,
                Collectors.toMap(o -> o.name, Function.identity())));

年代でグルーピングした後、nameをキーとしたHashMapになります。

10={Iris=Iris(18:♀), Andrew=Andrew(16:♂), Lewis=Lewis(14:♂), Abby=Abby(13:♀), Hannah=Hannah(14:♀), Rose=Rose(12:♀), Bobby=Bobby(13:♂), Dexter=Dexter(15:♂), Noah=Noah(17:♀), Tommy=Tommy(18:♂)}
20={Thomas=Thomas(21:♂), Norris=Norris(28:♂), Vanessa=Vanessa(28:♀), Kara=Kara(24:♀), Leon=Leon(25:♂), Emma=Emma(20:♀), Celia=Celia(29:♀)}
30={Mey=Mey(36:♀), Cody=Cody(36:♂), Ken=Ken(33:♂)}

Map<K, LinkedHashMap<L, V>>

前述したとおり、
Collectors.toMapを使えば、グルーピングした後にHashMap化することが可能です。
このHashMapをLinkedHashMapにしたい場合は、toMapの引数にさらにmapFactoryを指定するようにします。

Collectors.toMapのシグネチャはこんな感じ、mapFactoryは第4引数です。

public static <T, K, U, M extends Map<K, U>>
    Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper,
                             Function<? super T, ? extends U> valueMapper,
                             BinaryOperator<U> mergeFunction,
                             Supplier<M> mapFactory) 
// 年代ごとにnameでMapを作る
// Collectors.toMapのmapFactoryに作成したいクラスのコンストラクタ参照を指定。
Map<Integer, LinkedHashMap<String, Person>> map = list.stream()
        .collect(Collectors.groupingBy(o -> (o.age / 10) * 10,
                Collectors.toMap(o -> o.name,
                        Function.identity(),
                        (a, b) -> {
                            throw new IllegalStateException("duplicated keys: " + a + " and " + b);
                        },
                        LinkedHashMap::new)));

前述のHashMapの場合と比べて、LinkedHashMapになっているので、
要素の並び順が追加された順番(もともとの要素の順)で保持されている事に注目。
(HashMapの場合、順序は保持されません)

10={Rose=Rose(12:♀), Bobby=Bobby(13:♂), Abby=Abby(13:♀), Lewis=Lewis(14:♂), Hannah=Hannah(14:♀), Dexter=Dexter(15:♂), Andrew=Andrew(16:♂), Noah=Noah(17:♀), Iris=Iris(18:♀), Tommy=Tommy(18:♂)}
20={Thomas=Thomas(21:♂), Leon=Leon(25:♂), Norris=Norris(28:♂), Emma=Emma(20:♀), Kara=Kara(24:♀), Vanessa=Vanessa(28:♀), Celia=Celia(29:♀)}
30={Ken=Ken(33:♂), Cody=Cody(36:♂), Mey=Mey(36:♀)}

Collectors.toMapのmergeFunctionについて

Collectors.toMapの第4引数:mapFactoryを指定したいがために、第3引数:mergeFunctionも指定する必要があります。

このmergeFunctionとは、
キーが重複した場合に、どちらの要素を採用するのかを決めるためにコールされるものです。
採用する方をreturnすれば良いのです。

そもそも、キーが重複することを想定していないような場合では、
下記のように例外を発生させるのが良いでしょう。
(toMapの引数を省略した場合の挙動がそうなっています)

Collectors.toMap(o -> o.name,
        Function.identity(),
        (a, b) -> {
            // キー重複が発生した時点で例外にする
            throw new IllegalStateException("duplicated keys: " + a + " and " + b);
        },
        LinkedHashMap::new)));

Map<K, TreeMap<L, V>>

最後にTreeMapですが、LinkedHashMapの場合と同じく、Collectors.toMapのmapFactoryを指定すればOKです。

// 年代ごとにnameでMapを作る
// Collectors.toMapのmapFactoryに作成したいクラスのコンストラクタの参照を指定する
Map<Integer, TreeMap<String, Person>> map = list.stream()
        .collect(Collectors.groupingBy(o -> (o.age / 10) * 10,
                Collectors.toMap(o -> o.name,
                        Function.identity(),
                        (a, b) -> {
                            throw new IllegalStateException("duplicated keys: " + a + " and " + b);
                        },
                        TreeMap::new)));

TreeMap<String, Person>になるので、nameキーがアルファベット順に並んでいるのがわかると思います。

10={Abby=Abby(13:♀), Andrew=Andrew(16:♂), Bobby=Bobby(13:♂), Dexter=Dexter(15:♂), Hannah=Hannah(14:♀), Iris=Iris(18:♀), Lewis=Lewis(14:♂), Noah=Noah(17:♀), Rose=Rose(12:♀), Tommy=Tommy(18:♂)}
20={Celia=Celia(29:♀), Emma=Emma(20:♀), Kara=Kara(24:♀), Leon=Leon(25:♂), Norris=Norris(28:♂), Thomas=Thomas(21:♂), Vanessa=Vanessa(28:♀)}
30={Cody=Cody(36:♂), Ken=Ken(33:♂), Mey=Mey(36:♀)}

応用 groupingByで更に階層を深くする。

downstreamに集約関数を渡すことで、更に集約できるのがわかったと思います。
応用として、更にgroupingByすることで、階層を深くすることも可能です。

男女別でグルーピングした後に、
更に年代別にグルーピングした場合のサンプルも下記に記載します。

// 性別でグルーピング後、さらに年代ごとにnameでMapを作る
// {性別 => {年代 => {名前 => Person, ...}, ...}, ...}
Map<String, Map<Integer, TreeMap<String, Person>>> map = list.stream().collect(
        Collectors.groupingBy(o -> o.sex,
                Collectors.groupingBy(o -> (o.age / 10) * 10,
                        Collectors.toMap(o -> o.name,
                                Function.identity(),
                                (a, b) -> {
                                    throw new IllegalStateException("duplicated keys: " + a + " and " + b);
                                },
                                TreeMap::new))));

downstreamに更に集約関数を続ける事で、更にネストすることが可能。

♀={
  10={Abby=Abby(13:♀), Hannah=Hannah(14:♀), Iris=Iris(18:♀), Noah=Noah(17:♀), Rose=Rose(12:♀)}
  20={Celia=Celia(29:♀), Emma=Emma(20:♀), Kara=Kara(24:♀), Vanessa=Vanessa(28:♀)}
  30={Mey=Mey(36:♀)}
}
♂={
  10={Andrew=Andrew(16:♂), Bobby=Bobby(13:♂), Dexter=Dexter(15:♂), Lewis=Lewis(14:♂), Tommy=Tommy(18:♂)}
  20={Leon=Leon(25:♂), Norris=Norris(28:♂), Thomas=Thomas(21:♂)}
  30={Cody=Cody(36:♂), Ken=Ken(33:♂)}
}

まとめ

  • groupingByにdownstreamとして、更に集約関数を渡す事ができる。
  • downstreamを指定していない場合、デフォルトでList で集約される。
  • Collectors.toSet, toMap を利用することでグルーピングして更に集約する方法を変更できる。
  • 集約関数を組み合わせることで、好きに階層を作れる。

groupingByを使うと、複雑なMap構造を作れます。
変数宣言するのがちょっと面倒ですが、
Java9以降であれば var を使って型推論で省略して記載できるので便利です。
今回は集約後の型がわかりやすいようにあえて明記しています。

覚えると、いろんなパターンが自由にかけます。
書き方はちょっと忘れやすいと思うので、またその時はこの記事を参考にしてもらえると嬉しいです。

コメントを残す