1. Introduction
The Streams API, introduced in Java 8, revolutionized the way developers process data in Java. It allows for declarative, concise, and efficient handling of data streams, making it easier to perform complex operations on collections.
In this tutorial, we’ll explore the thinking process behind converting for loops to streams, highlighting key concepts and providing practical examples. We’ll start with a simple iteration, move on to filtering with conditions, and finally look at short-circuiting operations that mimic breaking out of a loop.
2. The Basics of Java Streams
A Stream in Java is a sequence of elements supporting functional operations, processed lazily from a source like a collection, array, or file. Unlike collections, Streams don’t store data but facilitate data processing.
Stream operations are either intermediate or terminal. Intermediate operations like filter(), map(), and sorted() return a new Stream and are lazily evaluated. Terminal operations like forEach(), collect(), and count() produce a result or side effect, triggering execution.
A key intermediate operation, flatMap(), transforms each element into a Stream and flattens nested structures, making it useful for handling nested collections.
Let’s start by converting a basic nested loop that generates all possible pairs of elements from two lists. For an imperative approach, we’ll iterate over list1, then over list2, and collect every possible pair:
public static List<int[]> getAllPairsImperative(List<Integer> list1, List<Integer> list2) {
List<int[]> pairs = new ArrayList<>();
for (Integer num1 : list1) {
for (Integer num2 : list2) {
pairs.add(new int[] { num1, num2 });
}
}
return pairs;
}
The Stream-based approach is more concise:
public static List<int[]> getAllPairsStream(List<Integer> list1, List<Integer> list2) {
return list1.stream()
.flatMap(num1 -> list2.stream().map(num2 -> new int[] { num1, num2 }))
.collect(Collectors.toList());
}
We first create a stream from list1 by calling list1.stream(). For each element in list1, we create a stream from list2, forming a pair [num1, num2]. Finally, using forEach(), we collect each generated pair. Both of these implementations give us the same output:
List<Integer> list1 = Arrays.asList(1, 2, 3);
List<Integer> list2 = Arrays.asList(4, 5, 6);
List<int[]> imperativeResult = NestedLoopsToStreamsConverter.getAllPairsImperative(list1, list2);
List<int[]> streamResult = NestedLoopsToStreamsConverter.getAllPairsStream(list1, list2);
assertEquals(imperativeResult.size(), streamResult.size());
for (int i = 0; i < imperativeResult.size(); i++) {
assertArrayEquals(imperativeResult.get(i), streamResult.get(i));
}
Now, let’s modify our approach and filter the pairs based on a condition. Instead of saving all pairs, we’ll only save pairs where the sum is greater than 7. For the classic approach, we’ll need an extra if-statement inside the inner loop:
public static List<int[]> getFilteredPairsImperative(List<Integer> list1, List<Integer> list2) {
List<int[]> pairs = new ArrayList<>();
for (Integer num1 : list1) {
for (Integer num2 : list2) {
if (num1 + num2 > 7) {
pairs.add(new int[]{num1, num2});
}
}
}
return pairs;
}
Let’s implement the Stream equivalent:
public static List<int[]> getFilteredPairsStream(List<Integer> list1, List<Integer> list2) {
return list1.stream()
.flatMap(num1 -> list2.stream().map(num2 -> new int[]{num1, num2}))
.filter(pair -> pair[0] + pair[1] > 7)
.collect(Collectors.toList());
}
Here, we follow the same initial steps as in the previous example. However, before printing the results, we apply filter() to the stream of pairs, to retain only those pairs whose sum is greater than 7. Then, we collect each filtered pair. In this method, we effectively separate iteration and filtering into distinct operations, improving code readability and maintainability.
We’ll use the same two lists as in the previous use case to test if our two approaches give an equivalent output:
List<int[]> imperativeResult = NestedLoopsToStreamsConverter.getFilteredPairsImperative(list1, list2);
List<int[]> streamResult = NestedLoopsToStreamsConverter.getFilteredPairsStream(list1, list2);
assertEquals(imperativeResult.size(), streamResult.size());
for (int i = 0; i < imperativeResult.size(); i++) {
assertArrayEquals(imperativeResult.get(i), streamResult.get(i));
}
5. Introducing Short-Circuiting
In some cases, we need to stop processing once we find the first valid pair. Traditionally, we’d use break inside the loop:
public static Optional<int[]> getFirstMatchingPairImperative(List<Integer> list1, List<Integer> list2) {
for (Integer num1 : list1) {
for (Integer num2 : list2) {
if (num1 + num2 > 7) {
return Optional.of(new int[] { num1, num2 });
}
}
}
return Optional.empty();
}
The Stream-based approach would look like:
public static Optional<int[]> getFirstMatchingPairStream(List<Integer> list1, List<Integer> list2) {
return list1.stream()
.flatMap(num1 -> list2.stream().map(num2 -> new int[] { num1, num2 }))
.filter(pair -> pair[0] + pair[1] > 7)
.findFirst();
}
In this version, we build on the previous example but introduce short-circuiting behavior using findFirst(). Here, we use findFirst() to retrieve only the first matching pair, stopping execution once a match is found.
When using findFirst(), the result is wrapped in an Optional by default, and if a matching pair exists, it is returned. With this approach, we eliminate the need for a manual break statement, offering a more functional and readable way to handle early termination.
We expect the same result to be returned from each of these approaches:
Optional<int[]> imperativeResult = NestedLoopsToStreamsConverter.getFirstMatchingPairImperative(list1, list2);
Optional<int[]> streamResult = NestedLoopsToStreamsConverter.getFirstMatchingPairStream(list1, list2);
assertEquals(imperativeResult.isPresent(), streamResult.isPresent());
imperativeResult.ifPresent(pair -> assertArrayEquals(pair, streamResult.get()));
6. Conclusion
We use Streams to replace nested loops when we need a more declarative, readable, and efficient way to process data. Streams simplify complex transformations, improve maintainability, and enable parallel execution when needed.
However, we avoid them for simple loops where readability might suffer, performance-critical code where stream overhead is significant, or cases where debugging is challenging due to lazy evaluation.
By converting nested loops to Streams, we can write cleaner and more expressive code, but we must consider context and balance clarity with performance to choose the best approach.
The code backing this article is available on GitHub. Once you're
logged in as a Baeldung Pro Member, start learning and coding on the project.