Java streams vs for loop

I had quite a bit of trouble finding a good article about java streams vs for loops under this name so I guess I’ll have to write it myself. In this article I would like to talk about the difference of using the Streaming API and for loops from the standpoint of long term maintainability of the code.

tl;dr: To reduce maintenance costs of your projects, please do consider using the Stream API instead of for loops. It might take some investment in learning to do so, but this investment will pay off in the long run, both for the project and for the engineers.

The usual arguments

When googling about this issue there are 3 arguments that come up quite a bit.

  • Performance

  • Readability

  • For loops work just fine / Streaming API is the fancy way of doing it.

And I would like to address these arguments and give my own perspective.

Performance

There are many opinions about which style performs better. The short version basically is, if you have a small list; for loops perform better, if you have a huge list; a parallel stream will perform better. And since parallel streams have quite a bit of overhead, it is not advised to use these unless you are sure it is worth the overhead. So although the difference is not that big, for loops win by pure performance. That being said; performance is not the only important metric to measure your code by. Everything in software engineering is a trade-off. A relevant trade-off in this context is the following: "Performant code usually is not very readable, and readable code usually is not very performant." As the cost of maintenance is much higher these days than the cost of hardware, the trade-off usually leans towards readable/maintainable code now. So unless millisecond performance is mission critical (and you optimised your entire stack and software for this), it is not a strong argument in most cases.

Readability

Everything seems hard when it is not familiar. When you first started coding, a for loop looks (and is!) very complicated. But after a few years of coding and having used many for loops, it became more familiar and easier to use. The same applies to the streaming API. It has now been a major language feature since its introduction in 2014. If by now you haven’t had the time or energy to try it out or give it some proper attention, I hope this article might convince you to give it a try. Spending time learning this language feature and this way of working will definitely be a good thing for your career.

For loops work just fine / Streaming API is the fancy way of doing it

It is true for loops didn’t stop working when the streaming API was introduced. But the streaming API has a lot to offer. It actually does not require a big mindset change to use the streaming API, since you are already doing it unconsciously when writing a for loop. All the streaming API does, is make it more explicit, which in turn makes the code more simple in the long run. To give you some examples in code:

Given a person

class Person {
    private String name;
    private int age;

    private String gender;
    private List<Person> siblings;

    public Person(String name, int age, String gender, List<Person> siblings) {
        this.name = name;
        this.age = age;
        this.gender = gender;
        this.siblings = siblings;
    }

    public String getGender() {
        return gender;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public List<Person> getSiblings() {
        return siblings;
    }
}

Lets have the following problem:

Given a list of Persons, give me the names of everyone who is older than 18.

In the traditional way this would be implemented in the following way:

private void forLoop() {
    List<Person> persons = List.of();
    List<String> result = new ArrayList<>();
    for(Person p : persons){
        if(p.getAge() > 18){
            result.add(p.getName());
        }
    }
}

So, lets take a look at what we just did. We loop over the list of persons we do a check if the person meets the condition that it is over 18, and we add the name of that person to a new list. So when reading the problem, we identified the following 2 operations:

  • check if the person is over 18

  • get the name of the person

These are both standard problems which have standard names

  • check if the person is over 18 → filter

  • get the name of the person → map (transform)

So they could also be written as:

  • filter out the persons with an age of 18 or below (and keep everyone over 18)

