Java Streams and Lambdas Explanation


Perhaps you have already heard of methods like “Map” and “Filter”, which had been popping up in many other languages. Since Java version 8, they are also available in everyone’s favorite programming language! These methods were added to provide a less verbose and more functional alternative to the traditional for-loop. But how does this more functional approach work in a statically typed, object oriented language like Java? And why are all explanations of streams so complex, when the principles behind them are simple? In this tutorial, you will learn how useful streams can be without getting overwhelmed. I will also be covering lambdas – not in extreme detail, but to the point where you can use them with streams (which is their main use, anyway).

Lambdas – Functions as Arguments

A lambda is a function that we can pass to another function – essentially, a variable. Why would we want to do this? Because it lets us customize the behavior of a method, without having to override it repeatedly. Say we wanted to create a method that prints all numbers from a list, then prints the same array but all elements are multiplied by 2:

public class Main {
    public static void main(String[] args) {
        List list = List.of(2, 3, 4, 5, 6);
        for(Integer elem : list){
            System.out.println(elem);
        }
        for(Integer elem : list){
            System.out.println(elem*2);
        }
    }
}

Pretty wordy for such simple code. There is no good way for me to extract the repeated loop, unless I create an entire dedicated function that multiplies each element by a constant and then prints the result – a bit much for such a basic computation! Well, lambdas are here to save the day:

public static void main(String[] args) {
    List list = List.of(2, 3, 4, 5, 6);
    list.forEach(System.out::println);
    list.forEach(elem -> System.out.println(elem*2));
}

The forEach method, which is built into the List object, is a method that takes a “Consumer” object as a parameter. The Consumer is our Lambda – a simple method that takes a single, arbitrary object as a parameter. It then goes through each element and executes the lambda, passing the given element as the argument. The previous code snippet shows two ways to declare a lambda: referencing the method directly (System.out::println) or declaring an inline function. An inline function is declared by issuing a parameter name for the object passed to it (note: because the compiler can infer the type of the parameter, you don’t need to – and cannot – add the type in-front of the parameter declaration). Parameters are followed by the function body after the “->” arrow symbol. You might notice that I did not use any brackets or semicolons in the function body. This is optional – you can be much more verbose if you desire:

// this code does the exact same thing as in the example prior
list.forEach(elem -> {
    System.out.println(elem*2);
});

The Stream Object

In essence, the stream object allows for data transformations similar to those in interpreted languages, where typing can be inferred during run-time. Due to the static typing in Java, if a collection was processed by a function that changed the types of each element within it, I would need to assign that collection to a new variable. By putting that collection into a stream, we can skip that intermediate step, making for more concise code. Declaring a stream is as simple as:

List list = List.of(2, 3, 4, 5, 6);
Stream stream = list.stream();

As aforementioned, streams are great for working with collections in a concise manner. To do so, the stream object has multiple utility methods, that allow you to apply transformations to the underlying collection and its elements using lambdas. Here is a quick list of some of the most commonly used methods:

  • map – applies a method and replaces the input element with the return of that method. A lambda passed to a map can never return void. Commonly, this method is also used to cast or convert elements of one collection into elements of a different type
    Stream stream = list.stream().map(elem -> Integer.toString(elem)); // turns our integers into strings
  • flatMap – some operations could actually return a collection rather than a single element. In those situations, it can be advantages to add all elements into the stream as one large collection, instead of having a stream of collections. flatMap does this by adding the elements of several streams into the current stream.
    Stream stream = Stream.of("hello world", "I am dave")
                    .flatMap(elem -> Arrays.asList(elem.split("\\s")).stream());
    // stream now holds the elements "hello", "world", "I", "am", "dave"
  • filter – removes all elements from a stream that do not return true when passed to a given lambda.
    Stream stream = Stream.of(1, 2, 3, 4, 5)
                    .filter(elem -> elem > 2);
    // stream now holds the elements 3, 4 and 5

You will probably run into much more stream methods, however, these are all that you need to get started, since they allow you to do pretty much anything that you could do in a traditional for-loop!

Collectors

Until now, we saw how we can manipulate elements within a stream. However, usually we want our data in some other form – this is done by calling the collect method and passing it one of many collectors. Collectors are a group of utility methods, designed to turn our stream into a desired object. Many different types of collectors exist, giving you the flexibility to not only return a collection, but also common aggregations for improved performance and readability.

  • toList – simply turns your stream into a list
    List‹Integer› stream = Stream.of(1, 2, 3, 4, 5)
                    .filter(elem -> elem > 2).collect(Collectors.toList());
  • joining – used to combine strings into a single string with the character sequence passed as the argument. Ensure that all elements in the stream prior to calling this collector are strings.
    String stream = Stream.of("hello", "world").collect(Collectors.joining(" "));
            // returns "hello world"
  • groupingBy – returns a Map, commonly used to group elements of a specific type to a certain member variable
     class Employee{
            private int age;
    
            public Employee(int age){
                this.age = age;
            }
    
            public void setAge(int age) {
                this.age = age;
            }
    
            public int getAge() {
                return age;
            }
        }
    Map‹Integer, List‹Employee›› stream = Stream.of(new Employee(30), new Employee(21))
                    .collect(Collectors.groupingBy(Employee::getAge));
  • summingInt – creates a sum based on the return values of the lambda passed to it.
    Integer stream = Stream.of(2, 3, 4, 5, 6)
                    .collect(Collectors.summingInt(Integer::intValue)); // returns 14

Collectors are where the static typing of Java adds a lot of additional complexity that is omitted for interpreted languages. I suggest looking into collectors, as there are a lot more than I listed here, each with specific use cases.

Wrap Up

I hope you enjoyed this introduction to streams in Java – there is still a lot to learn. There are only few situations where I would prefer a for-loop over a stream-based solution, the most common case being where I want to loop over several collections at the same time. In this case, I would almost always opt for an index based loop, since there is no good way I know of that uses streams. Thanks for reading, subscribe to my newsletter to get updated on new Java tutorials!

Newsletter
Please enter a valid email!
Please agree to the terms!