RizTech Academy logo
RizTech Academy
Streams and Functional JavaLesson 5 of 530 min

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::name over s -> 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 / mapToLong for numbers. Money in paise wants mapToLong: mapToLong(s -> s.tiffins() * 8_235L) gives Rs 7,905.60 for the five subscribers, and mapToInt would 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

  1. Convert the four wins. Sum, filter-map-collect, first-match, grouping. Keep both versions and print both results side by side.

  2. 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 a HashMap and your stream used a TreeMap.

  3. Convert the ranked list both ways. The index loop and the IntStream.range version. Confirm they match, then say in one sentence which you would keep.

  4. 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.

  5. 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.

  6. 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