An easy to understand and read Lamda expression, with detailed analysis of the source code

Posted by Solarpitch on Tue, 23 Nov 2021 04:41:49 +0100

Lamda expression is very convenient. It is generally used in stream programming in projects.

List<Student> studentList = gen();
Map<String, Student> map = studentList .stream()
        .collect(Collectors.toMap(Student::getId, a -> a, (a, b) -> a));

There are three steps to understand a Lamda expression:

  1. Confirm the type of Lamda expression
  2. Find the method to implement
  3. Implement this method

Confirm the type of Lamda expression

The type that can be represented by Lamda expression must be a functional interface, which is an interface with only one abstract method.

Let's take a look at the familiar Runnable interface in JDK.

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

This is a standard functional interface. Because there is only one abstract method. And there is an annotation @ functional interface on this interface

This is only to help you check whether your interface meets the conditions of functional interface during compilation. For example, if you do not have any abstract methods or have multiple abstract methods, compilation cannot pass.

// There is no interface that implements any abstract methods
@FunctionalInterface
public interface MyRunnable {}

// After compilation, the console displays the following information
Error:(3, 1) java: 
  Unexpected @FunctionalInterface notes
  MyRunnable Not a function interface
    At interface MyRunnable Abstract method not found in

A little more complicated, default methods and static methods are allowed in the interface after Java 8, and these are not abstract methods, so they can also be added to the functional interface.
Look at an interface that you may not be familiar with and a little deja vu.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    default Consumer<T> andThen(Consumer<? super T> after) {...}
}

Look, there is only one abstract method and one default method (the code of the method body is omitted), which does not affect that it is a functional interface. Look at a more complex, more static method, which is also a functional interface, because it still has only one abstract method. Feel it for yourself.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    
    default Predicate<T> and(Predicate<? super T> other) {...}
    default Predicate<T> negate() {...}
    default Predicate<T> or(Predicate<? super T> other) {...}
    
    static <T> Predicate<T> isEqual(Object targetRef) {...}
    static <T> Predicate<T> not(Predicate<? super T> target) {...}
}

Regardless of what these methods are for, these classes are everywhere in the methods designed by Stream. Let's first remember that the type required by Lamda expression is a functional interface. Only one abstract method in the functional interface is enough. The above three examples belong to functional interfaces.

Find the method to implement

Lamda expression is to implement a method. What method? Just those abstract methods in functional interfaces.

That's too simple, because functional interfaces have and only have one abstract method. Just find it. We try to find the abstract methods of those functional interfaces.

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    default Consumer<T> andThen(Consumer<? super T> after) {...}
}

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    default Predicate<T> and(Predicate<? super T> other) {...}
    default Predicate<T> negate() {...}
    default Predicate<T> or(Predicate<? super T> other) {...}
    static <T> Predicate<T> isEqual(Object targetRef) {...}
    static <T> Predicate<T> not(Predicate<? super T> target) {...}
}

Implement this method

Lamda expression is to implement this abstract method. If you don't use lamda expression, you must know how to implement it with anonymous classes? For example, we just implemented the anonymous class of the predict interface.

Predicate<String> predicate = new Predicate<String>() {
    @Override
    public boolean test(String s) {
        return s.length() != 0;
    }
};

What if you replace it with Lamda expression? Like this.

Predicate<String> predicate = 
    (String s) -> {
        return s.length() != 0;
    };

This Lamda syntax consists of three parts:

  • Parameter block: the previous (String s) simply writes the parameters of the abstract method to be implemented intact.
  • Small arrow: that's the symbol.
  • Code block: the method to be implemented is written here intact.

First, look at the fast part of the parameter. The type information in (String s) is redundant, because it can be deduced by the compiler and removed.

Predicate<String> predicate = 
    (s) -> {
        return s.length() != 0;
    };

Parentheses can also be removed when there is only one parameter.

Predicate<String> predicate = 
    s -> {
        return s.length() != 0;
    };

Look at the code block. There is only one line of code in the method body. You can remove the curly braces and the return keyword.

Predicate<String> p = s -> s.length() != 0;

Let's implement a Runnable interface.

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable r = () -> System.out.println("I am running");

You see, this method has no arguments, so there are no parameters in the front parentheses. In this case, the parentheses cannot be omitted.

Usually, when we quickly create a new thread and start it, is it as follows? Are you familiar with it?

new Thread(() -> System.out.println("I am running")).start();

Multiple input parameters

Before, we only tried one input parameter. Next, let's look at the of multiple input parameters.

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);
    // default methods removed
}

Then see if a usage is clear at a glance.

BiConsumer<Random, Integer> randomNumberPrinter = 
        (random, number) -> {
            for (int i = 0; i < number; i++) {
                System.out.println("next random = " + random.nextInt());
            }
        };
        
randomNumberPrinter.accept(new Random(314L), 5));

Just a few input parameters, let's add a return value.

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
    // default methods removed
}

// Look at an example
BiFunction<String, String, Integer> findWordInSentence = 
    (word, sentence) -> sentence.indexOf(word);

