语法

Java中的Lambda表达式的语法还算蛮标准的,即:参数列表->函数体。如下面的表达式和函数在功能上是等价的:

// 表达式这样写
i -> i.toString() 

// 上面这个表达式等价于这个函数
public String functionName(ClassOfI i){
    return i.toString();
}

如果没有参数呢?那就直接()即可。如

// 表达式这样写
()->1

// 上面这个表达式等价于这个函数
public int FunctionName(){
    return 1;
}

由于Java“一切皆对象”,“->”符号右侧的函数体常常是来自某个对象的方法。针对这种情形,Java提供了一种更加简洁的表达式语法。如:

// 表达式的简写形式
Integer::toString

// 上面的简写形式等价于这个表达式
i -> i.toString()

// 又如,这个简写形式
System.out::println
// 上面的简写形式等价于这个表达式
i -> System.out.println(i)

上面的表达式中,函数体都只有一条语句。如果某个函数体需要写多条语句怎么办呢?这也简单,就跟正常的方法体一样用“{}”把函数体包起来就可以了:

i -> {
    System.out.println(i);
    return i.toString();
}

在语法上来说,Java中的lambda其实就是这样了。

原理

由于Java是基于“一切皆对象”的思想来设计、实现的(虽然Java自己也没有 严格的遵循这一思想),因此,它无法脱离对象(类/实例)来实现函数式编程。为此,Java用了一个小花招来支持函数式编程。这个小花招就是匿名内部类。

虽然是一个匿名类,但这个类必须要有一个接口声明。对Java中的函数式编程来说,这个“接口”必须是一个所谓的“函数式接口”。而“函数式接口”是什么呢?就是“只有一个抽象方法的接口”。例如:

public interface Func{
    String asString(int i);
}

当然,接口中可以有其它方法,但那些方法必须通过default关键字来实现方法,以保证整个接口中只有一个抽象方法。如:

public interface Func{
    /**这个方法是抽象方法*/
    String asString(int i);
    
    /**这个方法有默认方法体*/
    default Integer asInteger(String s){
        return Integer.parse(s);
    }
}

为了标记这类接口,Java专门引入了一个新的注解:@FunctionalInterface。但是这个注解吧……完全只起个标记作用。符合“函数式接口”要求的,不加这个注解也能拿来用;不符合要求的,加了这个注解也没用。

Java实现函数式编程的方式,实际上就是根据Lambda表达式为函数式接口创建一个匿名实例。例如上文中提到的表达式 i -> i.toString(),Java会将它编译成这样一个实例:

// 实例的名字中,除了类(接口)名、Lambda关键字之外,其它的都可能有变化
new Func$$Lambda$39/845272209@fb7141f implements Func{
    public String asString(int i){
        return i.toString();
    }
}

从这个角度来说,Java中的函数式编程其实更多的像一种语法糖,用于简化冗长的匿名实例的一种语法糖。

常用

各种常见的函数式接口

由上文可见,在Java中要使用函数式编程,必须先定义函数式接口。Java已经在java.util.function包下声明了一系列常用的函数式接口,大部分情况下我们可以把这些接口直接拿来使用。如:

public interface Function<T,R>{
    R apply(T t);    
    // 省略其它方法
} 

这个接口声明了这样一个函数:它只接受一个类型为T的入参;并且返回一个类型为R的出参。使用时,我们一般会这样写:

i -> i.toString()
i -> someService.doService(i)
someService::doService // 这么写的话,必须保证doService只接受一个入参,并且返回值不是void

但是,如果someService.doService()方法需要两个入参怎么办?别急,Java提供了两个参数的BiFunction接口供你使用。如果三个参数呢?四个参数呢?五六七八个参数呢……额,你不会用一个包装类来封装参数列表么……哈哈。

除Function外,还有如Consumer、Predicate、Supplier等函数式接口供我们“拿来主义”,有兴趣的可以看看,这里不赘述。

虽然Java已经提供了不少好用的函数式接口,但是,我们在coding时难免会遇到用不了这些接口的地方。例如,当我们要向线程池提交一个新的任务时,我们需要的是Runnable或Callable、而不是上述任何一个接口的实例,如:

