Writing a Collector properly

Streams · lesson 41 of 42 · 7 min read

Supplier, accumulator, combiner, finisher - and what the characteristics promise.

Open this lesson in the learning hub

Key points

  • A Collector is four functions. supplier creates a fresh container, accumulator folds one element in, combiner merges two containers, and finisher converts the container to the result.
  • The combiner is only used in parallel. A collector with a wrong or lazy combiner works perfectly in every sequential test and produces wrong answers only under parallel execution.
  • IDENTITY_FINISH declares that the container is already the result, letting the pipeline skip the finisher entirely. Claiming it while returning a different type is a ClassCastException at runtime.
  • CONCURRENT is a much stronger claim: it says a single container may be shared by all threads, so the accumulator itself must be thread-safe. Combined with UNORDERED it lets the pipeline skip merging.
  • UNORDERED declares the result does not depend on encounter order. It permits real optimisations, and it is wrong for anything building an ordered list.
  • Before writing one, check Collectors.teeing, collectingAndThen and mapping - most custom collectors people write are a composition of existing ones.

Example

import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
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;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class CustomCollector {

    /** Collects into an immutable list, tracking min and max as it goes. */
    record Summary(List<Integer> values, int min, int max) { }

    static class Acc {
        final List<Integer> items = new ArrayList<>();
        int min = Integer.MAX_VALUE, max = Integer.MIN_VALUE;
    }

    static Collector<Integer, Acc, Summary> summarising() {
        Supplier<Acc> supplier = Acc::new;

        BiConsumer<Acc, Integer> accumulator = (a, v) -> {
            a.items.add(v);
            a.min = Math.min(a.min, v);
            a.max = Math.max(a.max, v);
        };

        // ONLY used in parallel. A wrong combiner passes every sequential test.
        BinaryOperator<Acc> combiner = (l, r) -> {
            l.items.addAll(r.items);
            l.min = Math.min(l.min, r.min);
            l.max = Math.max(l.max, r.max);
            return l;
        };

        Function<Acc, Summary> finisher = a ->
                new Summary(Collections.unmodifiableList(a.items), a.min, a.max);

        // NOT IDENTITY_FINISH: the container is Acc, the result is Summary.
        Set<Collector.Characteristics> characteristics = EnumSet.noneOf(Collector.Characteristics.class);
        return Collector.of(supplier, accumulator, combiner, finisher,
                characteristics.toArray(new Collector.Characteristics[0]));
    }

    public static void main(String[] args) {
        List<Integer> data = IntStream.rangeClosed(1, 10_000).boxed().collect(Collectors.toList());

        Summary seq = data.stream().collect(summarising());
        System.out.println("sequential: size=" + seq.values().size()
                + " min=" + seq.min() + " max=" + seq.max());

        // The combiner is exercised only here.
        Summary par = data.parallelStream().collect(summarising());
        System.out.println("parallel  : size=" + par.values().size()
                + " min=" + par.min() + " max=" + par.max());
        System.out.println("agree     : " + (seq.values().size() == par.values().size()
                && seq.min() == par.min() && seq.max() == par.max()));

        // The result really is immutable.
        try {
            seq.values().add(1);
        } catch (UnsupportedOperationException e) {
            System.out.println("finisher produced an immutable list: yes");
        }

        // Most "custom" collectors are a composition of existing ones.
        var minMax = data.stream().collect(Collectors.teeing(
                Collectors.minBy(Integer::compare),
                Collectors.maxBy(Integer::compare),
                (lo, hi) -> lo.orElseThrow() + ".." + hi.orElseThrow()));
        System.out.println("teeing gave the same range: " + minMax);
    }
}

The combiner runs only in parallel, so a wrong one passes every sequential test - and the characteristics are promises the runtime trusts.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Streams course, and every lesson in it is listed on the Streams contents page.