In fact, the abstract method in a functional interface is nothing more than the number of input parameters and the type of return value. The number of input parameters can be one or two, and the return value can be void, boolean, or a type. The arrangement and combination of these cases are the classes under the java.util.function package provided by JDK.

```bash
BiConsumer
BiFunction
BinaryOperator
BiPredicate
BooleanSupplier
Consumer
DoubleBinaryOperator
DoubleConsumer
DoubleFunction
DoublePredicate
DoubleSupplier
DoubleToIntFunction
DoubleToLongFunction
DoubleUnaryOperator
Function
IntBinaryOperator
IntConsumer
IntFunction
IntPredicate
IntSupplier
IntToDoubleFunction
IntToLongFunction
IntUnaryOperator
LongBinaryOperator
LongConsumer
LongFunction
LongPredicate
LongSupplier
LongToDoubleFunction
LongToIntFunction
LongUnaryOperator
ObjDoubleConsumer
ObjIntConsumer
ObjLongConsumer
Predicate
Supplier
ToDoubleBiFunction
ToDoubleFunction
ToIntBiFunction
ToIntFunction
ToLongBiFunction
ToLongFunction
UnaryOperator

Don't look dizzy. Let's just classify. It can be noted that many class prefixes are Int, Long and Double, which actually specifies the specific type of input parameter, rather than a generic type that can be customized by users, such as DoubleFunction.

@FunctionalInterface
public interface DoubleFunction<R> {
    R apply(double value);
}

This can be fully realized by the more free functional interface Function.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

Then we might as well remove these specific types of functional interfaces (I also secretly removed several classes of XXXOperator because they all inherit other functional interfaces), and then sort to see what's left.

Consumer
Function
Predicate
 
BiConsumer
BiFunction
BiPredicate
 
Supplier

Almost none. Let's focus on these next. Here I just list the classes and corresponding abstract methods

Consumer: void accept(T t)
Function: R apply(T t)
Predicate:   boolean test(T t)
 
BiConsumer: void accept(T t, U u)
BiFunction: R apply(T t, U u)
BiPredicate: boolean test(T t, U u)
 
Supplier: T get()

See the pattern? The above simple categories are:

supplier: No input parameter, return value.
consumer: There are input parameters and no return value.
predicate: With input parameters, return boolean value
function: There are input parameters and return values

Then, those with Bi prefix have two input parameters, and those without Bi prefix have only one such parameter. OK, these have been clearly divided by us. In fact, it provides us with a function template. The difference is only the arrangement and combination of the number of input and return parameters.

Get familiar with our common Stream programming

If you use stream programming in your project, you must be familiar with the following code. There is a student list. You want to convert it into a map. key is the id of the student object and value is the student object itself.

List<Student> studentList = gen();
Map<String, Student> map = studentList .stream()
        .collect(Collectors.toMap(Student::getId, a -> a, (a, b) -> a));

Extract the part of Lamda expression.

Collectors.toMap(Student::getId, a -> a, (a, b) -> a)

Since we haven't seen this form yet, call it back as it is. Here's just for you to warm up.

Collectors.toMap(a -> a.getId(), a -> a, (a, b) -> a)

Why is it written like this? Let's look at the definition of Collectors.toMap.

public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(
        Function<? super T, ? extends K> keyMapper,
        Function<? super T, ? extends U> valueMapper,
        BinaryOperator<U> mergeFunction) 
{
    return toMap(keyMapper, valueMapper, mergeFunction, HashMap::new);
}

There are three input parameters: Function, Function and BinaryOperator. BinaryOperator only inherits BiFunction and extends several methods. We don't use it, so we might as well treat it as BiFunction.

Remember Function and BiFunction?

Function  R apply(T t)
BiFunction R apply(T t, U u)

The first parameter a - > a.getId() is the implementation of R apply(T t). The input parameter is Student type object a, which returns a.getId()

The second parameter a - > A is also the implementation of R apply(T t). The input parameter is a of Student type and returns a itself

The third parameter (a, b) - > A is the implementation of R apply (T, t, u, U), the input parameters are a and B of Student type, and the return is the first input parameter a, which is used in the Stream. When the key s of two objects a and B are the same, the value takes the first element a

The second parameter a - > A in the Stream represents the value value when converting from list to map, using the original object itself. You must have seen such a writing.

Collectors.toMap(a -> a.getId(), Function.identity(), (a, b) -> a)

Why can I write like this? I'll show you the full picture of the Function class, and you'll understand.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t); 
    ...
    static <T> Function<T, T> identity() {
        return t -> t;
    }
}

See, the identity method is to help us implement the expression, so we don't have to write it ourselves. In fact, it packages a method. This time I know a functional interface. Why do many of them contain a pile of default methods and static methods? It's for this.

Let's try again. There is such a default method in predict.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }
}

What can it do? Let me tell you, without this method, there is a piece of code that you might write like this.

Predicate<String> p = 
    s -> (s != null) && 
    !s.isEmpty() && 
    s.length() < 5;

Topics: Java Back-end