ExecutorService executorService = Executors.newFixedThreadPool(4);
executorService.submit(new Callable<String>(){
    public String call(){
        return "abc";
    }
});

这种情况下,我们要怎么办呢?其实很简单:只要我们需要的是一个“函数式接口”,是“只有一个抽象方法的接口”。无论这个方法是java.util.function包下的、还是Java定义好的其它接口、亦或是我们自定义的接口,都可以用lambda表达式来写。例如上面那段向线程池提交新任务的接口,我们就可以写成:

executorService.submit( () -> "abc");

自定义的接口当然也可以这样,例如有这样一个接口:

public interface MyFunction{
    int calculate(int... nums);
}

那么,我就可以用这样的表达式来进行计算:

n -> n[0]+n[1]
n -> n[0]+n[1]-n[2]-n[3]

等等等等。

Optional和Stream

上面写了这么多Java函数式编程的“实例”,但是,能通过编译、能在实践中用到的,恐怕一行都没有。所以,这一段来看看在实践中我们要怎样使用函数式编程。

一般来说,Java中使用函数式编程都遵循这这一“公式”:

(开启函数式编程)[调用函数式接口]*(结束函数式编程)

这个公式中,我们一般通过Optional或Stream来“开启函数式编程”。声明或者获取这两个对象后,就可以借助它们的以函数式接口为参数的方法,用于“调用函数式接口”;并且可以链式的、多次调用函数式接口。在借助函数式接口做了一系列处理后,就可以“结束函数式编程”,以得到所需的最终结果了;当然,这个“结束函数式编程”自身也可以是一个Lambda表达式。

Optional

先来看看Optional。Optional的本意是“可选的”,也就是“有可能有值、有可能没有值”。Java用它来包装一个可能为null的实例,意图以此来取代Null、并减少Null Check和NPE。

开启函数式编程

这个类只有私有构造函数。因此,想要获得一个Optional实例,只能通过静态方法of(T value)或者ofNullable(T value):

String value = ....;
// 如果value==null,下面这行将抛出异常。
// 因此,一般只在能够100%确定value!=null的情况下才使用of()方法。
Optional<String> op = Optional.of(value);

// 如果value有可能为null(例如这是从数据库中查到的某个Nullable字段),建议使用ofNullable()方法。
// 当value==null时,这里不会抛出异常,只是会获得一个Optional.Empty实例。
Optional<String> op_0 = Optional.ofNullable(value);

Optional.EMPTY是一个静态、不可变实例。它的本质含义是“本Optional实例包装了一个null”。因为这个缘故,对于Optional的很多方法来说,Optional.EMPTY都会有“特殊”处理。我们在后面细说。

调用函数式接口

获取到了Optional的实例后,我们就可以借助这个类的方法来使用Lambda表达式了。常用的“调用函数式接口”的方法其实也就俩:

public Optional<T> filter(Predicate<? super T> predicate){...}

public Optional<U> map(Function<? super T, ? super U> mapper){...}

filter方法相当于一个if条件判断,其中的Predicate接口要求传入一个类型为T的参数、返回一个boolean类型的结果。如果返回结果为true,则将当前Optional原样返回,供后续使用。否则返回Optional.EMPTY。如果Optional中包装的值是null,那么无论Predicate的返回值是什么,都会直接返回当前Optional——其实细想想,也就是对Optional.EMPTY调用filter方法会恒定返回Optional.EMPTY。参见下面这个例子:

String str  = ...;
// 实践中后面还会有其它代码,这里直接返回
Optional<String> op = Optional
                    .ofNullable(str)
                    .filter(s -> s.length()>10);

如果str是个null,看起来s.length()会抛出NPE。但是实际上,这里的op会被赋值为Optional.EMPTY。这也就是Optional消除NPE的方式:用一个EMPTY实例来代替Null。

如果str不是null,且其长度不小于10(如str ="abc"),那么,由于filter中的Predicate表达式会返回false,所以op最后也是Optional.EMPTY。

