By this topic, you have already learned different types of stream operations such as filtermap and reduce. Now is the time to start using these operations together and get into more details on the real stream pipelines. In a sense, the idea of this topic is to consolidate your knowledge about streams and to guide you through more complex practice exercises.

More about operations in stream pipelines

As a rule, production-ready streams contain multiple operations at once. It is possible to distinguish the following kinds of operations:

  • filtering: using filter or other methods to skip some of the elements like skiplimittakeWhile and so on;
  • mapping or modifying stream elements: for example, sorting or removing duplicates;
  • reducing or combining: reducemaxmincollectcount , findAny, and so on.

This is not a coincidence. These groups of operations compose a standard data pipeline in many information systems and streams are well suited to simulate them.

Let’s consider an example of a stream with several operations. Suppose, there is a list of strings named words. We would like to count the total number of words that start with "JA" . The case is not important: "ja""jA", and "Ja" are suitable as well.

Here is our solution with the mapfilter and count operations.

List<String> words = List.of("JAR", "Java", "Kotlin", "JDK", "jakarta");

long numberOfWords = words.stream()
        .map(String::toUpperCase)         // convert all words to upper case
        .filter(s -> s.startsWith("JA"))  // filter words using a prefix
        .count();                         // count the suitable words

System.out.println(numberOfWords); // 3

Here is a picture that explains how this stream works:

It is obvious, that the result is 3 because the list contains only three suitable words ("JAR""Java""jakarta").

The order of execution

But there is also one less obvious thing in the case of the previous example: the order of operations in this stream. It seems that the filter operation is only called after the map operation has converted all the elements to the upper case. But that is not always true. We can see it by ourselves by adding the peek operation to print the intermediate elements of the stream.

As Javadoc says, the peek method exists mainly to support debugging, where you want to see the elements as they flow past a certain point in a pipeline. Do not forget to remove it after debugging is completed.

After adding the peek operation before and after filter, the stream will look like this:

long numberOfWords = words.stream()
        .map(String::toUpperCase)
        .peek(System.out::println)
        .filter(s -> s.startsWith("JA"))
        .peek(System.out::println)
        .count();

And here is its output:

JAR
JAR
JAVA
JAVA
KOTLIN
JDK
JAKARTA
JAKARTA

This output actually means, that the filter operation is applied to an element right after the element was mapped.

Do not try to predict the order of operations in a complex stream. Depending on the operations, the actual execution order may slightly differ from the expected one because of the internal stream optimization. The main point is that a stream will produce the result regardless of the execution order.

Streams with custom classes

In real situations, streams often process custom classes designed specifically for the program.

Let’s assume that we have the Event class that represents a public event, such as a conference, a film premiere, or a concert. It has two fields:

  • beginning (LocalDate) is a date when the event happens;
  • name (String) that is the name of the event (for instance, "JavaOne – 2017").

Also, the class has getters and setters for each field with the corresponding names.

We also have a list of instances named events.

Let’s find all names of events that will occur from December 30 to December 31, 2017 (inclusively).

LocalDate after = LocalDate.of(2017, 12, 29);
LocalDate before = LocalDate.of(2018, 1, 1);
        
List<String> suitableEvents = events.stream()
        .filter(e -> e.getBeginning().isAfter(after) && e.getBeginning().isBefore(before))
        .map(Event::getName)
        .collect(Collectors.toList());

The code above finds names of all suitable events and collects them to a new list of strings. The map methods allow us to make the transition from Event objects to the String objects.

Mapping and reducing functions

Since functions are presented as objects of certain classes, we can map and reduce them similar to regular stream elements.

For example, we have a collection of integer predicates. Let’s negate each predicate by using a map operator and then conjunct all predicates into one by using a reduce operator.

public static IntPredicate negateEachAndConjunctAll(Collection<IntPredicate> predicates) {
    return predicates.stream()
            .map(IntPredicate::negate)
            .reduce(n -> true, IntPredicate::and);
}

In this example, map negates each predicate in a stream and then reduce conjuncts all predicates into one. The initial value (seed) of reducing is a predicate that is always true, because it’s the neutral value for conjunction.

So, the input predicates P1(x), P2(x), ..., Pn(x) will be reduced into one predicate Q(x) = not P1(x) and not P2(x) and ... and not Pn(x). Of course, this is not the most frequent way to apply streams, but it’s worth knowing that such use is also possible.

Conclusion

As you have seen, stream pipelines allow writing short and readable code to perform various evaluations. It is possible to combine many different operations in a single powerful stream. Keep in mind, that the order of performing the operation is determined by the stream itself. Do not try to influence it in any way.

Leave a Reply

Your email address will not be published.