Skip to content
academia.sh

Lesson 09 / 15

Collectors

A collector gets built from four parts: supplier, accumulator, combiner, finisher. The result container's type and mutability are not the interface's promise — toList() returns a mutable list but promises this nowhere, while toUnmodifiableList() guarantees immutability. Once a duplicate key gets encountered, the map collector gives an exception, grouping merges.

Contents

The previous lesson measured what a stream chain says: order is mostly the source’s promise, laziness is an unconditional guarantee but visiting every item is not. What accumulates at the end of the chain is usually not a single value, it is a container — a list, a map, a number — and the piece that builds this container is a collector (Collector) handed to the collect call. Names like toList(), groupingBy(), joining() read less like a method and more like a recipe: each is a collector the library has already built and delivers ready-made. What promise does a collector itself give, is the result container’s type part of that promise, and once a duplicate key gets encountered, what does each collector do?

A Collector’s Four Parts

A collector is not a single method; it gets built from four separate parts. The supplier builds an empty container, the accumulator works each item in the stream into this container, the combiner merges two separate containers into one (it enters play in parallel runs), the finisher turns the accumulated container into the final result. The ready-made collectors in the Collectors class already deliver these four parts built; here, they get built by hand to see the parts themselves.

  • FJ23 — The four parts get given one by one with Collector.of and get used in a stream chain. The container here is not a string, it is a single-slot int[] — an accumulator holding a total letter count.
// Parts.java - a collector's four parts: supplier, accumulator, combiner, finisher
import java.util.function.*;
import java.util.stream.*;

public class Parts {
    public static void main(String[] args) {
        Supplier<int[]> supplier = () -> new int[1];
        BiConsumer<int[], String> accumulator = (box, word) -> box[0] += word.length();
        BinaryOperator<int[]> combiner = (a, b) -> { a[0] += b[0]; return a; };
        Function<int[], Integer> finisher = box -> box[0];

        Collector<String, int[], Integer> letterCounter =
                Collector.of(supplier, accumulator, combiner, finisher);

        int total = Stream.of("pear", "cherry", "apple").collect(letterCounter);
        System.out.println("collector built from four parts, total letters: " + total);
    }
}
collector built from four parts, total letters: 15

The result is correct: pear (4) + cherry (6) + apple (5) = 15. But what gets measured here is not the result itself, it is the four parts’ role. supplier gets called once before the stream starts and builds the empty box. accumulator gets called once per item and updates the box — this is the stream chain’s real work. combiner never gets called in this run, because the stream is sequential; no two separate parts came from the stream that need merging. finisher gets called once at the very end and turns the int[] box into a plain Integer. Ready-made collectors like Collectors.toList(), Collectors.groupingBy() already fill in and deliver these four parts; the only difference between them is which container they build and how they write into it.

combiner never getting called in this run is not a gap, it is exactly the expected result. The standard library’s four-part design accounts for the possibility of the stream running in parallel from the start: once the stream gets split into more than one part and each part accumulates its own box, the step that reduces the boxes to one is the combiner. This course does not parallelize streams, so the combiner does no work here — but the collector’s signature always requires it, because the same collector object has to be usable on a parallel stream too. Three of the four parts (supplier, accumulator, finisher) are enough on a sequential stream, while the fourth sits there only for the possibility.

The Result Container’s Type Is Not the Interface’s Promise

Collectors.toList() returns a List<T>, but does not specify which class’s List it is. This is the reappearance, here, of the “promise left to the implementation” pattern measured in the previous topic: the interface states a type, it does not state which concrete class it is.

  • FJ24 — The same three words get collected with two separate collectors: toList() and toUnmodifiableList(). Adding an item afterward gets tried on both.
// Container.java - is the collector's returned container's type and mutability in the docs
import java.util.*;
import java.util.stream.*;

public class Container {
    public static void main(String[] args) {
        List<String> mutable = Stream.of("pear", "cherry").collect(Collectors.toList());
        try {
            mutable.add("apple");
            System.out.println("adding to toList() result: succeeded (no promise in the docs)");
        } catch (UnsupportedOperationException e) {
            System.out.println("adding to toList() result: failed");
        }

        List<String> immutable = Stream.of("pear", "cherry").collect(Collectors.toUnmodifiableList());
        try {
            immutable.add("apple");
            System.out.println("adding to toUnmodifiableList() result: succeeded");
        } catch (UnsupportedOperationException e) {
            System.out.println("adding to toUnmodifiableList() result -> exception: " + e.getClass().getSimpleName());
        }
    }
}
adding to toList() result: succeeded (no promise in the docs)
adding to toUnmodifiableList() result -> exception: UnsupportedOperationException