只有当str非null、且其长度大于10(如 str = "0123456789abcdefg")时,op会得到一个包装了这个str的Optional。需要注意的是,这里不会创建新的Optional实例,而是直接将原Optional原样返回。

map方法所做的事情与if条件判断没有必然联系。它的本意是执行Function定义操作,从而将当前Optional中包含的实例转换成其它的什么实例。

可别小看这个Function。所有的代码、功能,其实都可以抽象为 入参+操作=出参。反过来说,这个Function接口,可以用来表达任何的代码或功能,简直是个万能接口。

回到Optional.map方法上来。这个方法的作用已经很清楚了:小到调用get方法,大到调用远程服务,都可以浓缩到这一个方法中来。如果操作正常,它会返回一个新的Optional,这个新Optional中包装的就是Function的返回值。如果操作有问题——如被包装的值为null——则会直接返回Optional.EMPTY。例如:

String str = ...
//实践中后面还会有其它代码,这里直接返回
Optioanl<Integer> op = Optional
                        .ofNullable(str)
                        .map(String::length); 

如果str是个null,看起来s.length()会抛出NPE。但是实际上,这里的op会被赋值为Optional.EMPTY。

如果str不是null,那么op将会是一个新的Optional,其中包装的数据是str的长度。

如果这个Function返回了Null呢?这个很好理解:op会被赋值为Optional.EMPTY。如果这个Function在执行过程中抛出异常呢?别想多,要么手动处理——无论在Function内部还是在Optional外部,要么向上继续抛出。从这个角度来说,Optional只能消除由于str为null所导致的NPE。

filter和map方法的返回值仍然是Optioanl对象,意味着它们可以通过链式调用来配合着完成一些比较复杂的功能。例如:

long id = Optional.ofNullable(bean)
    .filter(b->"PROCESSING".equals(b.getStatus()))
    .map(b -> someService::process)
    .filter(Result::isSuccess)
    .map(Result::getData)
    .map(someDao::save)
    .get();
    
// 上面这段代码相当于:
long id;
if(bean != null && "PROCESSING".equals(bean.getStatus())){
    Result<Data> result = someService.process(bean);
    if(result != null 
      && result.isSuccess() 
      && result.getData()!=null){
        id = someDao.save(result.getData());
    }
}

其实Optional中还有这个方法,只不过我用的比较少,所以不做介绍咯(其实就是介绍不来哈哈):

public Optional<U> flatMap(Function<? super T, Optional<U>> mapper){...}

结束函数式编程

在使用Optional完成了函数式编程之后,我们需要通过一些方法来“结束函数式编程”并获得最终处理结果。Optional提供了以下几个方法来达到这一目标:

public T get(){...}

public boolean isPresent() {...}

public void ifPresent(Consumer<? super T> consumer) {...}

public T orElse(T other) {...}

public T orElseGet(Supplier<? extends T> other) {...}

public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {...}

其中,get():T方法最简单:把当前Optional中包装的对象取出来。但是,这个方法与of()方法一样:如果当前Optional中包装的对象为null,就会抛出异常。也就是说,对Optional.EMPTY调用这个方法将会抛出异常。为了避免这种问题,在调用get()方法前,通常会先借助isPresent()方法来确保只在当前Optional中包装的对象非null的情况下才去调用get()方法。

// get()和isPresent()方法一般这样配合使用
Optional<String> op = ...;
if(op.isPresent()){
    String str = op.get();
    someService.process(str);
}

ifPresent(Consumer)方法与isPresent()方法在名字上只差之毫厘,但是在使用上却谬以千里。ifPresent(Consumer)方法可以看做是“isPresent()+get()+对get()到的结果进行处理”的“三合一”方法。即:如果当前Optional中的对象非null(isPresent() == true),则取出这个对象(T value = get()),然后用Consumer去处理它(consumer.accept(value))。

// 上面那段get()+isPresent()+someService.prosess的代码可以这样写:
Optional<String> op = ...;
op.ifPresent(someService::process);

Stream

