collectors
toList, groupingBy, partitioningBy, toMap, teeing.
- chapter
- chapter 07.06
- reading time
- 5 min read
collect(...) folds a stream into one result using a collector: a recipe that says how to make an empty container, how to add one element, how to merge two containers, and how to finish.
Collectors ships the common recipes: toList, toMap, joining, groupingBy, partitioningBy, teeing. the real power is downstream collectors: a collector that runs inside each group of another collector.
the picture
you know that loop where you make a HashMap<String, List<Thing>>, check if the key exists, create the list if it doesn't, then add? groupingBy is that loop, written once and correctly.
think of a mail room. every letter gets read once and dropped into a pigeonhole labelled with its key (the classifier). what happens inside each pigeonhole is a separate decision (the downstream): keep the letters, just count them, or staple them into one bundle.
·groupingBy starts with an empty map. buckets are created the first time a key shows up.
the everyday ones
import java.util.*;
import static java.util.stream.Collectors.*;
void main() {
List<String> words = List.of("fig", "kiwi", "plum", "pear", "apple",
"mango", "banana", "cherry", "avocado");
IO.println(words.stream().limit(3).toList()); // java 16, unmodifiable
IO.println(words.stream().limit(3).collect(joining(", ")));
IO.println(words.stream().limit(3).collect(joining("|", "[", "]")));
IO.println(words.stream().collect(averagingInt(String::length)));
IO.println(words.stream().collect(summarizingInt(String::length)));
}[fig, kiwi, plum]
fig, kiwi, plum
[fig|kiwi|plum]
4.888888888888889
IntSummaryStatistics{count=9, sum=44, min=3, average=4.888889, max=7}groupingBy and downstream collectors
groupingBy(classifier) alone gives you Map<K, List<T>>. the second argument replaces that List with any other collector, and a three-argument version also lets you pick the map:
import java.util.*;
import java.util.function.*;
import static java.util.stream.Collectors.*;
void main() {
List<String> words = List.of("fig", "kiwi", "plum", "pear", "apple",
"mango", "banana", "cherry", "avocado");
IO.println(words.stream()
.collect(groupingBy(String::length, TreeMap::new, counting())));
IO.println(words.stream()
.collect(groupingBy(w -> w.charAt(0), TreeMap::new,
// a downstream inside a downstream
mapping(String::toUpperCase, joining("+")))));
IO.println(words.stream()
.collect(partitioningBy(w -> w.length() > 4,
maxBy(Comparator.comparingInt(String::length)))));
// word frequencies
String text = "the cat and the hat and the bat";
IO.println(Arrays.stream(text.split(" "))
.collect(groupingBy(Function.identity(), TreeMap::new, counting())));
}{3=1, 4=3, 5=2, 6=2, 7=1}
{a=APPLE+AVOCADO, b=BANANA, c=CHERRY, f=FIG, k=KIWI, m=MANGO, p=PLUM+PEAR}
{false=Optional[kiwi], true=Optional[avocado]}
{and=2, bat=1, cat=1, hat=1, the=3}downstreams nest as deep as you like: mapping, filtering and flatMapping transform elements before they reach the inner collector, counting, summingInt, averagingDouble, minBy, maxBy reduce them, and collectingAndThen post-processes the result. that last example, word frequencies in one expression, is a classic interview question.
toMap, and the duplicate key
toMap(keyFn, valueFn) is the one that blows up in production. it assumes keys are unique, and it's right until the day it isn't:
import java.util.*;
import java.util.stream.*;
void main() {
List<String> words = List.of("apple", "avocado", "banana");
IO.println("indexing " + words);
Map<Character, String> byInitial = words.stream()
.collect(Collectors.toMap(w -> w.charAt(0), w -> w));
IO.println(byInitial);
}indexing [apple, avocado, banana] Exception in thread "main" java.lang.IllegalStateException: Duplicate key a (attempted merging values apple and avocado)
the fix is the third argument, a merge function that says what to do when two values land on the same key. add a fourth for the map type:
import java.util.*;
import static java.util.stream.Collectors.*;
void main() {
List<String> words = List.of("fig", "kiwi", "plum", "pear", "apple",
"mango", "banana", "cherry", "avocado");
IO.println(words.stream()
.collect(toMap(w -> w.charAt(0), w -> 1, Integer::sum)));
IO.println(words.stream()
.collect(toMap(w -> w.charAt(0), w -> w,
(a, b) -> a + "+" + b, // merge function
TreeMap::new))); // map factory
}{p=2, a=2, b=1, c=1, f=1, k=1, m=1}
{a=apple+avocado, b=banana, c=cherry, f=fig, k=kiwi, m=mango, p=plum+pear}see the first line's key order: p before a. that's a HashMap, and 'p' (112) happens to land in bucket 0 of a 16-slot table. never print or test against a HashMap's order. (why)
teeing: two collectors at once
teeing (java 12) sends every element to two collectors and combines their results at the end. one pass, two answers:
import java.util.stream.*;
import static java.util.stream.Collectors.*;
record Range(int min, int max) {}
void main() {
Range r = Stream.of(4, 8, 15, 16, 23, 42).collect(teeing(
minBy(Integer::compare),
maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));
IO.println(r);
}Range[min=4, max=42]
what a collector actually is
every collector is four functions. collect calls them in this order:
hover a stage.
so you can build your own with Collector.of:
import java.util.*;
import java.util.stream.*;
void main() {
Collector<String, StringBuilder, String> shout = Collector.of(
StringBuilder::new, // supplier
(sb, w) -> sb.append(w.toUpperCase()), // accumulator
StringBuilder::append, // combiner
StringBuilder::toString); // finisher
IO.println(Stream.of("fig", "kiwi", "plum").collect(shout));
}FIGKIWIPLUM
the container is mutable on purpose. that's the big difference between collect and reduce, which is the next chapter.
- a collector is supplier, accumulator, combiner, finisher.
collectfolds into a mutable container. groupingBymakes buckets on demand.partitioningByalways has bothtrueandfalse.- downstream collectors decide what each bucket holds: a list, a count, a joined string, anything.
toMapthrows on duplicate keys unless you give it a merge function.Stream.toList()is unmodifiable.Collectors.toList()isn't. don't swap them blindly.