In this post, we’ll take a look at filtering and manipulating objects in a Collection using Java 8 lambdas, streams, and aggregates. All code in this post is available in BitBucket here.
For this example we’ll create a number of objects that represent servers in our IT infrastructure. We’ll add these objects to a List and then we’ll use lambdas, streams, and aggregates to retrieve servers from the List based on certain criteria.
- Introduce the concepts of lambdas, streams, and aggregate operations.
- Explain the relationship between streams and pipelines.
- Compare and contrast aggregate operations and iterators.
- Demonstrate the filter, collect, forEach, mapToLong, average, and getAsDouble aggregate operations.
Lambdas are a new Java language feature that allows us to pass functionality or behavior into methods as parameters. One example that illustrates the usefulness of Lambdas comes from UI coding. When a user clicks on button on a user interface, it usually causes some action to occur in the application. In this case, we really want to pass a behavior into the onClick(…) method so that the application will execute the given behavior when the button is clicked. In previous versions of Java, we accomplished this by passing an anonymous inner class (that implemented a known interface) into the method. Interfaces used in this kind of scenario usually contain only one method which defines the behavior we wish to pass into the onClick(…) method. Although this works, the syntax is unwieldy. Anonymous inner classes still work for this purpose, but the new Lambda syntax is much cleaner.
When we use Collections to store objects in our programs, we generally need to do more than simply put the objects in the collection — we need to store, retrieve, remove, and update these objects. Aggregate operations use lambdas to perform actions on the objects in a Collection. For example, you can use aggregate operations to:
- Print the names of all the servers in inventory from a particular manufacturer
- Return all of the servers in inventory older than a particular age
- Calculate and return the average age of Servers in your inventory (provided the Server object has a purchase date field)
All of these tasks can be accomplished by using aggregate operations along with pipelines and streams. We will see examples of these operations below.
Pipelines and Streams
A pipeline is simply a sequence of aggregate operations. A stream is a sequence of items, not a data structure, that carries items from the source through the pipeline. Pipelines are composed of the following:
- A data source. Most commonly, this is a Collection, but it could be an array, the return from a method call, or some sort of I/O channel.
- Zero or more intermediate operations. For example, a Filter operation. Intermediate operations produce a new stream. A filter operation takes in a stream and then produces another stream that contains only the items matching the criteria of the filter.
- A terminal operation. Terminal operations return a non-stream result. This result could be a primitive type (for example, an integer), a Collection, or no result at all (for example, the operation might just print the name of each item in the stream).
Some aggregate operations (i.e. forEach) look like iterators, but they have fundamental differences:
- Aggregate operations use internal iteration. Your application has no control over how or when the elements are processed (there is no next() method).
- Aggregate operations process items from a stream, not directly from a Collection.
- Aggregate operations support Lambda expressions as parameters.
Now that we have discussed the concepts related to Lambda expressions, it is time to look at their syntax. You can think of Lambda expressions as anonymous methods because they have no name. Lambda syntax consists of the following:
- A comma-separated list of formal parameters enclosed in parentheses. Data types of parameters can be omitted in Lambda expressions. The parentheses can be omitted if there is only one formal parameter.
- The arrow token: ->
- A body consisting of a single expression or code block.
Using Lambdas, Streams, and Aggregate Operations
As mentioned in the overview, we’ll demonstrate the use of lambdas, streams, and aggregates by filtering and retrieving Server objects from a List. We’ll look at four examples:
- Finding and printing the names of all the servers from a particular manufacturer.
- Finding and printing the names of all of the servers older than a certain number of years.
- Finding and extracting into a new List all of the servers older than a certain number of years and then printing the names of the servers in the new list.
- Calculating and displaying the average age of the servers in the List.
Let’s get started…
The Server Class
First, we’ll look at our Server class. The Server class will keep track of the following:
- Server name
- Server IP address
- Amount of RAM (GB)
- Number of processors
- Purchase date (LocalDate)
Notice (at line 65) that we’ve added the method
getServerAge() that calculates the age of the server (in years) based on the purchase date – we’ll use this method when we calculate the average age of the Servers in our inventory.
Creating and Loading the Servers
Now that we have a Server class, we’ll create a List and load several servers:
Example 1: Print the Names of All the Dell Servers
For our first example, we’ll write some code to find all of the servers made by Dell and then print the server names to the console:
Our first step is on line 76 – we have to get the stream from our list of servers. Once we have the stream, we add the filter intermediate operation on line 77. The filter operation takes a stream of servers as input and then produces another stream of servers containing only the servers that match the criteria specified in the filter’s lambda. We select only the servers that are made by Dell using the following lambda:
s -> s.getManufacturer().equalsIgnoreCase(manufacturer)
The variable s represents each server that is processed from the stream (remember that we don’t have to declare the type). The right hand side of the arrow operator represents the statement we want to evaluate for each server processed. In this case, we’ll return true if the current server’s manufacturer is Dell and false otherwise. The resulting output stream from the filter contains only those servers made by Dell.
Finally, we add the forEach terminal operation on line 78. The forEach operation takes a stream of servers as input and then runs the given lambda on each server in the stream. We print the names of the Dell servers to the console using the following lambda:
server -> System.out.println(server.getName())
Note that we used s as the variable name for each server in the stream in the first lambda and server as the variable name in the second – they don’t have to match from one lambda to the next.
The output of the above code is what we expect:
Example 2: Print the Names of All the Servers Older Than 3 Years
Our second example is similar to the first except that we want to find the servers that are older than 3 years:
The only difference between this example and the first is that we changed the lambda expression in our filter operation (line 89) to this:
s -> s.getServerAge() > age
The output stream from this filter contains only servers that are older than 3 years.
The output of the above code is:
Example 3: Extract All Servers Older Than 3 Years Into a New List
Our third example is similar to the second in that we are looking for the servers that are older than three years. The difference in this example is that we will create a new List containing only the servers that meet our criteria:
As in the previous example, we get the stream from the List and add the filter intermediate operation to create a stream containing only those servers older than 3 years (lines 102 and 103). Now, on line 104, we use the collect terminal operation rather than the forEach terminal operation. The collect terminal operation takes a stream of servers as input and then puts them in the data structure specified in the parameter. In our case, we convert the stream into a list of servers. The resulting list is referenced by the oldServers variable declared on Line 100.
Finally, to demonstrate that we get the same set of servers in this example as the last, we print the names of all the servers in the oldServers list. Note that, because we want all of the servers in the list, there is no intermediate filter operation. We simply get the stream from oldServers and feed it to the forEach terminal operation.
The output is what we expect:
Example 4: Calculate and Print the Average Age of the Servers
In our final example, we’ll calculate the average age of our servers:
The first step is the same as our previous examples – we get the stream from our list of servers. Next we add the mapToLong intermediate operation. This aggregate operation takes a stream of servers as input and produces a stream of Longs as output. The servers are mapped to Longs according to the specified lambda on Line 119 (you can also use the equivalent syntax on Line 120). In this case, we are grabbing the age of each incoming server and putting it into the resulting stream of Longs.
Next we add the average terminal operation. Average does exactly what you would expect – it calculates the average of all of the values in the Stream. Terminal operations like average that return one value by combining or operating on the contents of a stream are known as reduction operations. Other examples of reduction operations include sum, min, max, and count.
Finally, we add the operation getAsDouble. This is required because average returns the type OptionalDouble. If the incoming stream is empty, average returns an empty instance of OptionalDouble. If this happens, calling getAsDouble will throw a NoSuchElementException, otherwise it just returns the Double value in the OptionalDouble instance.
The output of this example is:
We’ve only scratched the surface as to what you can do with lambdas, streams, and aggregates. I encourage you to grab the source code, play with it, and start to explore all the possibilities of these new Java 8 features.