如果说Optional主要用来针对一个对象做函数式处理,那么Stream就是用来针对一批对象做函数式处理的。或者简单点说,Stream是用来处理for循环的。因而,stream中大部分“调用函数式接口”方法,都会针对“当前Stream”中的每一个元素进行处理。 之所以强调是“当前Stream”,是因为Stream对象中的“调用函数式接口”方法一般都会返回一个Stream。返回的这个Stream有时是一个新的Stream,后续的函数表达式会作用于这个新的Stream,而不是原先的那个。

顺带一提,Stream只是一个接口(作为对比,Optional是一个类)。与之类似的(但没有什么继承关系)的还有IntStream/LongStream/DoubleStream等。看名字就知道啦,这些是专门针对某种数据结构的Stream。

开启函数式编程

使用Stream来“开启函数式编程”的方式一般有两种。

一种是在某个集合(如List/Set)或Map实例上,通过stream()方法来开启一个Stream。另一种是用Stream/IntStream/LongStream/DoubleStream上的静态方法来构造一个Stream。如:

// 用List来开启Stream
List<String> list = new ArrayList<>();
list.stream()...

// 用Stream上的静态方法来构造Stream
Stream.of("a","b","c","d")...

// 类似的,用IntStream上的静态方法来构造Stream
// 对这个Stream做操作,等于遍历1/2/3/4这四个值并依次做操作
IntStream.of(1,2,3,4).... 

// 这个Stream意味着[1,4)的一个遍历序列
IntStream.range(1,4)...

// 这个Stream意味着[1,4]的一个遍历序列。注意右边界。
IntStream.rangeClosed(1, 4)...

还有一个比较小众的方法,用Stream.Builder来创建Stream。不过这方法也未见得就比上面的方法更简便,我基本不用。

调用函数式接口

这里只讲Stream上的一些方法;IntStream/LongStream/DoubleStream等基本都“同理可证”。

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

Stream<T> filter(Predicate<? super T> predicate);

Optional<T> findFirst();Optional<T> findAny();

Stream<T> sorted();

Stream<T> sorted(Comparator<? super T> comparator);

Stream<T> peek(Consumer<? super T> action);

Stream<T> distinct();

上述方法中,map和filter方法与Optional的同名方法很相似(当然,也有相似的flatmap方法),这里不多赘述。但是,findFirst()和findAny()这两个方法值得拿出来说一说。

如前所述,filter()方法相当于一个if判断;而Stream的处理流程类似于一个for循环。综合起来看,Stream.filter()方法就相当于在一个for循环中,对循环的每一个元素都做一次if判断。而这种for+if的逻辑中,经常会出现下面这种情况:

List<Integer> list = ...;
int n = -1;
for(int i=0; i<list.size();i++){
    if(i >10){
        n = list(i);
        break;
    }
}
someService.process(n);

Stream中的findFirst()或findAny()方法就是用来处理这种情况的:只要满足了filter中的判断条件,那么立即跳出此迭代,专注于处理找到的这个数据。如上面这一段代码,我们就可以用表达式这样写:

List<Integer> list = ...;
n = list.stream()
        .filter(i>i>10)
        .findFirst()
        .orElse(-1);
someService.process(n);

至于为什么需要findFirst)()和findAny()这两个方法么……Javadoc解释说,findAny()方法会从Stream中选择任意一个满足条件的数据,在并发Stream的中这样处理可以很好的提高性能。

Stream重载了两个排序方法:sorted()和sorted(Comparator)。两个方法的功能都是一样的:把当前Stream中的元素按顺序排列好。不过,调用sorted()方法要求Stream中的元素全都实现Comparable接口,因为排序时需要使用Comparable.compareTo()方法。而sorted(Comparator)则没有这个必要,因为用这个方法排序使用的是入参的Comparator.compare()方法。

peek()方法其实是一个挺有用的方法:对Stream中的每一个元素都做一次处理。但是它的存在感不强。因为大部分情况下我们都会用map方法来实现同样的功能。distinct()方法的存在感不强则是因为大部分情况下我们会直接构建一个无重复元素的集合,即使这个集合允许有重复元素。