Adding to the list toList() returns succeeds — but this is not a guarantee, it is a consequence of today’s implementation. Collectors.toList()‘s documentation gives no promise about the returned list’s type, mutability, or thread safety; even if this code returned a different class tomorrow, no contract would break, because no contract was ever given. toUnmodifiableList(), though, does exactly the opposite: trying to add falls with UnsupportedOperationException, and this fall is a behavior explicitly guaranteed in the documentation. Both methods return a List<String>; their signatures are the same, their promises are not. This difference cannot be seen by reading the type — only by reading the documentation, or, exactly as done here, by running it.

This ambiguity has a rationale: not promising leaves the library room to move. Had toList()’s documentation fixed which class it would return, that class could never change again — every change would break backward compatibility. By not promising, the library keeps the right to change its own implementation whenever it wants; the price for this gets paid by the caller having to answer “is this list mutable” by reading the documentation, not the code.

A Finisher Can Get Added to an Existing Collector Later

toUnmodifiableList() guaranteed immutability, but it is not the case that only the one collector carrying its own name can give this guarantee. Since the four parts are separate from each other, a new transformation can get added on top of any existing collector’s finisher — without touching the rest of the collector.

  • FJ25Collections::unmodifiableList gets added as a second finisher on top of the container Collectors.toList() produces. Collectors.joining also gets tried with three separate contributions — delimiter, prefix, suffix — on an empty and a full stream.
// Finisher.java - can a finisher be added later, how does a three-part joining work
import java.util.*;
import java.util.stream.*;

public class Finisher {
    public static void main(String[] args) {
        List<String> layered = Stream.of("pear", "cherry", "apple")
                .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
        try {
            layered.add("grape");
            System.out.println("adding to collectingAndThen result: succeeded");
        } catch (UnsupportedOperationException e) {
            System.out.println("adding to collectingAndThen result -> exception: " + e.getClass().getSimpleName());
        }

        String empty = Stream.<String>of().collect(Collectors.joining(", ", "[", "]"));
        String full = Stream.of("pear", "cherry", "apple").collect(Collectors.joining(", ", "[", "]"));
        System.out.println("joining on empty stream : " + empty);
        System.out.println("joining on full stream  : " + full);
    }
}
adding to collectingAndThen result -> exception: UnsupportedOperationException
joining on empty stream : []
joining on full stream  : [pear, cherry, apple]

Collectors.collectingAndThen takes an existing collector (Collectors.toList()) and an extra transformation function (Collections::unmodifiableList), and fuses them into a single new finisher — the supplier and accumulator stay exactly the same as toList()‘s, only the last step changes. The result is the same as toUnmodifiableList(): adding falls with UnsupportedOperationException. This completes the previous section’s observation that “the result container’s type is not the interface’s promise”: if the promise is missing, the caller can add it afterward with their own finisher.

The second measurement shows when the finisher runs. joining(", ", "[", "]") takes three separate pieces: the delimiter goes between items, the prefix and suffix get placed only once, at the very start and the very end. Even on an empty stream, the result is [] — the prefix and suffix still get added, because the finisher adding them runs exactly once at the end, no matter how many items accumulated. On the full stream, the three items get separated by two delimiters, and the prefix and suffix again show up only once each. This is direct proof that the finisher is a step separate from the accumulator: the accumulator runs once per item, the finisher runs once, independent of item count.

The delimiter itself shows the same distinction: between three items, there are two delimiters, not three. The logic adding the delimiter sits in the accumulator and follows the rule “add the delimiter first, if this is not the first item” — this rule does not fire on the first item, so it produces one fewer delimiter than the item count, not one per item. The prefix and suffix, though, have no place in the accumulator at all; they are only the finisher’s job, and this is why both show up even when the stream is entirely empty.

Duplicate Key: Exception in a Map, Merging in Grouping

A collector’s rule that actually matters to the caller shows up when the input arrives in an unexpected shape. When two words of the same length — that is, the same key — show up, two different collectors give two different reactions.

  • FJ26 — Two of five words are four letters (pear, plum), two are five letters (apple, grape). The same stream first gets grouped by length, then gets tried for collection into a map with length as the key.
// Repeated.java - duplicate key: merges in grouping, exception in a map
import java.util.*;
import java.util.stream.*;

