lostars
发布于 2018-12-11 / 2541 阅读
0

EFFECTIVE JAVA 3RD EDITION — 第七章 LAMBDA表达式与流

Item 42 : Prefer lambdas to anonymous classes

使用lambda表达式来代替匿名类

Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

匿名类适合于传统面向对象编程中需要函数对象的场景,特别是策略模式,lambda类似于匿名类但是更为简洁:

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

lambda隐藏了参数类型和返回值,这样让代码量更少,同时忽略lambda表达式的参数类型,除非它能让你的代码更清楚。

lambda使用中另外一个需要考虑的是类型推断,因为编译器是不会去作类型推断的。比如上面的代码如果words的类型是 List 而不是 List,代码根本不会编译。因为编译器能获取到大部分的类型信息从而作类型推断,但是如果不提供类型信息的话,编译器就无法知道。
上面的代码还可进一步简化:

words.sort(comparingInt(String::length));

非常重要的一点:
由于lambda表达式缺少命名(类型)和注释,如果本身计算缺乏解释性或者代码超过3行了就不要使用,那样反而会让代码更加晦涩难懂,最理想的lambda使用一行就能完事(不超过3行)。

并不是说匿名类就没用了,比如想创建一个抽象类的实例就可以使用匿名类来实现,而lambda就无法办到。 同时在lambda表达式中无法获取到自身的引用,因为在lambda表达式中的this指的是你实际使用的实例,在匿名类中this指的就是匿名类实例,涉及到上面的情况还是需要使用匿名类了。

lambda和匿名类共享了不能可靠序列化(反序列化)的类属性,所以千万不要序列化lambda表达式和匿名类。

总的来说在java8之后尽量不要使用匿名类,除非你创建的实例并不支持函数接口。

Item 48 : Prefer method references to lambdas

多使用方法引用
方法引用比lambda表达式更加简洁。比如:

map.merge(key, 1, (count, incr) -> count + incr);

这个方法是1.8之后添加到Map中的,就是合并一个Map,
最终会新增一对key-value到map中,value就是后面操作的一个统计,方法返回值就是这个统计的最终value。

如果用方法引用来写:

map.merge(key, 1, Integer::sum);

在IDEA中有上面第一种代码存在的时候会提示可以用方法引用来代替,下面是五种方法引用和lambda表达式对比:
image-720600116f6b4e90ab9b2d9cc5e0ae11_1639726193733
总的来说就是哪个简单用哪个。

Item 44 : Favor the use of standard functional interfaces

使用标准的函数接口
lambda表达式的出现改变了Java API的设计模式,比如模版方法模式,子类覆盖父类方法去声明父类的行为,现在的做法是提供一个静态工厂方法,传入函数对象去做同样的事情。

java.util.function内建了多种函数接口可供使用,分为6种基本类型:
Operator接口表明参数和返回值都是一样;
Predicate表明函数只接收一个参数并返回一个boolean值;
Supplier表明函数没有参数但返回一个结果;
Consumer表明函数接收一个参数但没有返回值;
Function表明参数和返回值都不是同一个类型;
上面六种基本类型针对int,long,double三种基本数据类型又有3种不同的接口变种,比如 IntPredicate,LongBinaryOperator

Function接口有9种变种接口用在返回类型是基本数据类型的时候:
3种基本数据类型分别作为参数和返回值(3*2)共6种,比IntToDoubleFunction
3种基本数据类型作为参数并返回对象共3种,比如 DoubleToObjFunction

针对 Predicate Consumer Function 又有多参数的接口:
BiPredicate<T, U> BiFunction<T,U,R> BiConsumer<T,U>

BiFunction有3种变种(2个参数,返回基本数据类型):
ToIntBiFunction<T,U> ToLongBiFunction<T,U> ToDoubleBiFunction<T,U>

Consumer3种变种(2个参数,一个对象,一个基本数据类型):
ObjDoubleConsumer ObjIntConsumer ObjLongConsumer

最后是 BooleanSupplier,返回boolean值Supplier的一个变种,但是返回boolean在Predicate已经得到了支持
上面大部分的函数接口都是对基本数据类型提供的支持,不要使用他们的时候使用包装类(Integer等),这样可能会造成严重的性能问题。

