Java Lambdas & Stream API

Lambda Expressions are the most remarkable feature added to the Java platform with Java 8. It's specified in JSR 335 and JEP 126.

The very need for this feature is to gain the capabilities supplied by functional programming.

The main idea behind this concept is to be able to parametrize the functions for subsequent executions of them.


Till Java 8, this can be simulated via the use of anonymous inner classes with some design patterns like command pattern and functors.

However the adoption of lambda expressions gave way to the direct use of this concept.


1. What is a Lambda Expression ?

A lambda expression is a piece of code that is giving an alternative way to the anonymous class to pass the function as parameter to other methods, constructors and etc.. 

In this approach, a function can be referenced with a variable and passed as reference to be executed in a subsequent flow of code execution.


2. Type of Lambda Expressions; Functional Interfaces

A lambda expression is identified by a special interface called Functional Interface that is an interface with a single method. This functional interface is the target type that's determined by the compiler and used as a reference to the lambda expression. 


This binding of a lambda expression to a functional interface is determined from the context of the lambda expression. That means the binding of a lambda expression to a target type can take place in different contexts such as variable decleration, method arguments and etc. and from this binding the compiler finds the target type which is a functional interface and infers the types of parameters used in the lambda expression according to that functional interface.


A functional interface can be marked with an informative annotation @FunctionalInterface that can be used to inform other developers.


Let's do a simple example to understand it well:

Think that we want to lowercase or uppercase a text based on a condition.

It will be a dynamic evaluation so we can abstract the operation.

With using the lambda expressions we can do it as follows:


Here is the lambda expressions for case operations:

  t -> t.toUpperCase();
  t -> t.toLowerCase();


To be able to pass these expression to somewhere, we have to declare a functional interface as such:

interface CaseOperation {
    public String operate(String text);
}


Then we can write our main code as such:

public void printWithCaseOperation(String text, CaseOperation operation){

    System.out.println(operation.operate(text));    
}

public void mainCode(){

    if(upperCaseEnabled){
        printWithCaseOperation("Hello Lambda!", t -> t.toUpperCase());
    } else {
        printWithCaseOperation("Hello Lambda!", t -> t.toLowerCase());
    }
}


3. Some Internals

Then here, let's watch this video to listen the internals of lambda expressions. For whom needs some speed, i'll summerize it, you're welcome:

  • We need lamdas simply since of; parallel friendly APIs and less code with the usage of closure -like functional programming capabilities.
  • Lambdas is not a new function type in the VM level it's mostly about compiler level.
  • Compiler does a great job for us by transforming our lambdas into the form of a related defined functional interface. In other words; compiler infers our lambdas as functional interfaces.
  • When the compiler sees the lambda expression, it simply creates a static method from the resolved related functional interface and in the invocation time, the VM executes that method by calling invokedynamic which is an invocation mode introduced in Java SE 7. 

4. More Lambda Syntax

Some inferences can be done automatically by the compiler for us. So when writing lambda expressions these compiler inferences directs us to write less code.

4.1 Full Syntax

(int a, int b) -> { return a + b; }

4.2 Single Statements in Body

Curly brackets and the return leyword can be omitted in case of single statements in body.
(int a, int b) -> a + b

4.3 Implicit target types

(a, b) -> a + b

4.4 Parantheses are optional in case of a single implicit target type

a -> a.size()

4.5 When using explicit target types, then parantheses are required

(String str) -> str.length()

4.6 Lambda expressions without parameters

() -> "abc"

4.7 The body can have multiple statements

(a, b) -> {
    int result = a + b;
    return result;
}


5. Built-in Functional Interfaces

Now that, we already know; ultimately, a functional interface is a method reference and also defines the target type of a lambda expression for the sake of compiler.

So we can structure our api around functional interfaces and use lambdas for more effective and clean code. However, as you see, a functional interface is just defines the target types of a lambda expression. Hence, the same functional interfaces could be used in most cases. For that aim, in Java 8; common built-in functional interfaces have already been created.

So instead of declaring our custom functional interfaces, we can use the built-in ones that will mostly meet our needs.

5.1 Functions

The Function interface is used in case of the need for; 
    one input, one output
/**
 * @param <T> the type of the input to the function
 * @param <R> the type of the result of the function
 */
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

So if you need a functional interface that gets one input and returns an output then you should use the Function interface instead of creating a custom one of yours.

Let's look over the following code:
Function<Person, String> f = t -> t.getGivenName();
String name = f.apply(Person.createShortList().get(0));
System.out.println(name);

In the code above, our lambda gets an instance t of type Person as an input and returns the name as String. When we execute the lambda via the Function interface then we get the result.

5.2 Suppliers

The Supplier interface is used in case of the need for;
    no input, one output
/**
 * @param <T> the type of results supplied by this supplier
 */
@FunctionalInterface
public interface Supplier<T> {
    T get();
}

So if you need a functional interface that gets no input and returns an output then you should use the Supplier interface instead of creating a custom one of yours.

Let's look over the following code:
public void supplierTest(){
    display(() -> 4);
}
	
public void display(Supplier<Integer> arg){
    System.out.println(arg.get());
}

5.3 Consumers

