1. Overview
In this lesson, we’ll understand the main differences between intermediate and terminal operations in streams. We’ll learn about the Stream’s lazy evaluation feature. Finally, we’ll see that streams are closed upon consumption and cannot be reused.
The relevant module we need to import when starting this lesson is: intermediate-and-terminal-operations-start.
If we want to reference the fully implemented lesson, we can import: intermediate-and-terminal-operations-end.
2. Intermediate Operations
Intermediate operations are methods that transform one Stream into another. For example, if we have a Stream of Task objects and map() each Task instance to its code, we’ll get a Stream of String objects containing just the task codes. Let’s write a new unit test in the JavaStreamsUnitTest class to demonstrate this behavior:
class JavaStreamsUnitTest {
private final Collection<Task> tasks = List.of(...);
@Test
void whenMappingStream_thenReturnsAStreamOfTaskCodes() {
Stream<Task> taskStream = tasks.stream();
Stream<String> taskCodeStream = taskStream.map(Task::getCode);
// ...
}
}
Intermediate operations don’t do any processing right away; instead, they build a pipeline of operations to be carried out later. This is known as lazy evaluation, which we’ll cover in more detail in a later module.
For now, just remember that nothing happens unless a terminal operation is present; intermediate operations alone won’t trigger any execution.
Some common intermediate operations include: filter(), map(), flatMap(), distinct(), sorted(), peek(), limit(), and skip(). In future lessons, we’ll take a closer look at each of these and see how they help us shape and refine data as it flows through a Stream.
3. Terminal Operations
Terminal operations are methods that produce a result or a side-effect from a Stream. Unlike intermediate operations, a terminal operation triggers the actual processing of the stream pipeline.
Some common terminal operations are: forEach(), reduce(), collect(), count(), anyMatch(), allMatch(), findFirst(), and findAny(). We’ll dive deeper into each of these operations in future lessons.
For this example, we’ll update our whenMappingStream_thenReturnsAStreamOfTaskCodes() test method, adding the toList() terminal operation. This will trigger the execution of the map() function on all stream elements and collect the results into a List<String>:
@Test
void whenMappingStream_thenReturnsAStreamOfTaskCodes() {
Stream<Task> taskStream = tasks.stream();
Stream<String> taskCodeStream = taskStream.map(Task::getCode);
List<String> codes = taskCodeStream.toList();
List<String> expectedCodes = List.of("T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8");
assertTrue(codes.containsAll(expectedCodes));
}
The fluent nature of the Stream API allows a more concise syntax, which is often preferred. We can inline the taskStream and taskCodeStream variables directly into the stream pipeline to make the code shorter and easier to read:
@Test
void whenMappingStream_thenReturnsAStreamOfTaskCodes() {
List<String> codes = tasks.stream()
.map(Task::getCode)
.toList();
List<String> expectedCodes = List.of("T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8");
assertTrue(codes.containsAll(expectedCodes));
}
4. Stream Reuse
Once a terminal operation is executed, the stream is considered consumed and can no longer be used. Any attempt to operate on it again will result in an exception.
We can see this if we try to use the forEach() terminal operation to print all elements of taskCodeStream after already using toList() on it. In this case, we’ll get an IllegalStateException with the message “stream has already been operated upon or closed”.
To demonstrate this, we can create a new test method and use Junit’s assertThrows(). This assertion method enables us to catch and verify the exceptions thrown by our code:
@Test
void whenTryingToUseAConsumedStream_thenExceptionIsThrown() {
Stream<String> taskCodeStream = tasks.stream()
.map(Task::getCode);
List<String> codes = taskCodeStream.toList();
assertThrows(
IllegalStateException.class,
() -> taskCodeStream.forEach(System.out::println)
);
}
Simply put, a stream can only be used once. If we need further processing, we need to create a new stream.
To fix this, after collecting the Stream into a List, we should stream again, effectively creating a new Stream. Let’s add a new unit test to explore this approach:
@Test
void whenCollectingAStreamAndStreamingAgain_thenNoExceptionIsThrown() {
Stream<String> taskCodeStream = tasks.stream()
.map(Task::getCode);
List<String> codes = taskCodeStream.toList();
assertDoesNotThrow(() ->
codes.stream() // <-- a new stream is created
.forEach(System.out::println));
}
5. Key Differences
In this quick lesson, we explored the two categories of operations supported by streams: intermediate and terminal. We learned that intermediate operations are lazily evaluated, meaning nothing happens immediately. In contrast, terminal operations are eagerly evaluated and trigger the execution of the entire stream pipeline.
Another key difference is that we can use zero, one, or multiple intermediate operations chained together in a stream pipeline. However, we need to use exactly one terminal operation, which closes the stream and prevents any further operations.