  • map the person to its name

So when writing the for loop we identified the 2 sub problems, and have given them their proper name. Once we have done that, we start seeing that the streaming API starts getting a bit more readable.

private void streaming() {
    List<Person> persons = List.of();
    List<String> result = persons.stream()
            .filter(p -> p.getAge() > 18)
            .map(p -> p.getName())
            .collect(Collectors.toList());

Here we can see that we do the same thing as we did in the for loop. All we did was make it more explicit. And by making it more explicit, we make it

  • Simple

  • Scalable

  • Maintanable

While this is a trivial example, this becomes more clear when we expand the problem

Given a list of Persons, give me the names of everyone who is between 19 and 65 whose name is not null and starts with a 'B'

Let’s see what this does to our for loop.

// With combined if
private void forLoop(){
    List<Person> persons = List.of();
    List<String> result = new ArrayList<>();

    for(Person p : persons){
        if(p.getAge() > 18 && p.getAge() <= 65 && p.getName() != null && p.getName().startsWith("B")){
            result.add(p.getName());
        }
    }
}
// Same solution, but slightly more readable
private void forLoop2() {
    List<Person> persons = List.of();
    List<String> result = new ArrayList<>();

    for (Person p : persons) {
        if (p.getAge() > 18 && p.getAge() <= 65) {
            if (p.getName() != null && p.getName().startsWith("B")) {
                result.add(p.getName());
            }
        }
    }
}

As you can see we only added a few operations:

  • check if the person is under 65 → filter

  • check if the person has a name → filter

  • check if the person’s name starts with a "B" → filter

And the for loops got quite a bit more complex. Lets see what the stream would look like

private void streaming() {
    List<Person> persons = List.of();
    List<String> result = persons.stream()
            .filter(p -> p.getAge() > 18)
            .filter(p -> p.getAge() <= 65)
            .filter(p -> p.getName() != null)
            .filter(p -> p.getName().startsWith("B"))
            .map(p -> p.getName())
            .collect(Collectors.toList());
}

As you can see, the stream’s complexity scales much more linear than the for loop. Also, now we have learned the standard name of the problem (filter), it is still easy to read.

Let’s make it more complex one last time

Given a list of Persons, give me the names of all male siblings who are between 19 and 65 whose name is not null and starts with a 'B'

// With combined if
private void forLoop() {
    List<Person> persons = List.of();
    List<String> result = new ArrayList<>();

    for (Person p : persons) {
        for (Person sibling : p.getSiblings()) {
            if (sibling.getGender().equals("M") && sibling.getAge() > 18 && sibling.getAge() <= 65 && sibling.getName() != null && sibling.getName().startsWith("B")) {
                result.add(sibling.getName());
            }
        }
    }
}
// Same solution, but slightly more readable
private void forLoop2() {
    List<Person> persons = List.of();
    List<String> result = new ArrayList<>();

    for (Person p : persons) {
        for(Person sibling : p.getSiblings()){
            if(sibling.getGender().equals("M")) {
                if (sibling.getAge() > 18 && sibling.getAge() <= 65) {
                    if (sibling.getName() != null && sibling.getName().startsWith("B")) {
                        result.add(sibling.getName());
                    }
                }
            }
        }
    }
}

Here we added the following operations with their respective names

  • Retrieve the siblings → flatMap

  • Check if the sibling is male → filter

This is what the stream would look like

private void streaming() {
    List<Person> persons = List.of();
    List<String> result = persons.stream()
            .flatMap(p -> p.getSiblings().stream())
            .filter(p -> p.getGender().equals("M"))
            .filter(p -> p.getAge() > 18)
            .filter(p -> p.getAge() <= 65)
            .filter(p -> p.getName() != null)
            .filter(p -> p.getName().startsWith("B"))
            .map(p -> p.getName())
            .collect(Collectors.toList());
    }

Now imagine reading this code after a few weeks to find out what it is doing. Which of the 2 versions would be easier to understand? And hopefully by now it has become clear that it is not a 'fancy' way of doing things, it is a more structured way of doing the same thing as a for loop.

Conclusion

Once you learned the names of a few basic operations:

  • filter → decides if a value is correct

  • map → Transforms 1 value to another

  • flatMap → Transforms 1 value to a list of values, and then flattens it
    e.g. [ [sibling1, sibling2], [sibling3] ] becomes [sibling,1, sibling2, sibling3]

It becomes more natural to express these operations by their standard problem name. This makes the code more readable and scalable and therefore more maintainable. Also, by thinking more in the terms of these standard problems, you are also practising some more abstract thinking, which is quite useful during problem solving.

So no, for loops are not dead yet, they still serve their purpose. But there are a lot of good arguments to be made to give the streaming API a try.

shadow-left