avatarEmanuel Trandafir

Summary

The article provides a comprehensive guide to consuming streams in Java, detailing 13 different terminal operations, and emphasizes the importance of understanding their use cases and implications for functional programming.

Abstract

The article "All 13 Ways to Consume a Stream in Java: One of Them Is Evil" delves into the various terminal operations that can be performed on Java streams, which are crucial for effectively utilizing streams. It categorizes these operations based on their return types, such as boolean expressions (anyMatch, allMatch, noneMatch), optional elements (findFirst, findAny), collections (toList, toArray, custom collectors), maps (toMap, groupingBy), single elements (reduce), and side-effect inducing operations (forEach). The author stresses the importance of understanding the lazy evaluation of intermediate operations and the impact of terminal operations on stream processing. The article also discusses the potential pitfalls of using forEach as a terminal operation, suggesting that it can introduce side effects and compromise functional programming principles.

Opinions

  • The author suggests that forEach is the "evil" among the terminal operations due to its tendency to introduce side effects, which can lead to impure functions and make code harder to test.
  • The article promotes the use of streams for processing data rather than storing it, advising the collection of stream elements into appropriate data structures when necessary.
  • The author encourages the use of streams in a way that aligns with functional programming principles, such as maintaining lazy evaluation and purity of functions.
  • The article implies that a solid understanding of terminal operations is essential for developers to work with streams efficiently and effectively.
  • The author provides practical examples and highlights the flexibility of collectors, including custom collectors, for managing stream elements.
  • The article subtly critiques the use of forEach as a terminal operation, suggesting it as a deviation from the functional programming paradigm that streams are designed to support.

All 13 Ways to Consume a Stream in Java: One of Them Is Evil

Let’s discuss do’s and don’ts when it comes to consuming streams in Java.

Photo by Anvesh Uppunuthula on Unsplash

Overview

As we already know, on a Java stream we can perform intermediary and terminal operations. Intermediate steps alter or transform the stream, such as filter, limit, and sort, with the return type also being a stream. It’s important to note that intermediate operations are lazily evaluated, meaning they won’t be executed without terminal operations.

On the other hand, terminal operations consume the stream and put an end to the flow. The result of these operations is something else entirely. In this article, we’ll explore all possible ways of consuming a stream, or in other words, all possible terminal operations.

It’s essential to have a solid understanding of these terminal operations, as they’re crucial in effectively utilizing Java streams. With this knowledge, you’ll be able to work with streams efficiently and effectively, making the most out of this powerful tool. So, let’s dive into the different ways of consuming a Java stream and explore their benefits and use cases.

1–3) Evaluate a Boolean Expression

Oftentimes, we need to iterate through a stream just to evaluate a boolean expression. If this is the case, the result of consuming the stream will be a boolean value.

In Java, we have three methods for evaluating an expression like this: anyMatch(), allMatch(), and noneMatch().

boolean atLeastOneMinor = students().anyMatch(s -> s.age() < 18);
boolean noMinors = students().noneMatch(s -> s.age() < 18);
boolean onlyMinors = students().allMatch(s -> s.age() < 18);

4–5) Find An (Optional) Element

Perhaps some of the most commonly used stream methods are the ones for finding a particular element. For this purpose, we might use the intermediate operation filter, followed by findFirst() or findAny().

Note that both of these methods are going to wrap the element into the Optional<> monad, and, if the stream is empty, an empty Optional will be returned:

Optional<Student> firstStudent = students().findFirst();
Optional<Student> anyStudent = students().findAny();

6–9) Collect The Elements To Collection

Streams are all about processing the data, and they aren't a good option when it comes to storing data. For this case, if we want to keep the data as a local variable or a field, we need to collect it first.

Two quick ways of collecting the stream are directly calling the toList() or toArray() methods:

List<Student> listOfStudents = students().toList();
Student[] arrayOfStudents = students().toArray(Student[]::new);

Though, if we need more flexibility when it comes to the types of Collection to collect into, we can use the collect() method and provide a collector. We can use out-of-the-box collectors defined as static methods of the java.util.stream.Collectors such as toList(), toUnmodifiableList(), toSet() ... etc.

Set<Student> studentsSet = students().collect(Collectors.toUnmodifiableSet());

Additionally, we can also use the collect method by passing a custom Collector or providing a set of lambda expressions that will be used to collect all the data:

LinkedHashSet<Student> studentsLinkedHashSet = students().collect(
      LinkedHashSet::new,
      LinkedHashSet::add,
      LinkedHashSet::addAll
);

10–11) Collect The Stream To a Map

Sometimes we need to group the items by a specific property. For this purpose, there are two extremely useful Collectors: we can either use Collectors.toMap() or Collectors.groupingBy().

