Java 8 Streams: An Intro to Filter, Map and Reduce Operations

    Share

    Learn more on Java with our tutorial Bridging Android and Java in Android Development on SitePoint.

    After shying away from them for years, Java finally embraced functional programming constructs in the spring of 2014. Java 8 includes support for lambda expressions, and offers a powerful Streams API which allows you to work with sequences of elements, such as lists and arrays, in a whole new way.

    In this tutorial, I’m going to show you how to create streams and then transform them using three widely used higher-order methods named map, filter and reduce.

    The code for this post can be found here.

    Creating a Stream

    As you can tell from its name, a stream is just a sequence of items. Although there are lots of approaches to stream creation, for now, we’ll be focusing only on generating streams from lists and arrays.

    In Java 8, every class which implements the java.util.Collection interface has a stream method which allows you to convert its instances into Stream objects. Therefore, it’s trivially easy to convert any list into a stream. Here’s an example which converts an ArrayList of Integer objects into a Stream:

    // Create an ArrayList
    List<Integer> myList = new ArrayList<Integer>();
    myList.add(1);
    myList.add(5);
    myList.add(8);
    
    // Convert it into a Stream
    Stream<Integer> myStream = myList.stream();
    

    If you prefer arrays over lists, you can use the stream method available in the Arrays class to convert any array into a stream. Here’s another example:

    // Create an array
    Integer[] myArray = {1, 5, 8};
    
    // Convert it into a Stream
    Stream<Integer> myStream = Arrays.stream(myArray);
    
    stream

    The map Method

    Once you have a Stream object, you can use a variety of methods to transform it into another Stream object. The first such method we’re going to look at is the map method. It takes a lambda expression as its only argument, and uses it to change every individual element in the stream. Its return value is a new Stream object containing the changed elements.

    To give you a realistic example, let me show you how you can use map to convert all elements in an array of strings to uppercase.

    You start by converting the array into a Stream:

    String[] myArray = new String[]{"bob", "alice", "paul", "ellie"};
    Stream<String> myStream = Arrays.stream(myArray);
    

    Then you call the map method, passing a lambda expression, one which can convert a string to uppercase, as its sole argument:

    Stream<String> myNewStream = 
                 myStream.map(s -> s.toUpperCase());
    

    The Stream object returned contains the changed strings. To convert it into an array, you use its toArray method:

    String[] myNewArray =
                 myNewStream.toArray(String[]::new);
    

    At this point, you have an array of strings, all of which are in uppercase.

    I hope you are now beginning to realize that with this style of programming, you can do away with loops, and the code you write can be very concise and readable.

    The filter Method

    In the previous section, you saw that the map method processes every single element in a Stream object. You might not always want that. Sometimes, you might want to work with only a subset of the elements. To do so, you can use the filter method.

    Just like the map method, the filter method expects a lambda expression as its argument. However, the lambda expression passed to it must always return a boolean value, which determines whether or not the processed element should belong to the resulting Stream object.

    For example, if you have an array of strings, and you want to create a subset of it which contains only those strings whose length is more than four characters, you would have to write the following code:

    Arrays.stream(myArray)
          .filter(s -> s.length() > 4)
          .toArray(String[]::new);
    

    The code above looks a lot more concise than the code we wrote in the previous example because I’ve chained all the Stream methods. Most developers prefer writing functional code in this manner because, usually, there’s no need to store references to intermediate Stream objects.

    Reduction Operations

    A reduction operation is one which allows you to compute a result using all the elements present in a stream. Reduction operations are also called terminal operations because they are always present at the end of a chain of Stream methods. We’ve already been using a reduction method in our previous examples: the toArray method. It’s a terminal operation because it converts a Stream object into an array.

    Java 8 includes several reduction methods, such as sum, average and count, which allow to perform arithmetic operations on Stream objects and get numbers as results. For example, if you want to find the sum of an array of integers, you can use the following code:

    int myArray[] = { 1, 5, 8 };
    int sum = Arrays.stream(myArray).sum();
    

    If you want to perform more complex reduction operations, however, you must use the reduce method. Unlike the map and filter methods, the reduce method expects two arguments: an identity element, and a lambda expression. You can think of the identity element as an element which does not alter the result of the reduction operation. For example, if you are trying to find the product of all the elements in a stream of numbers, the identity element would be the number 1.

    The lambda expression you pass to the reduce method must be capable of handling two inputs: a partial result of the reduction operation, and the current element of the stream. If you are wondering what a partial result is, it’s the result obtained after processing all the elements of the stream that came before the current element.

    The following is a sample code snippet which uses the reduce method to concatenate all the elements in an array of String objects:

    String[] myArray = { "this", "is", "a", "sentence" };
    String result = Arrays.stream(myArray)
                    .reduce("", (a,b) -> a + b);
    

    Conclusion

    You now know enough to start using the map, filter and reduce methods in your projects. For the sake of the brevity, throughout this tutorial, I’ve used only serial streams. If you have computationally intensive map operations, or if you expect your streams to be very large, you should consider using parallel streams instead. Fortunately, its very easy to convert any stream into its parallel equivalent. All you need to do is call its parallel method.

    I’d also like to let you know that if you prefer not to use lambda expressions while working with the map, filter, and reduce methods, you can always use method references instead.

    To learn more about the Streams API and the other methods it has to offer, you can refer to its documentation.

    Learn more on Java with our tutorial Bridging Android and Java in Android Development on SitePoint.

    Frequently Asked Questions (FAQs) about Java 8 Streams, Filter, Map, and Reduce

    What is the significance of Java 8 Streams in programming?

    Java 8 Streams are a significant feature introduced in Java 8. They provide a new abstraction of dealing with sequences of data in a declarative way. The main advantage of Java 8 Streams is that they allow for significant optimizations through lazy evaluations and parallel execution by dividing the data into multiple chunks. This makes it easier to work with large data sets, especially in multi-threaded environments. Streams also support functional-style operations on streams of elements, such as map-reduce transformations on collections.

    How does the filter operation work in Java 8 Streams?

    The filter operation in Java 8 Streams is used to filter out elements from a stream that don’t match a given predicate. A predicate is a functional interface that represents a boolean-valued function of one argument. The filter operation is an intermediate operation, meaning that it can be chained with other stream operations and doesn’t perform any processing until a terminal operation is invoked on the stream pipeline.

    Can you explain the map operation in Java 8 Streams?

    The map operation in Java 8 Streams is a transformation operation. It takes a Function as an argument, which is applied to each element in the stream and transforms it into a new element. The Function is a functional interface that represents a function that takes one argument and produces a result. The map operation is useful for converting or transforming a Stream of one type into a Stream of another type.

    How does the reduce operation work in Java 8 Streams?

    The reduce operation in Java 8 Streams is a terminal operation that takes a BinaryOperator as an argument and returns an Optional describing the reduced value, if any. This operation processes all the elements in the stream to produce a single result. The BinaryOperator is a functional interface that represents an operation upon two operands of the same type, producing a result of the same type as the operands.

    What is the difference between a sequential and a parallel stream?

    A sequential stream is a stream where the elements are processed one after the other, while a parallel stream is a stream where the elements can be processed in parallel, meaning multiple elements can be processed at the same time. Parallel streams utilize the Fork/Join Framework under the hood to use multiple threads for processing, which can significantly increase the performance of the operation on large datasets.

    How can I convert a Stream into a Collection?

    You can convert a Stream into a Collection using the collect method. The collect method is a terminal operation that transforms the elements in the stream into a different kind of result, such as a Collection. You can use the Collectors utility class to provide a Collector that accumulates the input elements into a new Collection.

    Can I use Streams to perform operations on data from a database?

    Yes, you can use Streams to perform operations on data from a database. You can use the Stream API to process the data in a declarative way, which can lead to more readable and maintainable code. However, keep in mind that the Stream API is not a replacement for SQL and that the performance characteristics can be different.

    What is the difference between an intermediate and a terminal operation?

    Intermediate operations are operations that transform a Stream into another Stream, such as filter and map. They are always lazy, meaning that they don’t perform any processing until a terminal operation is invoked on the stream pipeline. Terminal operations, on the other hand, are operations that produce a result or a side-effect, such as reduce or collect.

    Can I reuse a Stream?

    No, you cannot reuse a Stream. Once a terminal operation is invoked on a Stream, it is consumed and cannot be used again. If you need to traverse the same data source again, you will have to create a new Stream.

    How can I debug a Stream?

    You can debug a Stream using the peek method. The peek method is an intermediate operation that takes a Consumer and returns a new Stream consisting of the elements of the original Stream, additionally performing the provided action on each element as elements are consumed from the resulting Stream.