Use Java Streams to Write Clean Java Code

765 VIEWS

· ·

Traditionally, Java code is verbose, and syntactically demanding. Java requires several lines of code to achieve what other languages do with a few lines of code. The nature of Java as object oriented often leads to over-specification in implementation, increasing complexity of code, and overhead in testing. The Java 8 API has introduced the Stream Interface to tackle this syntactic setback that is long overdue. Java Streams provides an abstraction on Collections and Arrays so that developers can side-step loops and long-winding operations, and simply ask questions on Collections. E.g findFirst(), anyMatch(), sorted().

We will go over how to utilize this powerful new addition to achieve concision of code, without compromising expressiveness.

Imperative vs Declarative Programming 

Java Streams introduces declarative style programming, as opposed to the imperative nature of programming in Java prior to streams. Imperative programming involves explicitly specifying all the necessary steps needed to achieve a result. Whereas Declarative style simply leaves out the details of implementation and asks for the results we want. In short, imperative concerns itself with the how of things, whereas Declarative focuses on the what of things. A simple illustration to achieve the count of elements in a list, using both imperative and declarative approach is shown below.

Declarative approach utilizes abstraction which when combined with good variable naming convention leads to cleaner code, better readability, and much simpler code. Furthermore, since code you do not write means code you do not test, declarative approach reduces overhead in testing. 

Java Streams has also made functional programming in Java less tedious prior to Java 8. Functional programming is a programming paradigm where the output of the function depends only on its input. A huge advantage of functional programming is its ability to achieve immutability of state and pure functions, which can often reduce bugs and side effects that makes testability of code difficult.

List<Integer> list = Arrays.asList(new Integer[]{1, 2, 3, 4, 5});

//Imperative style
Integer count = 0;
for(int ele : list ){
    count++;
};
System.out.println("total  count is : " + count);

// Declarative style using streams
Integer sum = list.stream().count()

Using Java Streams

Streams are essentially wrappers to Collections and Lists, which declaratively describes their source and the computational operations that can be performed in aggregate on their source. Stream operations are composable, and consist of a source (can be a collection, list etc), some intermediate operations (which converts a stream into stream), and a terminal operation which consumes the stream to produce a result. The entire stream pipeline is evaluated only when a terminal operation is evoked on the pipeline. 

Creating a Stream

In order to set up a stream pipeline, you must first create a stream. There are a few ways to create a stream depending on the type of the source:

(i) Create streams from Java Collections with the stream() method of the collection:

List<Integer> list = Arrays.asList(new Integer[]{1, 2, 3, 4, 5});
Stream<Integer> stream  =  list.stream()

(ii) Also, create streams from an array like so:

Import Java.util.stream.*
...
Int[] arr= new Integer[]{1, 2, 3, 4, 5};
Stream<Integer> stream  =  Stream.of(arr);

(iii) For primitive types java offers IntStream, DoubleStream, and LongStream:

Import Java.util.stream.*
...
IntStream stream = DoubleStream.of(8, 4, 3);
DoubleStream stream = DoubleStream.of(8.2, 4.5, 2.3);
LongStream stream = LongStream.of(8L);

(iv) Generally, you can create streams from individual values as well:

Import Java.util.stream.*
...
Stream<String> = Stream.of(“Hello”, “Hi”, “Hey”);

Applying Intermediate Operations

After creating a stream, apply one or more intermediate functions chained together to transform the stream into a new stream. These intermediate operations are lazy. Therefore, they are not evaluated until a terminal operation is applied. Examples of intermediate operations include: distinct, sorted, limit, peek, map, filter, skip,  etc. 

(i)  

Import Java.util.stream.*
...
Int[] arr= new Integer[]{1, 2, 3, 4, 5};
Stream<Integer> stream  =  Stream.of(arr);
Stream<Integer> intermediateResult = stream
               .filter(ele -> ele > 2)
               .sorted()

(ii) The filter is an intermediate operation that allows us to filter elements of a stream given a Predicate, which is expressed as a lambda function. Lambda functions enable us to express single methods in Java more compactly. The Functional Programming paradigm utilizes this concept. Most Java Streams operations expect a Predicate expressed in the form of lambda function. Refer to Java Docs for comprehensive notes on lambda functions.

Applying Terminal Operations

Terminal operations consume the stream pipeline, and turn the pipeline into a  result or side-effect: essentially into something else other than a Stream. Computation on the source is only performed once a terminal operation is invoked on the pipeline. Examples include count, forEach, sorted, toArray, reduce, collect, min, max, etc.

(i) To consume a stream pipeline as an ArrayList:

Import Java.util.stream.*
...
Int[] arr= new Integer[]{1, 2, 3, 4, 5};
Stream<Integer> stream  =  Stream.of(arr);
ArrayList arrList = stream
            .filter(ele -> ele > 2)
            .sorted()
            .collect(Collectors..toCollection(ArrayList::new))

Ease of Traversing Collections and Data Searching With Stream

Streams operations makes it easy to work with Collections in java, to achieve data traversal and searching. We will illustrate this with two examples:

(i) Looping through a list to print elements:

(a) Without streams:

List<String> greetings = Arrays.asList(“Hello”, “Hi”, “Hey”);
for(int i = 0; i < words.size(); i++) {
      System.out.println(greetings.get(i));
}

 (b) Using streams with anonymous methods:

greetings.stream().forEach(new Consumer<String>(){
public void accept(String t) {
        System.out.println(t);
    }
})

(c) Using streams with lambda functions:

List<String> greetings = Arrays.asList(“Hello”, “Hi”, “Hey”);
greetings.stream().forEach(System.out::println)

 

(ii) Checking if list contains a string of a certain size with streams:

List<String> greetings = Arrays.asList(“Hello”, “Hi”, “Hey”);
System.out.println(
  greetings.stream().anyMatch(ele -> ele.length == 5)
) // true

Conclusion

Java Streams provides a powerful yet intuitive way to express your code. This tutorial demonstrated a few simple ways to utilize streams.


Samuel is a software developer at an investment banking firm. He works on developing Rest services with Java, and frontend with the popular React framework.


Discussion

Click on a tab to select how you'd like to leave your comment

Leave a Comment

Your email address will not be published. Required fields are marked *