public class Repeated {
    public static void main(String[] args) {
        List<String> words = List.of("pear", "plum", "apple", "grape", "fig");

        Map<Integer, List<String>> grouped = words.stream()
                .collect(Collectors.groupingBy(String::length));
        System.out.println("grouping's map type: " + (grouped instanceof HashMap ? "HashMap" : "other"));
        System.out.println("length 4 group      : " + grouped.get(4));
        System.out.println("length 5 group      : " + grouped.get(5));

        try {
            Map<Integer, String> map = words.stream()
                    .collect(Collectors.toMap(String::length, s -> s));
            System.out.println("toMap duplicate key: fell through, size=" + map.size());
        } catch (IllegalStateException e) {
            System.out.println("toMap duplicate key -> exception: " + e.getClass().getSimpleName());
        }

        Map<Integer, String> mergedMap = words.stream()
                .collect(Collectors.toMap(String::length, s -> s, (a, b) -> a + "+" + b));
        System.out.println("with a merge function given: " + mergedMap.get(4));

        Map<Integer, String> orderedMap = words.stream()
                .collect(Collectors.toMap(String::length, s -> s, (a, b) -> a + "+" + b, TreeMap::new));
        System.out.println("with a supplier given, type: " + (orderedMap instanceof TreeMap ? "TreeMap" : "other"));
    }
}
grouping's map type: HashMap
length 4 group      : [pear, plum]
length 5 group      : [apple, grape]
toMap duplicate key -> exception: IllegalStateException
with a merge function given: pear+plum
with a supplier given, type: TreeMap

The first line repeats the previous section’s observation for groupingBy: groupingBy‘s documentation says “returns a Map,” it does not say which class; today’s implementation gives a HashMap, but this too is an implementation detail like toList()’s, not the promise itself. groupingBy(String::length) splits five words into two groups, and no item gets lost — the length-4 group has two words, the length-5 group has two words. A duplicate key here is not an error, it is the expected case: grouping already assumes every key can carry more than one value, and accumulates the values in a list.

toMap(String::length, s -> s), called with the same input, behaves differently: it assumes every key corresponds to a single value, does not know what to do once a second value arrives, and throws IllegalStateException. The third line shows the path that closes this ambiguity: toMap with four independent arguments takes a merge function, and merges two colliding values into a single value like "pear+plum" — no more exception.

The last line is the boundary measurement, and it joins with the lesson’s second finding. toMap with five independent arguments also takes a container supplier (TreeMap::new); once a supplier gets given, the result container’s type is no longer ambiguous, it is exactly the requested class, and this is a precise outcome confirmed with instanceof in the run. That is, the ambiguity about the result container’s type is not permanent — once the caller gives the supplier themselves, a promise left to the implementation passes back into the caller’s hands, and the observation becomes as certain as an interface guarantee.

toMap‘s four separate forms gather these three measurements into a single pattern — each extra argument hands one of the collector’s four parts back to the caller:

Argument count Parts given Behavior on duplicate key
2 (key, value) accumulator is the library’s default IllegalStateException
3 (+ combiner) accumulator with the caller’s own rule colliding values merge
4 (+ container supplier) supplier is the caller’s too merges, and the container’s type is fixed too

The two-argument form leaves the supplier and the combiner to the library’s own default; there is no default combiner, so the only thing the collector can do at the moment of collision is stop. The three- and four-argument forms hand these two parts back to the caller in order — the collector’s “four parts” structure here is not an abstract description, it is a fact visible directly in the method signature. The accumulator is always implicitly fixed: it gets built from the key and value functions, it never gets written separately. The finisher is also the same across all four forms — since the container itself is already the result, no extra transformation is needed. What changes is only the supplier and the combiner, and these two, exactly, are what can get left to the caller, because both correspond to the “which container” question; the “which key, which value” question is already answered by the first two arguments.

Summary

  • A collector gets built from four parts: supplier (builds the empty container), accumulator (works every item into the container), combiner (merges two partial containers), finisher (turns the container into the final result).
  • Collectors.toList()’s returned list being mutable is an implementation detail, not guaranteed in the documentation; toUnmodifiableList() explicitly guarantees immutability and its violation falls with UnsupportedOperationException.
  • groupingBy, once it encounters a duplicate key, accumulates the values in a list, no item gets lost; two-argument toMap throws IllegalStateException in the same situation.
  • Once a merge function gets given, toMap merges the colliding values; the exception disappears.
  • Boundary measurement: once the caller gives an explicit container supplier (like TreeMap::new), the ambiguity about the result container’s type closes — a promise left to the implementation can get reclaimed by the caller.
  • toMap’s four forms show these four parts directly in the method signature: the two-argument form leaves the supplier and combiner to the library, the four-argument form hands both to the caller.

Next Step

This lesson measured which promises the container accumulating at the end of a stream chain gets built with: four parts, the result container’s type left unstated in the documentation, the distinction between exception and merging on a duplicate key, and how that distinction can get closed with an explicit container supplier. Collecting always produces a value — collecting into an empty list gives an empty list, counting an empty stream gives zero. But once a single item gets sought, what happens if that item does not exist in the source at all? Some methods of the standard library return null in this case, some throw an exception. The next lesson takes up a type where absence gets expressed as a type: once absence moves into a type, does the caller’s obligation genuinely move to compile time, or does the check still stay at runtime?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close