Practice: rewriting loops as streams
Four lessons of mechanism. This one is judgement: which loops become better as streams, which do not, and how to tell before you have spent twenty minutes finding out.
Every conversion below was written both ways and run, and both versions produce identical output. That matters, because a refactor that changes behaviour is not a refactor, and the fastest way to be sure is to keep the old version until the new one agrees with it.
The conversions that are clear wins
Summing
long total = 0;
for (Sub s : subs) {
total += s.tiffins();
}
long total = subs.stream().mapToInt(Sub::tiffins).sum();
96 / 96
Three lines and a mutable accumulator become one line and none.
Filter, transform, collect
List<String> names = new ArrayList<>();
for (Sub s : subs) {
if (s.tiffins() >= 22) {
names.add(s.name());
}
}
List<String> names = subs.stream()
.filter(s -> s.tiffins() >= 22)
.map(Sub::name)
.toList();
[Priya, Kavita, Rahul] / [Priya, Kavita, Rahul]
The canonical case. If your loop is a filter and a map and a collect, convert
it.
First match with an early exit
String first = null;
for (Sub s : subs) {
if (s.area().equals("Kharadi")) {
first = s.name();
break;
}
}
String first = subs.stream()
.filter(s -> s.area().equals("Kharadi"))
.map(Sub::name)
.findFirst()
.orElse(null);
Arjun / Arjun
The break becomes findFirst, and short-circuiting means it really does stop
early. The stream version also makes the "what if nothing matches" case explicit
rather than leaving a null in a variable.
Grouping
Map<String, List<String>> groups = new TreeMap<>();
for (Sub s : subs) {
groups.computeIfAbsent(s.area(), k -> new ArrayList<>()).add(s.name());
}
Map<String, List<String>> groups = subs.stream().collect(
Collectors.groupingBy(Sub::area, TreeMap::new,
Collectors.mapping(Sub::name, Collectors.toList())));
{Bavdhan=[Rahul], Kharadi=[Arjun, Amit], Wagholi=[Priya, Kavita]}
equal: true
Honest assessment: the loop version is arguably easier to read for someone who
has not learnt collectors. The stream version wins once you have, and it wins
decisively when the downstream becomes counting() or summingInt(...) rather
than a list.
The loops to leave alone
Two accumulators at once
int count = 0;
long total = 0;
StringBuilder log = new StringBuilder();
for (Sub s : subs) {
if (s.tiffins() > 0) {
count++;
total += s.tiffins();
log.append(s.name()).append(' ');
}
}
count=4 total=96 log=[Priya Arjun Kavita Rahul]
As streams this is three separate pipelines over the same data, or one
Collectors.teeing that nobody enjoys reading. Leave it as a loop. One pass,
three results, perfectly clear.
The partial exception: if the three results are a count, a sum and an average,
summaryStatistics() gives you all of them in one pass and reads better:
IntSummaryStatistics{count=5, sum=96, min=0, average=19.200000, max=30}
Anything needing the index
List<String> ranked = new ArrayList<>();
for (int i = 0; i < sorted.size(); i++) {
ranked.add((i + 1) + ". " + sorted.get(i).name());
}
[1. Kavita, 2. Priya, 3. Rahul, 4. Arjun, 5. Amit]
The stream form exists:
List<String> ranked = IntStream.range(0, sorted.size())
.mapToObj(i -> (i + 1) + ". " + sorted.get(i).name())
.toList();
equal: true
It works, and it is not better. It streams indices in order to index back into a list, which is a loop wearing a costume. When the index is the point, write the loop.
Anything with a side effect
Writing to a file, sending a message, updating a database. A forEach that does
those things is a loop with worse stack traces — and the moment somebody changes
it to parallelStream() it becomes a bug. Use a loop and make the side effect
obvious.
Anything that needs to stop for a reason limit cannot express
break on an accumulated total passing a threshold, or on the third failure.
takeWhile covers some of these; a loop covers all of them.
The checklist
Before converting, ask:
| Question | If yes |
|---|---|
| Is it filter, map, collect? | Convert |
| Is it a sum, count, max or average? | Convert |
| Is it building a map keyed on something? | Convert |
| Does it need the index? | Leave it |
| Does it accumulate two or more unrelated things? | Leave it |
| Does it have a side effect? | Leave it |
| Does it break on a condition involving state? | Leave it |
| Would the lambda bodies exceed three lines? | Extract methods, then decide |
A stream that needs a comment to explain it has lost the argument. The whole point was readability.
Style, once you are converting
- One operation per line. A ten-operation chain on one line is unreadable regardless of how good streams are.
- Method references where they fit.
Sub::nameovers -> s.name(). - Extract long lambdas into named methods, then reference them. The name documents the step.
- Name the intermediate variable when a pipeline does two distinct things.
List<Sub> active = ...;then a second pipeline beats one chain of twelve. mapToInt/mapToLongfor numbers. Money in paise wantsmapToLong:mapToLong(s -> s.tiffins() * 8_235L)givesRs 7,905.60for the five subscribers, andmapToIntwould risk the overflow module 2 warned about.
Performance, honestly
For a list of a few thousand, streams and loops are indistinguishable and neither
is your bottleneck. For millions of primitives in a hot loop, an array and a
for loop win, because there is no boxing and no pipeline overhead.
Choose for readability and measure if it matters. The one performance
decision in this module that does matter is mapToInt rather than
map(Sub::tiffins) on a Stream<Integer>, and that costs nothing to get right.
Check your work
Which four loop shapes are clear wins as streams? Summing or averaging;
filter-map-collect; first match with break; building a keyed map.
Which four should stay loops? Anything using the index; anything accumulating two or more unrelated results; anything with a side effect; anything breaking on accumulated state.
What does IntStream.range(0, n).mapToObj(...) really do? Streams indices in
order to index back into a list — a loop in stream syntax. Write the loop.
What replaces three accumulators when they are count, sum and average?
summaryStatistics() on a primitive stream, in one pass.
Why mapToLong for money? The paise total overflows int quickly. The
primitive stream also avoids boxing.
How do you know a refactor was correct? Keep both versions and compare their output until they agree. Every conversion in this lesson was checked that way.
What is the sign that a stream has gone too far? It needs a comment to explain it, or its lambda bodies have grown past about three lines.
Practice 2, the conversions. Sum 96. Names with 22 or more tiffins
[Priya, Kavita, Rahul]. First Kharadi subscriber Arjun. Grouped by area into
a TreeMap, {Bavdhan=[Rahul], Kharadi=[Arjun, Amit], Wagholi=[Priya, Kavita]} — and loopGroups.equals(streamGroups) returns true, which is the
check worth writing rather than eyeballing.
Practice 5, the one that should not convert. The count/total/log loop becomes
either three pipelines over the same list or one Collectors.teeing call. Both
are worse. The right answer to "how do I write this as a stream" is sometimes
"you do not", and being able to say so with a reason is the skill this lesson is
actually testing.
Practice
-
Convert the four wins. Sum, filter-map-collect, first-match, grouping. Keep both versions and print both results side by side.
-
Assert they agree. For each pair, print
loopResult.equals(streamResult)rather than comparing by eye. One of them will surprise you, and it will be the grouping one if your loop used aHashMapand your stream used aTreeMap. -
Convert the ranked list both ways. The index loop and the
IntStream.rangeversion. Confirm they match, then say in one sentence which you would keep. -
Find a stream that should be a loop. Take the two-accumulator example, write it as streams, and time how long it takes you to be confident it is correct.
-
Refuse a conversion, with a reason. Write the count/total/log loop as streams in the best way you can, then write two sentences explaining why you would reject it in a code review.
-
Harder — convert a real method. Find a method of twenty or more lines in any Java project you have, with at least one loop in it. Convert what should be converted and leave what should not. Then write the list of what you left alone and why. That list is the actual output of this lesson — anybody can convert everything.
That is module six. You can write a lambda and a method reference, read the
functional interfaces, build a stream pipeline and know when it runs, use
Optional without turning it into a null check, produce a grouped report in one
line, and — the part that matters — decide when not to.
Next module: exceptions, where the orElseThrow and IllegalArgumentException
this module has been throwing get treated properly.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship