Week 7 Day 4 — collect, groupingBy, and reduce
Goal
Today I want terminal operations that build collections and maps without mutating a shared HashMap inside forEach.
Main questions:
- Why
collectinstead offorEach+add? - What does
Collectors.toMapdo on a duplicate key? - How does
groupingBywork? - When is
reducethe right tool? - Why primitive streams exist?
1. collect is the usual terminal
List<OrderResponse> body = orders.stream()
.map(OrderResponse::from)
.toList(); // unmodifiable, Java 16+
List<OrderResponse> mutable = orders.stream()
.map(OrderResponse::from)
.collect(Collectors.toCollection(ArrayList::new));
Set<String> skus = lines.stream()
.map(Line::sku)
.collect(Collectors.toSet());
toList() (Java 16) is unmodifiable. Collectors.toList() is a mutable ArrayList (not specified as safe to mutate, but historically an ArrayList). For an API I own, I pick on purpose.
forEach(list::add) is a side-effect terminal. It breaks on parallel streams and is harder to read. collect is the reduction the API was built for.
Memory sentence:
Collect into a new collection. Do not
forEachinto a list I captured.
2. toMap and key collisions
Map<Long, Order> byId = orders.stream()
.collect(Collectors.toMap(Order::id, Function.identity()));
Duplicate ids throw IllegalStateException. When duplicates are possible, I pass a merge function:
Collectors.toMap(
Order::customerId,
Function.identity(),
(first, second) -> first.createdAt().isAfter(second.createdAt()) ? first : second
);
(a, b) -> b means last wins. I say that out loud; I do not leave the two-arg toMap if keys can collide.
Collectors.toMap also takes a map supplier: LinkedHashMap::new to keep encounter order.
3. groupingBy
Map<Status, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status));
Default downstream is toList(). Downstream collectors change the value type:
Map<Status, Long> counts = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));
Map<Status, Set<String>> skus = orders.stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.mapping(Order::sku, Collectors.toSet())
));
groupingBy is a HashMap. I pass TreeMap::new as the map supplier if I need sorted keys.
partitioningBy(predicate) is groupingBy into true/false.
4. reduce and count / min / max
Money total = lines.stream()
.map(Line::price)
.reduce(Money.zeroEur(), Money::plus);
Optional<Order> newest = orders.stream()
.max(Comparator.comparing(Order::createdAt));
Identity + accumulator must be associative if I ever go parallel. Money::plus is. list.add is not a good reducer (mutates).
Specialized terminals: count, min, max, sum on primitive streams. Prefer those over a handwritten reduce when they exist.
5. Primitive streams
Stream<Integer> boxes. IntStream / LongStream / DoubleStream do not.
long sum = orders.stream()
.mapToLong(Order::cents)
.sum();
mapToLong then sum / average / summaryStatistics is the backend-friendly shape for totals. I convert back with boxed() only when I need a Stream<Long>.
6. Spring connection
- Building a DTO list for a controller:
stream().map(Dto::from).toList(). - Deduping by id after a join that duplicated parents:
toMap(Order::id, Function.identity(), (a, b) -> a).values(). - Do not
groupingBya million rows in the app.GROUP BYin SQL, or a projection query. JdbcTemplatealready returns aList. Stream after the query for small in-memory shaping, not as a second database.
7. Common traps
Trap 1: Two-arg toMap on a stream that can have duplicate keys.
Trap 2: forEach(map::put) instead of toMap.
Trap 3: groupingBy on a huge findAll.
Trap 4: reduce that mutates a shared ArrayList identity — broken in parallel, ugly in sequential.
Trap 5: Stream<Integer> + map(i -> i + 1) in a tight numeric loop. Use IntStream.
Practice Questions and Answers
Question 1
What happens if toMap sees the same key twice?
Answer:
The two-argument Collectors.toMap throws IllegalStateException. I add a merge function: last wins, first wins, or a real combine. I do not wait for production to find the duplicate.
Question 2
groupingBy vs toMap?
Answer:
toMap is one value per key (collision is my problem). groupingBy is many values per key, as a list by default, or as whatever downstream collector I pass (counting, mapping, reducing).
Question 3
Why prefer collect over forEach + add?
Answer:
collect is a reduction: it creates the container, is parallel-safe with the right collector, and reads as “this pipeline’s result is a list.” forEach + add captures a mutable list, races in parallel, and hides the result type.
Question 4
When do I use reduce?
Answer:
When I combine elements into one value with an associative function (Money::plus). For collections I use collect. For min/max/count/sum I use the dedicated terminal ops.
Question 5
Why mapToLong before sum?
Answer:
Stream<Long> boxes every element. mapToLong gives an LongStream whose sum is a primitive total. Less allocation, clearer numeric intent.
Memory sentences
Collect into a new collection. Do not
forEachinto a captured list.
toMapneeds a merge function if keys can collide.
groupingByis many per key; push large grouping to SQL.