public static void main(String[] args) {
Stream.of("Row, row, row your boat,",
"gently down the Java stream!",
"Merrily, merrily, merrily, merrily,",
"functional programming is but a dream!")
.forEach(System.out::println);
}
Java was my main programming language throughout high school and college, but by the time I graduated from college in 2013, the concept of Java streams did not yet exist. (We were still on Java 7.) I could write all sorts of awesome programs using for
and while
loops. If those constructs could help me get my projects done, and if I could receive my computer science degree without relying on streams, then what kinds of value do Java streams provide?
To aid us in answering this question, we ask another one: given a collection of objects, can we come up with a functional-based, more readable approach to processing those objects?
Not much later, on March 18, 2014, Java 8 was released, and with it came Java streams. What Java 8 was not packaged with was a sudden change in the way that we developers think about and approach solutions to problems. For me, it is easy to think in terms of imperative statements. Over time, however, I have found increasingly greater value in including functional programming in my Java development.
Example of imperative programming vs using streams
Let’s consider a simple example where we have a list of names provided to us, and we want to convert all of them to all-caps. We also want to exclude any names that came in as blank (empty or all whitespace). An imperative-style implementation might look like this:
public static List<String> toUpperCase(List<String> names) {
List<String> uppercaseNames = new ArrayList<>();
for (String name : names) {
if (!name.isBlank()) {
uppercaseNames.add(name.toUpperCase());
}
}
return uppercaseNames;
}
The above code block represents how I was programmed to think when it comes to converting all the strings to uppercase and omitting blank strings. But what about a developer inspecting this code for the first time? The inspector’s thought process as he or she reads it might be as follows:
It looks like we’re creating a new list here called uppercaseNames
; I imagine this is going to contain all the names but in uppercase. Then next we have a for
loop. This is looping through all the names. For each name we check if it is not blank, and if so, we add the uppercase version of the name to uppercaseNames
. Finally, it returns the list of uppercase names once it’s populated.
Upon reading the above thought process, I notice some red flags. First, the inspector imagines that uppercaseNames
is going to contain all the names in uppercase, but the inspector does not know that for sure until he or she inspects the for
loop. Also, it takes a while before the inspector sees how the uppercase names are being populated. Moreover, the thought process is quite verbose and needs to be simplified.
Instead, the thought process should more closely match how I would verbally explain the code to someone in a way that is easy to understand. The easier it is to explain the code to others, the less prone we are to bugs.
Let’s simplify the above thought process. The following is what the inspector might think if the code were more readable:
It looks like we’re streaming the list of names, filtering out the blank ones and mapping the rest to uppercase, before returning all the uppercase names as a list.
And this is the code that the inspector would be reading:
public static List<String> toUpperCase(List<String> names) {
return names.stream()
.filter(x -> !x.isBlank())
.map(String::toUpperCase)
.toList();
}
The code above uses Java streams. Not only is it easier to understand and review this code; the code is also less bug-prone for these reasons:
- No nesting is involved.
- The underlying collector populates the list for us; we do not have to do it ourselves.
- Java streams don’t let us modify the source (
names
), whereas we do not have that protection when usingfor
orwhile
constructs.
How do Java streams work?
It is important to know that a stream pipeline has the following components, in order:
- The source
- Zero or more intermediate operations
- The terminal operation
The elements that enter a stream must come from somewhere. That somewhere is known as the source. Common examples of sources are collections (list, set, etc.), arrays, and hardcoded sequences of values; another example can be the lines of a file. The first step of creating a stream pipeline is to create a Stream
object directly tied to the source.
The next step is to process the elements through the pipeline using intermediate operations, which return modified streams in which each element coming out of the previous stream gets processed. Some examples of intermediate operations from the Stream
class are:
map
, which applies a mapping function to each elementfilter
, which drops elements from the pipeline if they do not satisfy the given criteriadistinct
, which drops duplicate elements from the pipelinelimit
, which returns a stream that is truncated to include no more than the given number of elements.sorted
, which returns a stream where the elements are sorted
The last part is the terminal operation, which traverses the stream to produce a result or side effect. (The stream is traversed in full or in part, depending on the operation.) In the above example, the terminal operation toList
creates a list from the elements in the stream after they were processed. Some other examples of terminal operations are:
toArray
, which dumps all the stream elements into an arrayfindFirst
, which returns anOptional
for the first element of the stream (if the stream isn’t empty)sum
, which returns the sum of the integer elements in the stream (applies toIntStream
)collect
, which feeds all the stream elements into someCollector
, which in turn produces a resultforEach
, which performs some action on each stream element