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<String>
,代码根本不会编译。因为编译器能获取到大部分的类型信息从而作类型推断,但是如果不提供类型信息的话,编译器就无法知道。
上面的代码还可进一步简化:
words.sort(comparingInt(String::length));
非常重要的一点:
由于lambda表达式缺少命名(类型)和注释,如果本身计算缺乏解释性或者代码超过3行了就不要使用,那样反而会让代码更加晦涩难懂,最理想的lambda使用一行就能完事(不超过3行)。
并不是说匿名类就没用了,比如想创建一个抽象类的实例就可以使用匿名类来实现,而lambda就无法办到。 同时在lambda表达式中无法获取到自身的引用,因为在lambda表达式中的 this
指的是你实际使用的实例,在匿名类中 this
指的就是匿名类实例,涉及到上面的情况还是需要使用匿名类了。
lambda和匿名类共享了不能可靠序列化(反序列化)的类属性,所以千万不要序列化lambda表达式和匿名类。
总的来说在java8之后尽量不要使用匿名类,除非你创建的实例并不支持函数接口。
方法引用比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表达式对比:
总的来说就是哪个简单用哪个。
lambda表达式的出现改变了Java API的设计模式,比如模版方法模式,子类覆盖父类方法去声明父类的行为,现在的做法是提供一个静态工厂方法,传入函数对象去做同样的事情。
java.util.function
内建了多种函数接口可供使用,分为6种基本类型:
Operator
接口表明参数和返回值都是一样;
Predicate
表明函数只接收一个参数并返回一个boolean值;
Supplier
表明函数没有参数但返回一个结果;
Consumer
表明函数接收一个参数但没有返回值;
Function
表明参数和返回值都不是同一个类型;
上面六种基本类型针对 int
, long
, double
三种基本数据类型又有3种不同的接口变种,比如 IntPredicate
, LongBinaryOperator
Function
接口有9种变种接口用在返回类型是基本数据类型的时候:
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>
Consumer
3种变种(2个参数,一个对象,一个基本数据类型):
ObjDoubleConsumer
ObjIntConsumer
ObjLongConsumer
最后是 BooleanSupplier
,返回boolean值 Supplier
的一个变种,但是返回boolean在 Predicate
已经得到了支持
上面大部分的函数接口都是对基本数据类型提供的支持,不要使用他们的时候使用包装类( Integer
等),这样可能会造成严重的性能问题。
那么什么时候需要自己定义函数接口而不是使用java内置的函数接口?
Comparator Comparator
一旦自己定义函数接口就需要使用 FunctionalInterface
注解,原因:
最后一点就是不要在提供多个重载方式的时候在同一个参数位置使用不同的函数接口,这样可能会造成歧义。
比如 ExecutorService
的 submit
方法,同时支持 Callable
和 Runnable
作为参数,这样调用的时候就有可能需要参数类型转换才行。
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方法)也能实现上面的功能。
使用代码块的优势:
final
变量,不能修改局部变量; break
, continue
来终止,lambda表达式任何一点都无法做到; 流操作非常适合以下的场景:
流还有一个不足的地方是不好处理流操作每一步的参数,比如中间操作a->b->c a中的参数想要在c中使用,那只能交换bc操作的顺序或者用一个变量去存储,但这样就违背了stream的最主要目的(代码简洁)。
同时流与普通迭代循环的使用是根据实际情况来选择的,普通迭代代码容易懂,流代码简洁但是需要懂流的操作才能明白。
流并不仅仅是一个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。
选择适当的返回类型,集合或者是流
在写代码的时候需要考虑是返回元素的队列还是返回集合或者是迭代器,你需要考虑你的用户是用返回值来做迭代还是继续的做流的一些操作,最好的就是都提供。
好在java8的 Collection
已经在接口中添加了流的支持。如果你的返回值够小的话最好直接就返回一个集合的实现,比如 ArrayList
。
先看下面并行获取前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
原因:
同时流的最终操作的特性也会影响并行流的性能,如果最终操作中进行了大量的计算,那么并行也不会提升太大的性能。
最好的情况就是在最终操作中做减法,比如 min
, max
, count
, sum
这些操作,但是像 collect
这种操作就不适合并行的操作,因为太耗时了。
通常正常情况下通过并行能够提升的性能和你的处理器核数成线性关系的。
总的来说别轻易的使用并行流,除非经过了严格测试,并且确实有性能提升才使用。
如果需要编写自己的流,迭代器或者集合实现,如果你需要并行的操作,
那么一定要覆盖 spliterator
方法,并且经过测试。
这章其实就是第三版Effective Java的核心更新部分了,主要讲的就是Java8的新特性:lambda表达式和流的使用以及需要注意的点。
就我个人实际开发情况来说,工作中使用lambda表达式的时候不是特别多,因为复杂的循环基本不会使用lambda表达式,而是使用 forEach
循环或者是使用迭代器。
至于流的使用其实还是需要一定的学习成本的,加入的东西很多,想要完整的掌握还是需要花点时间的。特别是并行流的使用,没有完全掌握流的话还是不要使用为好,普通流的性能大部分场景下也足够了。
BugHome版权所有丨转载请注明出处:https://minei.me/archives/491.html