当然,上面大部分的方法也都可以通过链式调用来组成一个完成的处理流(不过findFirst()和findAny()返回的是Optional,不算是“流”了)。例如:

list.stream()
    .map(String::toString)
    .peek(System.out::println)
    .distinct()
    .sorted()
    .filter(Objects::isNull)
    .findFirst()
    .get();

结束函数式编程

由于Stream操作的是一批数据,因此,它提供的“结束函数式编程”的方法也更多。常用的一般是这么几个:

void forEach(Consumer<? super T> action);

boolean allMatch(Predicate<? super T> predicate);
<R, A> R collect(...);T reduce(...);

这里的每个方法,几乎都可以代表一套或几个方法。

首先是forEach方法,它有一个类似的方法叫做“forEachOrdered”。两个方法的区别只在于后者会确保执行顺序,即使在多线程操作时也是如此。

这两个方法的作用跟前面的peek方法很像,也是对当前Stream中的每一个数据都做一次处理。但是与peek不同的是:peek方法会将当前Stream原封不动的返回给用户,因而我们可以继续在这个Stream上做后续处理;但foeEach方法的返回值是void,因而调用forEach后,这个Stream的处理就完全结束了。如最简单的:

new ArrayList().stream()
    .forEach(System.out::print);
// 大概是这个方法太常用了,Java很贴心的给List也加上了这个方法:
new ArrayList()
    .forEach(System.out::print);

allMatch方法则有两个名字相近的“兄弟”:anyMatch/noneMatch。他们都接受一个Precicate作为入参,而他们的作用可以从名字上看出来:当前Stream中“所有元素都满足(allMatch)”/“任一元素满足(anyMatch)”/“没有一个元素满足(nonMatch)”入参Precidate的条件。如:

List<String> list = ...;
boolean all = list
            .stream()
            .allMatch(str -> str.length()>10);
            
boolean any = list
            .stream()
            .anyMatch(str -> str.length()>10);

boolean none = list
            .stream()
            .noneMatch(str -> str.length()>10);

collect和reduce大概是最有用的也是最常用两个方法。所以作为压轴戏放到最后。这两个方法分别用来把Stream中的元素重新收集到一个新的容器(集合或者Map)中;而reduce则用来把Stream中的元素“压缩”到一个非容器对象中。

“普通”的collect方法需要三个参数:Supplier<R> supplierBiConsumer<R, ? super T> accumulatorBiConsumer<R, R> combiner)。其中,supplier用来为Stream中生成一个容器,然后借助accumulator用来将Stream中的元素放进这个容器中。大功告成~哎,combiner呢?combiner是用来做什么的?就我的理解来说,combinler只在并行Stream中会发挥作用,它用来把并行生成的多个容器组合成一个完整容器。这个过程……我没弄错的话就是用ForjJoin的思路来做的,这里不啰嗦,只讲讲应用:

List<Integer> list = ...;
List<Integer> newList = list.stream()
    .collect(ArrayList::new, List::add, List::addAll);

在上面这个例子中,collect方法的三个参数分别是ArrayList::new、List::add和List::addAll。也就是说:对这个Stream,会先调用ArrayList::new方法创建一个新的容器,然后通过List::add方法依次将Stream中的元素放到新容器中。同步Stream的操作就到此结束,直接返回这个新的List就可以了。如果开启了并行Steam,最后通过ForkJoin的方式调用List::addAll方法,将所有的ArrayList合并成一个新的、完整的容器。

虽然这个方法很强大,但是这段代码理解和写起来可就没那么简单了。所以,Java提供了一个辅助类:Collectors,配合上collect的另一个重载方法。借助它们,上面的代码可以简化为:

List<Integer> list = ...;
List<Integer> newList = list.stream().collect(Collectors.toList());

代码简单,理解起来也很简单:toList嘛,把这个Stream中的元素收集到list中。当然,也还有toSet/toMap/toConcurrentMap等方法(不过,toMap和toConcurrentMap使用上要稍复杂一些)。其实就实现原理上来说,使用Collectors和使用三个入参是一样的,Collectors只是提供一个“快捷方式”而已。