那么什么时候需要自己定义函数接口而不是使用java内置的函数接口?

  1. 接口被广泛的应用并且有个表述性非常强的名字,比如Comparator接口;
  2. 有着非常强的相关性;
  3. 实现该接口可以有很大的好处,比如调用者实现Comparator接口可以定义自己的比较器;
    一旦自己定义函数接口就需要使用FunctionalInterface注解,原因:
  4. 表明这是一个函数接口用来支持lambda表达式;
  5. 让你自己知道这个接口是不会被编译的除非添加了抽象方法;
  6. 在接口变更中防止无意的添加抽象方法;
    最后一点就是不要在提供多个重载方式的时候在同一个参数位置使用不同的函数接口,这样可能会造成歧义。
    比如ExecutorService的submit方法,同时支持Callable和Runnable作为参数,这样调用的时候就有可能需要参数类型转换才行。

Item 45 : Use streams judiciously

适当的使用流

Java8中添加的流提供了2个重要的抽象:

  • 流,代表有限或无限的元素队列;
  • 流管道,代表对流中的元素做的多级运算;
    流中的元素来源可以是集合,数组,文件,正则匹配数据等等,可以是对象引用也可是基本数据类型,基本数据类型支持 int long double

流管道包含了0个或多个中间操作和一个最终操作,中间操作做一些映射、过滤、匹配等等操作,而最终操作则从最后一个中间操作返回做最终的计算,返回一个集合或者是计算等等。

流管道是懒操作(lazily),如果不做最终操作,中间操作都是空操作,因为它没有做任何计算。流管道默认都是顺序执行的,虽然可以让他并行执行,但是很少这样做。虽然流API可以用在很多计算的场景,但是并不能随意的使用它。流API能否正确使用会影响你的代码可读性和可维护性。

比如下面的代码(排版修改过):

// Overuse of streams - don't do this!
public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) {
            words
                .collect(groupingBy(word -> word.chars().sorted()
                .collect(StringBuilder::new,(sb, c) -> 
                    sb.append((char) c),StringBuilder::append).toString()))
                .values()
                .stream()
                .filter(group -> group.size() >= minGroupSize)
                .map(group -> group.size() + ": " + group)
                .forEach(System.out::println);
        }
    }
}

上面就是一个过度使用流的一个例子,所以正确的使用流API是非常重要的。
由于缺少参数类型,在lambda表达式中要谨慎给参数命名。
当你开始使用流的时候会非常想把所有代码用流重构一遍,但千万不要这样做,
只在确实有需要的时候才去重构。

上面代码的功能,流通过lambda表达式和方法引用来实现,实际上我们写代码块或者抽象私有方法(或者helper方法)也能实现上面的功能。

使用代码块的优势:

  1. 使用代码块你可以读取修改任何局部变量,而lambda表达式你只能读取final变量,不能修改局部变量;
  2. 使用代码块你可以返回或者抛出任何检查异常,在循环中使用 break,continue来终止,lambda表达式任何一点都无法做到;
    流操作非常适合以下的场景:
  3. 修改元素队列;
  4. 过滤元素队列;
  5. 用一个单独操作来合并元素队列(比如计算总和,最大值,最小值等等);
  6. 将元素队列合并到一个集合或者是按照某些条件分组;
  7. 从元素队列中查找满足条件的元素;
    流还有一个不足的地方是不好处理流操作每一步的参数,比如中间操作a->b->c a中的参数想要在c中使用,那只能交换bc操作的顺序或者用一个变量去存储,但这样就违背了stream的最主要目的(代码简洁)。
    同时流与普通迭代循环的使用是根据实际情况来选择的,普通迭代代码容易懂,流代码简洁但是需要懂流的操作才能明白。

Item 46 : Prefer side-effect-free functions in streams

使用流中的安全方法(无副作用)
流并不仅仅是一个API,更是函数编程的一个极佳范例。流最重要的是组织你的操作队列,每一步操作都是对外封闭的;其结果只依靠于操作内部的入参,不依靠其他任何可变状态也不改变任何状态;所有的中间操作和最终操作都没有任何副作用。

有时候你可以看到下面的吊代码:

// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

统计一个文本中每个单词的频率,没什么毛病,但是其实根本没用流。它在迭代里调用merge方法进行统计,并且迭代就是最终操作,中间操作都在迭代中。

// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

上面的代码更简洁,但为啥还是有人写最上面的代码?因为人都会使用自己熟悉的东西,但是forEach的使用只应该在展示流计算的结果或者用来添加流计算结果到一个集合中,而不是用作流的计算。

// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet()
    .stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

上面的代码用到了Collectors API,在流中使用可以让代码更加简单可读。

