可能是 Java Stream 的最佳实践(三)—— 实战

1,182 阅读5分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

前两篇文章向大家简要地介绍了Stream所具备的能力,有些可能你非常熟悉,有些可能你没用过但其实很有用,不管如何,希望能对大家的日常开发有所帮助。下面总结了一些实际开发中可能被大家忽略的点,但会有效地提升代码的可读性和性能。

一个操作一行

可读性对于业务代码来说十分重要,有些时候甚至可以牺牲一点性能。stream大部分时候都是链式操作,如果不注意分隔,加上有些函数可能还比较长,操作符比较多,会给阅读者带来很多麻烦。所以提倡在stream之后一个操作符一行,清晰明了。

//Bad
String s = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1").parallel()
        .filter(x -> x.startsWith("a")).collect(joining());

//Good
String s = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1")
        .parallel()
        .filter(x -> x.startsWith("a"))
        .collect(joining());

import static

这条还是和代码可读性相关的。stream相关的方法可以静态引入,从而减少一些冗长的类名,减少视觉干扰。

//Bad
strings.stream()
	.sorted(Comparator.reverseOrder())
	.limit(10)
	.collect(Collectors.toMap(Function.identity(), String::length));

//Good
import static java.util.stream.Collectors.*;
strings.stream()
	.sorted(reverseOrder())
	.limit(10)
	.collect(toMap(identity(), String::length));

方法引用优于lambda

方法引用说白了就是基于类型推断的lambda表达式,写法上有所区别。当一个函数的输入和输出都一致时(即方法签名一致),可以简化成方法引用的写法,以String的length方法为例:

x -> x.length
//等价于
String::length

为什么上一个例子中filter(x -> x.startsWith("a"))就用不了String::startsWith呢,因为这里输入的参数是x,而不是"a",无法达到判断的效果。如果真想这么写也可以:

public static boolean startWithA(String x){
    return x.startsWith("a");
  }

//这个类叫Test,这样就可以使用方法引用了
String s = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1").parallel()
        .filter(Test::startWithA).collect(joining());

回正题,为什么推荐尽量使用方法引用呢?因为lambda表达式在编译时会被翻译成一个静态方法:

private static Integer lambda$main$0(String s) {
	return s.length();
}

但方法引用只会对应一个invokedynamic的字节码命令,不会有额外的方法,处理效率比lambda表达式更高。

重用Stream

Stream是不可重用的,如果一个stream执行一个终端操作之后,再次执行的话就会报异常:stream has already been operated upon or closed。之前我们说过,stream不是一个数据结构,不存储数据,一次性的。但如果你出于不想重复写代码的考虑,真的想重用也不是没有办法。

Supplier<Stream<String>> streamSupplier =
    () -> Stream.of("d2", "a2", "b1", "b3", "c")
            .filter(s -> s.startsWith("a"));

streamSupplier.get().anyMatch(s -> true);   // ok
streamSupplier.get().noneMatch(s -> true);  // ok

放到一个Supplier中,每次调用get都会构建一个新的stream出来,虽然效率上没有提升,但达到了Do not Repeat Youself的目的,代码更干净。

注意使用顺序,先filter后处理

这是效率的问题,也是一个逻辑问题。肯定要先做过滤再做别的操作,不然所有的操作都会走一遍最后再过滤肯定相对较慢。如果有filter的需求,大部分就放在第一个(除非你需要过滤中间操作可能产生的null)。就不举例子了。

注意使用Null Check

这一步可以有效地减少stream操作过程中的NPE,当你无法控制stream里面都有什么东西时,可以添加一步非空过滤,如下:

Optional<Integer> reduce = Stream.of(1, 2, 3, 4, 5, null)
        .filter(Objects::nonNull)
        .filter(x -> x > 1)
        .reduce(Integer::sum);

这一步校验的小技巧可以避免很多可能的异常,比较实用。

用不同的stream处理对应的原生类型

Stream支持了原生类型的各种stream,比如:IntStream LongStream DoubleStream。如果处理的流是原生类型的数据,优先选择这些stream,这样就免去拆箱的过程,效率更高。

//Good
OptionalInt reduce = IntStream.of(1, 2, 3, 4, 5)
        .filter(x -> x > 1)
        .reduce(Integer::sum);

//Bad:注意这里返回的 Optional<Integer>
Optional<Integer> reduce = Stream.of(1, 2, 3, 4, 5)
        .filter(x -> x > 1)
        .reduce(Integer::sum);

异常处理

有时候在某个中间过程执行方法时,这个方法会向外抛一个受检异常,这个异常你必须要处理,作为stream是可以支持在中间处理的,但代码可能就会变成这样:

List<Class> classes =
    Stream.of("java.lang.Object",
              "java.lang.Integer",
              "java.lang.String")
          .map(className -> {
            try {
                return Class.forName(className);
            }
            catch (ClassNotFoundException e) {
                // Ignore error
                return null;
            }
        })
        //这里要过滤转换失败带来的null,这里的filter可以在写后面
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

用一个map来处理异常,代码的视觉污染会十分严重,让人看了就没有去维护的欲望。但受检异常必须要处理,怎么办呢:

Class toClass(String className) {
    try {
        return Class.forName(className);
    }
    catch (ClassNotFoundException e) {
        return null;
    }
}

List<Class> classes =
    Stream.of("java.lang.Object",
              "java.lang.Integer",
              "java.lang.String")
          .map(this::toClass)
          .filter(Objects::nonNull)
          .collect(Collectors.toList());

定义一个方法在外部,将受检异常"吃掉"。既处理了异常,也让代码变得可读可维护。

debug技巧

stream的debug比较困难,它不像正常代码那样执行一行是一行,像IDEA这种的工具可以提供debug工具,但也仅限于本地且按照了IDEA的情况下。有一个API却可以感知执行的步骤,而且属于中间操作,不像foreach那样是一个终端操作。依然使用上面的例子:

Optional<Integer> reduce = Stream.of(1, 2, 3, 4, 5, null)
        .filter(Objects::nonNull)
        .filter(x -> x > 1)
        .peek(System.out::println)
        .reduce(Integer::sum);

System.out.println(reduce.get());

//output
2
3
4
5
14

peek接受一个Consumer作为参数,Consumer的方法签名是接收一个值且返回void,什么都不改变,什么也不返回。虽然把用作debug有点不厚道,但真的很好用。

总结

stream最佳实践系列已经全部更新完成,从stream的基础开始,讲了stream的常用API及其能力,最后总结了一些日常开发过程的技巧实践。希望可以帮到正在阅读的你,如有问题,也请斧正。