reduce方法collect很相似,只不过它的最终结果不是一个容器。不是容器却要“容纳”一批元素,这有点费解,我们用数字或字符串来举例会更清楚些:把Stream中的数字累加起来,或者把Stream中的字符串连接成一个长字符串,这就是两种典型的reduce操作。除了这两种之外还有什么reduce……大家自行体会吧……

与collect方法类似的,reduce也有几个重载方法:

<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);

T reduce(T identity, BinaryOperator<T> accumulator);
Optional<T> reduce(BinaryOperator<T> accumulator);

其中,reduce(三个参数)方法与collect(三个参数)方法差不多:identity用来指定一个初始对象;accumulator用来将Stream中的元素“压缩”到这个对象中;combiner用来将多个新的对象整合成一个完整对象。我们举个例子来看:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
list.stream()
    .reduce(BigDecimal.ZERO, 
            (decimal, integer) -> decimal.add(new BigDecimal(integer)),
            BigDecimal::add);

在上面这个reduce方法中,BigDecima.ZERO是一个初始对象;也就是说,如果Stream中压根没有数据,那么就直接返回这个结果。如果有数据,那么就按照第二个参数定义的表达式,将数据一一追加到BigDecimal中。如果是同步Stream,用这两个参数就可以完成全部操作了。如果是并行Stream,还需要借助第三个参数才能得到最终结果。

相比三参数版本,reduce(两个参数)的版本除了少一个参数之外,最重要的差异在于:reduce(两个参数)的返回值是T,即与当前Stream中元素的类型是一样的。即使这个差异,使得这个版本可以直接借助accumulator来将Stream中的元素“压缩”起来,而不需要先转换为其它对象、然后再将新的对象“累加”起来。这么说我自己都有点晕,还是看代码例子吧:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
Integer sum = list.stream().reduce(30, (a,b)->a+b);

上面这段代码中,“30”就是identity,后续所有的操作都是在这个“基数”上来处理的(至于为什么是30?我随手写的一个数,大概因为我今年30岁吧哈哈)。而后续操作其实就是不断地把两个Integer相加而已。最后得到的数是75。

从三参数到两参数,似乎已经很给力了。那么,还能再给力一点么?Java还真的更给力了一点,推出了一个参数的reduce方法。

看到两个参数的方法后,有多少人会跟我一样想到:我为什么一定要提供一个“基数”?绝大多数情况下,这个基数不就是0么?谁会跟作者一样闲的无聊写个30上去啊?少敲几下键盘能提高多少生产率你知道不?……

reduce(一个参数)方法就是不无聊而且追求高效的人设计的。这个方法的功能很简单,把当前Stream中所有元素都“压缩”到一个对象中就行了。如果Stream中没有元素,那么就返回null——哈哈,Java8力推Optional,怎么可能给你再返回一个null——这种情况下会返回一个Optional.EMPTY。这也是这个方法之所以要返回Optional的原因。我就不举例子了,很简单的。

最后顺带一提,有个叫永乐的人……咳咳串场了。顺带一提,Optional和Stream中还有很多其它的、非常实用的方法,大家不妨去看看。而且,这些类、方法的Javadoc写得很详尽,直接看Javadoc恐怕比读我这篇“嚼过的甘蔗”更有滋味。大家不妨去读一读,并希望大家在自己的类中也仿照着写一写,赠人玫瑰手有余香嘛。

并发Stream

前面提过一嘴“并发Stream”,这里来简单说一说。

过去我们写多线程,会用Thread、Runnable、Callable、Future、FutreTask来写异步任务,会用ExecutorService来开启线程池,其实已经相当简单、高效了。但是,还是有人不满足,还是有人会想:还能再给力一点吗?于是就有人提出了ForkJoin框架,引入了ForkJoinTask和ForkJoinPool。