Firstly, let’s consider we want to collect the students stream into Map where we use the lastName as the key.

import static java.util.stream.Collectors.toMap;

Map<String, Student> studentsByLastName = students().collect(toMap(
      Student::lastName,
      s -> s
));

// instead of s -> s, we can also use Function.identity()

Map<String, Student> studentsByLastName = students().collect(toMap(
      Student::lastName,
      Function.identity()
));

As a side note here: we are assuming we won’t have duplicates. However, if we know the stream might have multiple students with the same lastName and we want to either merge the elements or decide which element to keep, we’ll need to define a mergeFunction as the 3rd argument. Let’s use a merge function that will keep the oldest student, in case of duplicates:

Map<String, Student> olderSudentsByLastName = students().collect(toMap(
      Student::lastName,
      Function.identity(),
      (s1, s2) -> s1.age() > s2.age() ? s1 : s2
));

On the other hand, if we know we have duplicates, but we don’t want to merge or discard any of the elements and rather collect the values into a collection, we should use the groupBy collector:

import static java.util.stream.Collectors.toMap;

Map<String, List<Student>> allStudentsByLastName = students()
     .collect(groupingBy(Student::lastName));

Furthermore, we can decide which type of collection to store the values. To customize it, we have to pass yet another collector, as the second argument of groupinBy:

Map<String, Set<Student>> allStudentsByLastName = students()
    .collect(groupingBy(
        Student::firstName, 
        Collectors.toUnmodifiableSet()
));

12) Reduce The Stream to a Single (Optional) Element

Finally, we can reduce the whole stream down to a single element using the reduce method. For example, we can find the oldest student from the stream by comparing the age() property of the students:

Optional<Student> oldestStudent = students.stream()
      .reduce((s1, s2) -> s1.age() > s2.age() ? s1 : s2);

Similarly, the reduction will return an Optional, since there is always the possibility that the stream was empty, to begin with.

Oftentimes, we’ll use the intermediate operation map before reducing the stream. This will allow us, for instance, to reduce the stream to a single integer which represents the age of the youngest student, or reduce it to a String with all the names of the students separated commas:

Optional<Integer> youngestStudentAge = students.stream()
    .map(Student::age)
    .reduce((age1, age2) -> age1 < age2 ? age1 : age2);

Optional<String> allStudentNames = students.stream()
    .map(Student::firstName)
    .reduce((name1, name2) -> name1 + "," + name2);

// very similar to:
String allStudentNames2 = students.stream()
    .map(Student::firstName)
    .collect(Collectors.joining(","));

13) Providing a Consumer

I know, I know, there’s an elephant in the room: Let’s finally talk about forEach. This method allows us to pass a lambda expression that will consume each element of the stream and do something with it.

For example, we can use it to print something to the console, send an HTTP request, or call a function from a different object.

students().forEach(s -> sendDeleteRequest(s.id()));
students().forEach(s -> System.out.println(s));

I’d like to challenge you to find the differences between the terminal methods we’ve discussed so far and forEach. It’s something that all the other methods and techniques have in common, and forEach doesn’t.

To keep the answer a surprise, I've added an adorable cat image to give you some time to ponder before revealing the answer.

Photo by Bogdan Farca on Unsplash

The answer is that forEach is the only one forcing you to introduce side effects. Since it’s not returning anything, it means it needs to do something, to change the state of the application somehow.

Adding forEach as the final operation of your functional pipeline will make it harder to test. In short, whenever we add forEach as our final step, our function will no longer be a pure function.

In other words, if we want to go in this direction of functional programming, we should stick to lazy evaluation, pure functions, and avoid this terminal method. In fact, I have an article about this, if you want to dive deeper into the topic, I encourage you to read through it:

Conclusion

In this article, we’ve discussed all the various ways of consuming a stream in Java. We’ve divided the terminal operations by the type they return and we’ve seen that the main return types are:

  • Boolean (anyMatch, allMatch, noneMatch)
  • Optional<T> (findFirst, findAny, reduce)
  • Collection<T> (toList, toArray, Collectors.to___, custom collector)
  • Map<Key, T> or Map<Key, List<T>> (Collectors.toMap, Collectors.groupingBy)
  • Void (forEach 😈)
Photo by Fotis Fotopoulos on Unsplash

Thank You!

Thanks for reading the article and please let me know what you think! Any feedback is welcome.

If you want to read more about clean code, design, unit testing, object-oriented programming, functional programming, and many others, make sure to check out my other articles. Do you like the content? Consider following or subscribing to the email list.

Finally, if you consider becoming a Medium member and supporting my blog, here’s my referral.

Happy Coding!

Java
Functional Programming
Spring Boot
Programming
Software Architecture
Recommended from ReadMedium
avatarEngineering Digest
Multithreading in Java

CPU

17 min read