The Consumer interface is used in case of the need for;
    one input, no output
/**
 * @param <T> the type of the input to the operation
 */
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

So if you need a functional interface that gets one input and returns no output then you should use the Consumer interface instead of creating a custom one of yours.

Let's look over the following code:
public void consumerTest(){
    printLog("this log is via Consumer interface", (c) -> System.out.println(c));
}

public void printLog(String log, Consumer<String> logger){
    logger.accept(log);
}

5.4 Predicates

The Predicate interface is used in case of the need for;
    one input, one boolean output
/**
 * @param <T> the type of the input to the predicate
 */
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

So if you need a functional interface that gets one input and returns a boolean output then you should use the Predicate interface instead of creating a custom one of yours.

Let's look over the following code:
public static final Predicate<Person> YOUNG = p -> p.getAge() >= 18 && p.getAge() <= 25;
	
public void predicateTest() {
		
    Person person = Person.createShortList().get(0);
    System.out.println(YOUNG.test(person));
}

5.4.1 BiPredicate

The Predicate interface is used in case of the need for;
    two inputs, one boolean output
public boolean equals(String a, String b, BiPredicate<String, String> equalsPredicate){
    return equalsPredicate.test(a, b);
}
	
public void testBiPredicate(){
    boolean equals = equals("erol", "hira", (a, b) -> a.equals(b));
    System.out.println(equals);
}

5.4.2 Primitives version of Predicate

IntPredicate, LongPredicate and DoublePredicate are primitive versions of Predicate interface.
public static final IntPredicate IS_YOUNG = age -> age >= 18 && age <= 25;
	
public void isYoung() {
    System.out.println(IS_YOUNG.test(14));
}

5.5 UnaryOperator

The UnaryOperator interface is used in case of the need for;
    one input, one output and both are the same type
/**
 * @param <T> the type of the operand and result of the operator
 */
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {

    static <T> UnaryOperator<T> identity() {
        return t -> t;
    }
}
Let's look over the following code:
public void testUnaryOperator(){		
    UnaryOperator<String> upperCase = t -> t.toUpperCase();
    System.out.println(upperCase.apply("test"));
}

5.6 BinaryOperator

The BinaryOperator interface is used in case of the need for;
    two inputs, one output and all are the same type

Let's look over the following code:
public static final BinaryOperator<Integer> SUM = (a, b) -> a + b; 
	
public void sum() {		
    System.out.println(SUM.apply(10, 20));
}


6. Stream API

Streams are a new concept comes with Java 8 and enables easy processing of collections with the usage of lambda expressions. For the concept, there exists an API in the package java.util.stream.

6.1 Stream Terminology and Basics

  • A stream is a sequence of elements.
  • Various methods can be chained on elements of a stream in a single statement. That's called method chaining and is just like a builder pattern style.
  • Chain of operations using streams is called a stream pipeline that consists of:
    • a source which is a collection, file or stream.
    • zero or more intermediate operations such as filter, map, peek..
    • one terminal operation such as forEach, count, collect..
  • Intermediate operations are lazy that means the operation on the source is performed only when the terminal operation is executed and only on the elements that are necessary.

6.2 Stream Operations

Operations on a stream are either intermediate or terminal.

  • Intermediate
    An intermediate operation on a stream returns a new stream and so; this enables us to chain multiple intermediate operations in a single statement.
  • Terminal
    A terminal operation is the actual operation that acts on the elements remaining after all the intermediate operations.

6.3 Creating and Executing A Pipeline

A pipeline is formed of a collection, several intermediate operations and a terminal operation as mentioned above. The pipeline creation schema that's describing this flow is given below. 

And a code sample is simply just like:
Double sum = salaries.parallelStream()
		.filter(p -> p.getSalary() > 0)
		.mapToDouble(p -> p.getSalary())
		.sum();

Let's deep dive into the sample code above; 

  • A stream is created from a collection named salaries by calling the method parallelStream().
  • The resulting stream here just references the collection with a special iterator called Spliterator.
  • The methods stream() and parallelStream() are the two utilities added to Collection interface to create a stream from a collection instance.
  • On a stream we can call several intermediate operations, each of which creates a new stream reference, which helps us filtering or transforming the data on which we work. In the example above; at first, the filter intermediate operation is called.
  • Calling an intermediate operation on a stream does nothing on the data, it's just about the creation of pipeline. 
  • By calling the method filter, a new Stream instance is created having the intermediate operation filter to be called later on subsequent to the terminal operation.
  • After that, mapToDouble intermediate operation is called on the stream. This creates another stream having the mapToDouble operation to be called after the first intermediate operation which is filter. 
  • Just note here that; parallel stream does not affect the calling sequence of the streams and so also the intermediate operations. Concurrency on a stream is about splitting the data into groups and processing each group concurrently in seperate threads but in the order of intermediate operations just as specified in the pipeline creation.
  • After calling the terminal operation which is sum here, each element in the stream is forwarded to the intermediate operations in the pipeline and ultimately the terminal operation is executed on the resulting data set.


Code Samples

You can find all the code samples with the Java lambdas in my github page:

References




Yorumlar

Popular

Dependency Injection In Java and CDI

Servlet