Java把ForkJoin框架引入了Java 8中,并结合Stream推出了并发Stream的概念,使得多线程编程更加简单、高效了:我们甚至完全不必关心线程、线程池,就可以启动并发操作,像这样:

List<String> list = ...;
list.parallelStream().forEach(System.out::println);

// 上面这段代码相当于:
ExecutorService executorService = Executors.newFixedThreadPool
        (Runtime.getRuntime().availableProcessors());
for(String str : list){
    executorService.submit(new Runnable() {
        @Override
        public void run() {
            System.out.println(str);
        }
    });
}

当然,这里两段代码的“相当于”必须要打上一个引号,因为这两种多线程方式是存在不同的。不过这不是这篇文章的重点,感兴趣的话可以去看看ForkJoinPool的相关文章。

自定义函数式接口

绝大多数情况下,Java8中提供的这些函数式接口已经够用了。但是,我们当然也可以用Lambda表达式来实现一些我们自定义的接口,从而简化一些代码。例如,我以前写过一段类似这样的代码:

public interface Repayer{
    ReapayRecord repay(RepayPlan plan);
}
class RepayerAsDispatcher implements Repayer{
    @Resource
    private static Map<RepayMethod, Repayer> repayerDispatchMap;    
    @Override
    public RepayRecord repay(RepayPlan plan){
        // 省略howToRepay的定义
        RepayMethod repayMethod = howToRepay(plan);
        Repayer repayer = repayerDispatcherMap
            .getOrDefault(repayMethod,
                plan->throw new BizException("还款异常。",plan)); // 这里用到了Lambda表达式
        return repayer.repay(plan);
    }
}

注意这段代码中用到的Lambda表达式。这个表达式所实现的函数式接口并不是java.util.function下的任何一个,而是我自定义的接口。

Lambda的一些思考

语法糖

从前面的例子可以看出,Lambda表达式可以简化很多代码。这是它给我们带来的第一个便利。Java历来被诟病为语法啰嗦、代码冗长。虽然Lambda表达式并不能完全解决这个问题,但确实能有一定的缓解作用。前面已经有一些例子,这里就不再啰嗦而冗长地再举例了。

整合不同类的不同方法

这一点很难说清楚,我先举个例子。我们的系统需要调用若干个不同系统的服务接口。这些接口有REST的,有Dubbo的,甚至还有SOAP的,返回结果的封装类也各不相同:有叫ServiceResult的,有叫Response的,有叫ResultDto的。虽然具体接口差异很大,但是调用逻辑基本都是这样的:

public DataType callService(ArgType arg, SomeService service){
    try{
        // 调用服务,并获取ServiceResult/Response/ResultDto等返回数据
        ServiceResult<DataType> result = service.process(arg);
        // 判断业务逻辑是否正确;有些接口的判断规则是"0000".equals(result.getCode())
        if(result.isSuccess()){ 
            // 业务逻辑正确时,获取返回的数据。
            return result.getData();
        }else{
            // 业务逻辑不正确时,抛出异常
            log.warn("服务未返回正确结果。");
            throw new BizException();
        }
    }catch(Exception e){
        log.error("调用服务发生异常。",e);
        throw new BizException(e);
    }
}

几乎所有的调用逻辑都是这样的。即使我们能够用泛型来统一入参和出参,但是,由于不同服务接口返回的类不一样,调用服务、判断业务逻辑是否正确、获取返回的数据这三个功能,很难统一到同一个类中。因而,我们即使努力地想要避免重复代码,也只能为每个服务接口定制一个方法,在那个方法中把上面的代码重复一遍。这样,有几个服务接口,我们就会有几套大同小异的重复代码。虽然似乎已经尽力了,但这种重复率还是让人不爽。

但借助Lambda表达式,我们就可以把这些重复代码统一到一个方法中:

public <T,O, E> E callService(T arg,
                                Function<T,O> service, 
                                Predicate<O> isSuccess, 
                                Function<O,E> getData){    
    try{
        O result = service.apply(arg);
        if(isSuccess.test(result)){            
            return getData.apply(result);
        }else{            
            // 业务逻辑不正确时,抛出异常
            log.warn("服务未返回正确结果。servcie:{},arg:{}",service,arg);            throw new BizException();
        }
    }catch(Exception e){
        log.error("调用服务发生异常。servcie:{},arg:{}",service,arg,e); 
        throw new BizException(e);
    }
}

