Java | Java 詳細・モダン文法:Stream API 深掘り – Collector の自作

Java Java
スポンサーリンク

Collector 自作のゴールをまずイメージする

Collector を自作する、というのは
「Stream の要素を、標準の toListgroupingBy では表現しづらい“自分専用の集め方”でまとめたい」
ときにやることです。

やっていることはシンプルで、
「どんな器を用意して、どう要素を入れて、どう器同士をくっつけて、最後にどう仕上げるか」
を自分で定義するだけです。

難しそうに見えるのは、インターフェースの見た目がゴツいだけです。
中身は「バケツを作る・詰める・くっつける・仕上げる」の 4 ステップだと捉えると一気に楽になります。


Collector の構成要素をざっくりつかむ

型パラメータ T, A, R の意味

Collector<T, A, R> には 3 つの型パラメータがあります。

T は Stream の要素の型です。
A は「途中で使うバケツ(中間バッファ)」の型です。
R は「最終的な結果」の型です。

例えば「Stream<String>StringBuilder に詰めて、最後に String にする Collector」なら、
Collector<String, StringBuilder, String> というイメージになります。

必要なメソッドの役割

自作するときに実装するのは、ざっくり次の 4 つです。

supplier
空のバケツ A を作る関数です。() -> A という形になります。

accumulator
バケツ A に要素 T を 1 つ追加する関数です。(A, T) -> void という形です。

combiner
並列処理時に、バケツ A と A を 1 つにまとめる関数です。(A, A) -> A です。

finisher
バケツ A を最終結果 R に変換する関数です。A -> R です。

これに加えて、「どんな性質を持つ Collector か」を表す characteristics がありますが、最初は「とりあえず空集合を返す」から始めて構いません。


まずは「toList っぽい Collector」を自作してみる

Stream<T> を List<T> に集める Collector

標準の Collectors.toList() とほぼ同じものを、自分で書いてみます。

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;

public class MyCollectors {
    public static <T> Collector<T, List<T>, List<T>> toList() {
        return new Collector<T, List<T>, List<T>>() {
            @Override
            public Supplier<List<T>> supplier() {
                return ArrayList::new; // 空のバケツ(ArrayList)を作る
            }

            @Override
            public BiConsumer<List<T>, T> accumulator() {
                return List::add;      // バケツに要素を 1 つ追加
            }

            @Override
            public BinaryOperator<List<T>> combiner() {
                return (left, right) -> {
                    left.addAll(right); // 並列時にバケツ同士をマージ
                    return left;
                };
            }

            @Override
            public Function<List<T>, List<T>> finisher() {
                return Function.identity(); // そのまま返す
            }

            @Override
            public Set<Characteristics> characteristics() {
                return Set.of(Characteristics.IDENTITY_FINISH);
            }
        };
    }
}
Java

使い方は標準の toList と同じです。

import java.util.List;

public class MyCollectorDemo {
    public static void main(String[] args) {
        List<String> result =
                List.of("a", "b", "c").stream()
                    .collect(MyCollectors.toList());

        System.out.println(result); // [a, b, c]
    }
}
Java

ここで一番大事なのは、「supplier でバケツを作り、accumulator で詰め、combiner でくっつけ、finisher で仕上げる」という流れが見えることです。
このパターンさえ掴めば、あとは「バケツの型」と「詰め方」を変えるだけで、いろいろな Collector を作れます。


もう一歩:平均値を計算する Collector を自作する

Stream<Integer> から平均値 double を計算する

今度は少しだけ面白い例として、Stream<Integer> から平均値を計算する Collector を作ってみます。
途中で「合計」と「件数」を持っておき、最後に sum / count を計算するイメージです。

まずはバケツ用のクラスを作ります。

class IntSummary {
    long sum = 0;
    long count = 0;

    void add(int value) {
        sum += value;
        count++;
    }

    IntSummary combine(IntSummary other) {
        this.sum += other.sum;
        this.count += other.count;
        return this;
    }

    double average() {
        return count == 0 ? 0.0 : (double) sum / count;
    }
}
Java

これを使う Collector はこう書けます。

import java.util.Set;
import java.util.function.*;
import java.util.stream.Collector;

public class MyCollectors {
    public static Collector<Integer, IntSummary, Double> averagingInt() {
        return new Collector<Integer, IntSummary, Double>() {
            @Override
            public Supplier<IntSummary> supplier() {
                return IntSummary::new; // sum=0, count=0 のバケツ
            }

            @Override
            public BiConsumer<IntSummary, Integer> accumulator() {
                return (summary, value) -> summary.add(value);
            }

            @Override
            public BinaryOperator<IntSummary> combiner() {
                return (left, right) -> left.combine(right);
            }

            @Override
            public Function<IntSummary, Double> finisher() {
                return IntSummary::average; // 最後に平均を計算
            }

            @Override
            public Set<Characteristics> characteristics() {
                return Set.of(); // IDENTITY_FINISH ではないので空
            }
        };
    }
}
Java

使い方はこうです。

import java.util.List;

public class AveragingDemo {
    public static void main(String[] args) {
        double avg =
                List.of(1, 2, 3, 4, 5).stream()
                    .collect(MyCollectors.averagingInt());

        System.out.println(avg); // 3.0
    }
}
Java

ここでのポイントは、結果の型 R が Double で、バケツの型 A が IntSummary になっていることです。
「途中では合計と件数を持ち、最後にだけ平均に変換する」というパターンは、finisher を使う典型例です。


characteristics をどう考えるか

IDENTITY_FINISH と CONCURRENT / UNORDERED

characteristics() で返すセットは、「Collector の性質」をランタイムに伝えるためのヒントです。

IDENTITY_FINISH
finisher が「そのまま返す(identity)」であることを示します。
つまり A と R が同じ型で、finisherFunction.identity() のときに付けます。
先ほどの toList 自作版がこれに当たります。

CONCURRENT
複数スレッドから同時に accumulator が呼ばれても安全に動く Collector であることを示します。
スレッドセーフなバケツ(例:ConcurrentHashMap)を使う場合などに付けます。

UNORDERED
要素の順序に意味がない Collector であることを示します。
Set に集めるなど、「順番どうでもいい」場合に付けると、並列処理の最適化がしやすくなります。

初心者のうちは、
「まずは characteristics は空か IDENTITY_FINISH だけでいい」
と割り切って構いません。
慣れてきたら、「順序不要なら UNORDERED を付けると並列で速くなることがある」くらいを意識し始めるとよいです。


まとめ:Collector 自作を自分の言葉で整理する

Collector 自作をあなたの言葉でまとめるなら、こうなります。

「Collector を自作するというのは、
Stream の要素をどういう“バケツ”にどう詰めて、最後にどう仕上げるか、というレシピを自分で書くこと。
そのために、supplier(バケツを作る)、accumulator(詰める)、combiner(くっつける)、finisher(仕上げる)を定義する。」

まずは toList っぽい Collector を自分で書いてみて、
次に「合計+件数から平均を出す」ような、少しだけロジックを持った Collector を作ってみると、仕組みが一気に腑に落ちます。

タイトルとURLをコピーしました