Collector 自作のゴールをまずイメージする
Collector を自作する、というのは
「Stream の要素を、標準の toList や groupingBy では表現しづらい“自分専用の集め方”でまとめたい」
ときにやることです。
やっていることはシンプルで、
「どんな器を用意して、どう要素を入れて、どう器同士をくっつけて、最後にどう仕上げるか」
を自分で定義するだけです。
難しそうに見えるのは、インターフェースの見た目がゴツいだけです。
中身は「バケツを作る・詰める・くっつける・仕上げる」の 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_FINISHfinisher が「そのまま返す(identity)」であることを示します。
つまり A と R が同じ型で、finisher が Function.identity() のときに付けます。
先ほどの toList 自作版がこれに当たります。
CONCURRENT
複数スレッドから同時に accumulator が呼ばれても安全に動く Collector であることを示します。
スレッドセーフなバケツ(例:ConcurrentHashMap)を使う場合などに付けます。
UNORDERED
要素の順序に意味がない Collector であることを示します。
Set に集めるなど、「順番どうでもいい」場合に付けると、並列処理の最適化がしやすくなります。
初心者のうちは、
「まずは characteristics は空か IDENTITY_FINISH だけでいい」
と割り切って構いません。
慣れてきたら、「順序不要なら UNORDERED を付けると並列で速くなることがある」くらいを意識し始めるとよいです。
まとめ:Collector 自作を自分の言葉で整理する
Collector 自作をあなたの言葉でまとめるなら、こうなります。
「Collector を自作するというのは、
Stream の要素をどういう“バケツ”にどう詰めて、最後にどう仕上げるか、というレシピを自分で書くこと。
そのために、supplier(バケツを作る)、accumulator(詰める)、combiner(くっつける)、finisher(仕上げる)を定義する。」
まずは toList っぽい Collector を自分で書いてみて、
次に「合計+件数から平均を出す」ような、少しだけロジックを持った Collector を作ってみると、仕組みが一気に腑に落ちます。