上面这段代码中,入参和出参被统一为泛型类型;调用服务接口、判断是否操作成功、获取业务数据这三种方法,则被统一成了三个函数式接口(从中我们可以可以看出Function<T,E>这个接口真的是个万能接口。当然,我们也可以用自定义的函数式接口,这无伤大雅)。借助这三个函数式接口,我们就可以用上面这个方法加上Lambda表达式来调用不同的服务接口了:

DataType data = callService(arg, 
                            someService::process,
                            ServiceResult::isSuccess,
                            ServiceResult::getData);

Data d = callService(arg1, 
                     otherService::doService, 
                     resp->"0000".eqquals(resp.getCode),
                     Response::getData);
                     InfoType info = callService(anotherArg, 
                     anotherService::call, 
                     ResultDto::isSuccess, 
                     r->r.getData().getInfo());

这个功能看起来挺简单,但是我觉得它意义非凡。它给了我们一种对方法进行抽象的能力,也就是把不同的方法统一为相同的抽象的能力。而这种能力为我们复用代码提供了极大的便利。也许还有其它作用,但目前我只想到这一点……(好像没什么说服力……)

函数式与面向对象

函数式编程本质上是种“反”面向对象的编程思想。在面向对象的思路中,对象是一种包含了数据和行为的编程单元。但是函数却只有行为、没有自己的数据。有些文章介绍说函数没有状态,本质上就是因为函数没有自己的数据。

但是我们可以想一下,我们现在使用Java编程时,常常会把数据和行为分开:数据封装为pojo类,叫做bean/dto/vo等;而行为则封装为service类。本质上,service类就是一种函数。只不过在语法层面并不支持Lambda的语法。

Java中Lambda的一些问题

当然,Lambda不是万能的,也不可能是银弹。它同样存在一些问题。

过于依赖泛型/类型推导

从前面的代码中也可以看出,Lambda表达式非常依赖泛型,尤其是Java原生的函数式接口,几乎满篇都是泛型定义。虽然Java的类型推断已经非常强大了,但是偶尔也会出现推断不出类型的问题,尤其很长的链式调用中比较容易出现这问题。

这种问题很少见,但是遇上了就不必犯愁。推断不出类型,那就明确指定好类型。一般只要把一个长长的链式调用拆分成几段,每段都明确定义好所使用的类型就可以了。

不便于调试

由于Lambda表达式追求简洁,向其中加入日志记录等往往显得很“多余”。但这样一来,如果Lambda表达式中有bug、需要日志等信息进行调试时,就两眼一抹黑了。

即使有日志,由于Java使用了匿名内部类,在日志中记录的类名/方法名/行号等也会很莫名其妙。想要看明白他们,有时也颇要费一番心思。不仅如此,由于Lambda表达式很简短,有时一行之中会有多个表达式,这时,即使找到了日志中记录的行号,也往往找不出是这一行中的哪个表达式出了问题。

当然,这些问题并非无解。只是为了解决这些问题而做的额外处理,与函数式编程追求简洁的思路会背道而驰,需要慎重处理。

不能直接向表达式外赋值

由于Java中的Lambda表达式实际上是一个匿名内部类,因此,它不能给这个类外部的对象赋值。如下面的代码就会出现编译错误:

int i = 1;
// 下面这一行会报错:Variable used in lambda expression should be final or effectively finalOptional.of("abc").ifPresent(str -> i = str.length());

这不是一个大问题,实践中遇到它的几率也挺小的。但是有时候,这个限制会给我们带来一些麻烦,使得我们必须用一个对象或者一个返回值来把Lambda表达式内部的数据传递到外部去。

异常处理比较麻烦

Lambda表达式内部如果抛出了CheckedException,必须在表达式内捕获处理,而不能直接将其xiang'wai抛出。