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.ofand get used in a stream chain. The container here is not a string, it is a single-slotint[]— 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()andtoUnmodifiableList(). 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.
- FJ25 —
Collections::unmodifiableListgets added as a second finisher on top of the containerCollectors.toList()produces.Collectors.joiningalso 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 withUnsupportedOperationException.groupingBy, once it encounters a duplicate key, accumulates the values in a list, no item gets lost; two-argumenttoMapthrowsIllegalStateExceptionin the same situation.- Once a merge function gets given,
toMapmerges 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.