Colletctors中有39个方法,有些甚至还有5个参数的方法,所以没有必要完全去了解Collectors,同时Collectors返回值一般都是集合,刚好可以使用流去处理。
其内部有toList,toSet,toCollection三个方法分别返回对应List,Set,Collection,剩下的36个方法大部分都是处理返回map,比返回集合更加的复杂。

toMap方法使用唯一的key绑定流中的元素,如果多个元素尝试绑定同一个key,流就以IllegalStateException终止。
groupingBy和toMap一样,提供给你分组统计的方法来解决上面绑定同一个key的问题,使用的是merge方法。
三个参数的toMap方法用指定key创建一个map,这个key要绑定到一个已经有key的value上。同时三参数的toMap还有类似toConcurrentMap的变种方法。四个参数的toMap用来声明一些特殊的map,比如EnumMap,TreeMap等等。

最简单的groupingBy使用就是返回一个分组统计之后的map,只传入一个参数;
传入两个参数的groupingBy第一个参数和普通的使用一样,第二个参数传入counting(),返回的就不是分组之后的元素map,而是每个分组的数量。
第三种变种则允许声明一个map工厂,比如你可以声明一个collector返回一个value是TreeSets的map。
groupingByConcurrent则提供了上面三种的变种,只不过是并行的,同时返回ConcurrentHashMap

上面counting()返回的collector仅用于流下游的收集,像 collect(counting()) 这种代码是不应该写出来的。

另外需要说的是joining方法,该方法只用于处理CharSequence,提供三个方法,无参,一个参数和三个参数。
一个参数传入一个叫分隔符的字符,返回用该字符分隔的Collector;三个参数除了传入分隔符,还需要传入前缀和后缀,其他和一个参数的相同。

这节主要介绍了流的基本用法,以及一些常用的API。

Item 47 : Prefer Collection to Stream as a return type

选择适当的返回类型,集合或者是流

在写代码的时候需要考虑是返回元素的队列还是返回集合或者是迭代器,你需要考虑你的用户是用返回值来做迭代还是继续的做流的一些操作,最好的就是都提供。

好在java8的Collection已经在接口中添加了流的支持。如果你的返回值够小的话最好直接就返回一个集合的实现,比如ArrayList。

Item 48 : Use caution when making streams parallel

小心使用并行流

先看下面并行获取前20个梅森素数的代码:

public static void main(String[] args) {
    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
}
static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime).parallel();
}

普通方式在我的机器上大概13s左右的时间,并行是不是会快很多呢?实际上根本不会有任何输出,程序处于卡住的状态,然后cpu占用飙升。
因为流并不知道如何并行的进行这个操作,然后就启发式失败。

事实上,通过Stream.iterate或者流中有limit操作的,使用并行都不会增加其性能。所以不要盲目的使用并行流,错误的使用除了影响性能之外还可能导致错误的结果甚至奇怪的行为和其他灾难性后果。

因为流的并行操作是一个严格的性能提升选项,通常并行流的操作都在一个公共的 fork-join 池里,如果一个单独的操作没有按照预期执行很可能影响其他没有任何相关性操作的性能。

作为一个约定,并行流能提升以下情况的性能:
ArrayList,HashMap,HashSet,ConcurrenHashMap,数组,int和long
原因:

  1. 他们都能够随意的被分割成用于并行操作的大小;
  2. 他们都提供了很好的局部引用性,因为都保存在内存一块连续的区域,特别是基本类型的数组在并行流下的性能提升更为明显;
    同时流的最终操作的特性也会影响并行流的性能,如果最终操作中进行了大量的计算,那么并行也不会提升太大的性能。
    最好的情况就是在最终操作中做减法,比如 min, max, count, sum这些操作,但是像 collect 这种操作就不适合并行的操作,因为太耗时了。

通常正常情况下通过并行能够提升的性能和你的处理器核数成线性关系的。

总的来说别轻易的使用并行流,除非经过了严格测试,并且确实有性能提升才使用。
如果需要编写自己的流,迭代器或者集合实现,如果你需要并行的操作,
那么一定要覆盖spliterator方法,并且经过测试。


这章其实就是第三版Effective Java的核心更新部分了,主要讲的就是Java8的新特性:lambda表达式和流的使用以及需要注意的点。

就我个人实际开发情况来说,工作中使用lambda表达式的时候不是特别多,因为复杂的循环基本不会使用lambda表达式,而是使用forEach循环或者是使用迭代器。

至于流的使用其实还是需要一定的学习成本的,加入的东西很多,想要完整的掌握还是需要花点时间的。特别是并行流的使用,没有完全掌握流的话还是不要使用为好,普通流的性能大部分场景下也足够了。