《Effective Java》——关于Lambda和Stream

和《Java8实战相比》,很多东西都是重复的。写这篇笔记,一方面是作为回顾总结,另一方面,很多东西这本书换了一个角度去看,对于思考的完整性,依然不可或缺。

哲学上的白马非马其实就是所谓的副作用

其实上一篇的《Java8实战》学习笔记二已经把有些思想上的东西说的很多了,在这就是突然想到一些东西记录一些。
《信息简史》这本书里面说过一个意思“有逻辑的东西一定会有悖论”,程序逻辑一般三种(顺序、条件、循环)。大一开始学程序的时候,就感觉很别扭,想让a等于a+1,自己会先让b=a+1,再a=b,现在想想其实就是心里并没有意识到程序世界里的白马非马问题。a=a+1,一道错误的数学等式,却在程序里司空见惯。命令式编程可能对待它就是个sum指令,但是看成函数来说完全是错误的,a经过了加1映射以后就不可能是等于原来的a了,违反了引用的透明性,有副作用。

面向对象(命令式编程的代表)把编程世界世界看成一个个独立的个体(a,b,c....),随着时间的推移(命令的执行),一个个个体的状态在与之发生变化;而函数式编程是把世界规则化,他的眼里没有什么个体,就是那一整个世界(所有都是数据),随着规则的每次进行(一次函数映射),整个世界都会发生一次变化。

42条:Lambda优先于匿名类

Java里的函数一般理解为带有单个抽象方法的接口,具体的函数对象指的是他们的实例,表示函数中的映射或者要采取的动作。JDK1.1开始,创建函数对象的主要方式是通过匿名类,在使用的时候同时被声明和实例化。Java6以前,匿名类是动态的创建小型对象和过程对象的最佳范式,匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式。

// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

比较器接口表示排序的抽象策略,上面的匿名类是排序字符串的具体策略。
但是匿名类的烦琐使得java进行函数编程的前景很暗淡,于是java8中的Lambda应运而出。

Java8理念更新——“用单个抽象方法的接口是特别的,应该得到特别的对待”。

这些接口现在被称作函数接口,使用lambda表达可以直接创建这些接口的实例。Lambda在功能上与匿名类相似,但更为简洁。下面的代码使用lambdas替换上面的匿名类。样板不见了,行为也十分明确:

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

Lambda的类型(Comparator),其参数类型(s1和s2,两个都是String)及其返回值的类型(int),都没有出现在代码中。编译器使用称为类型推导(type inference)的过程从上下文中推导出这些类型。
类型推导的过程很复杂,但对Lambda来说,尽可能的去删除,除非编辑器报错。
关于类型推导应该增加一条警告。第26条告诉你不要使用原生态类型(List),第29条说过优先考虑泛型(Stack),第30条说过要优先考虑泛型方法(public static Set union(Set s1, Set s2))。因为编译器能从泛型中获得了大部分进行类型推断的类型信息。如果没有在泛型中提到这些信息,就很难进行推导,必须要在Lambda表达式中明确这些信息,依然很烦琐。比如上面的words如果是List,而不是List,就不会编译。

利用一些静态方法使用方法引用的方式以及List实例自带的接口,还可以更加简单。

Collections.sort(words, Comparator.comparingInt(String::length));
words.sort(Comparator.comparingInt(String::length));

Lambda没有名称和文档; 如果计算不是自描述的,或者超过几行,则不要将其放入lambda表达式中。一行代码对于lambda说是理想的,三行代码是合理的最大值。如果违反这一规定,可能会严重损害程序的可读性。

Lambda时代,匿名类的用武之地在于:
1.多个抽象方法就不是函数接口,需要用匿名类
2.Lambda不能获得自身的引用,this指的是外围实例,匿名类中this是当前匿名类实例。

此外,尽可能不要去序列化Lambda,因为其和匿名类共享属性,你无法可靠地通过实现来序列化和反序列化这些属性。如果非要序列化,使用私有的静态成员类。

总之,Java8开始,Lambda成了表示小函数对象的最佳方式。千万不要给函数对象使用匿名类,除非必须创建非函数接口的类型的实例。同时Lambda使得小函数对象变得如此轻松,因此打开了之前从未实践过的在java中进行函数编程的大门。

43条:方法引用优先Lambda

简洁程度:方法引用>lambda>匿名类
可读性和可维护性:lambda>方法引用
方法参数越多,方法引用对比lambda就越简洁,但是简洁不利于代码的维护,比如方法参数是可以写注释文档的。
只要方法引用能做的事情,Lambda基本都能做,除了下面这种情况——函数类型和泛型的结合问题。

Java不存在泛型lambda表达式,所以当泛型信息必须指定时,就只能用法方法引用,不能用lambda。

使用IDE编程,就尽可能的使用方法引用代替lambda,因为两者很容易切换。

lambda比方法引用更简洁的几种情况

  1. 同一个类中的方法,比如类名很长 XXXXXXXXXXXXX::action 就没有()->action()来的简洁
  2. Lambda表达式本身就很简单,比如x->x比Function的identity方法来的方便

常见的五种方法引用情况

方法引用类型 方法引用 lambda等式
静态方法 Integer:parseInt str -> Integer.parseInt(str)
有限制的实例方法 Instant.now()::isAfter Instant then = Instant.now(); t->then.isAfter(t)
无限制的实例方法 String::toLowerCase str->str.toLowerCase()
类构造器 TreeMap::new ()->new TreeMap
数组构造器 int[]::new len->new int[len]
  1. 最常见的是静态方法;
  2. 有限制的实例方法,具体的接收对象then必须在方法引用时才能确定,比如上面的当前时间;
  3. 无限制的实例方法,具体的接受对象可以直接通过方法额外添加参数str去实现,此种情况通常被用在Stream管道中;
  4. 类构造器和数组构造器都是利用new关键字,充当工厂对象。

总之,方法引用常常比Lambda表达式更加简洁。只要方法引用更加简洁,清晰,就用方法引用。如果方法引用并不简洁,就坚持使用Lambda。

44条:坚持使用标准的函数接口

Lambda表达式的出现,带动了Java基础API的改变。
很多时候我们当然可以去编写函数接口,把那个抽象方法给实现,但其实没有必要声明一个新的接口,java.util.function有很多约定俗成的接口,只需根据定义去实现这些约定俗成的接口就好,这样API会更加容易学习,减少了很多概念内容的学习,显著提升了互操作性优势,而且这些接口都很贴心,会提供给你一些很有用的默认方法。
java.util.function包提供了大量标准的函数接口。只要标准的函数接口能够满足需求,应该优先考虑,而不是专门再构建一个新的函数接口。

@FunctionalInterface注解,在类型类似于@Override。
这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现lambda表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。

始终使用@FunctionalInterface注解标注你的函数式接口。

45条:谨慎使用Stream

Java8增加了Stream API,简化了串行或并行的大批量操作。这个API提供2个关键抽象:Stream(流)代表数据元素有限或者无限的顺序,Stream pipeline(流管道)代表这些元素的一个多级计算。

流中的数据元素可以是对象引用或基本类型。支持三种基本类型:int,long和double的三种特化流。

流管道由源流(source stream)的零或多个中间操作和一个终结操作组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。

管道延迟(lazily)计算求值:计算直到终结操作被调用后才开始,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种延迟计算求值的方式使得可以使用无限流。

Stream API流式的(fluent):它设计允许所有组成管道的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。

过度使用流使程序难于阅读和维护。

总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。将这两种方法结合起来,可以最好地完成许多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种方法将是清楚的;在某些情况下,则不会很清楚。如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两种方法,看看哪一种效果更好。

46条:优先选择Stream中无副作用的函数

Stream不只是一个API,它是一种基于函数编程的模型。为了获得Stream带来的描述性和速度,有时还有并行性,必须采用泛型以及API。

Stream范型最重要的部分是把计算构成一系列变型,每一级结构都尽可能靠近上一级结果的纯函数(pure function)。纯函数是指其结果只取决于输入的函数:它不依赖任何可变的状态,也不更新任何状态。

forEach操作是终止操作中最没有威力的,也是对Stream最不友好的。它是显示迭代,并不适合并行。

改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。Collectors的API令人生畏:它有39个方法,其中一些方法有多达5个类型参数。好消息是,你可以从这个API中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略( reduction strategy)的不透明对象。在此上下文中,reduction意味着将流的元素组合为单个对象。 收集器生成的对象通常是一个集合(它代表名称收集器)。

将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器:toList()、toSet()和toCollection(collectionFactory)。它们分别返回集合、列表和程序员指定的集合类型。

总之,编写Stream pipeline的本质是无副作用的函数对象。这适用于传入Stream以及相关对象的所有函数对象。终止操作中的forEach应该只用来报告由Stream执行的计算结果,而不是让它执行计算。为了正确地使用Stream,必须了解收集器。最重要的收集器工厂是toList, toSet, toMap, groupingBy和joining。

47条:Stream要优先用Collection作为返回类型

许多方法返回元素序列(sequence)。在Java 8之前,通常方法的返回类型是Collection,Set和List这些接口;还包括Iterable和数组类型。如果返回的元素是基本类型或有严格的性能要求,则使用数组。在Java 8中,将流(Stream)添加到平台中,这使得为序列返回方法选择适当的返回类型的任务变得非常复杂。

或许你曾听说过,现在Stream是返回元素序列最明智的选择了,但如第45条所述,Stream并没有淘汰抛弃掉迭代:要编写出优秀的代码必须巧妙地将Stream与迭代结合起来使用。如果一个API只返回一个流,并且一些用户想用for-each循环遍历返回的序列,那么这些用户肯定会感到不安。

因为Stream接口在Iterable接口中包含唯一的抽象方法,Stream的方法规范与Iterable兼容。阻止程序员使用for-each循环在流上迭代的唯一原因是Stream无法继承Iterable

总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将它们作为流处理,而其他用户可能希望迭代方式来处理它们。 尽量适应两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,比如ArrayList。 否则,请考虑实现自定义集合,就像我们为幂集程序里所做的那样。

48条:谨慎使用Stream并行

在Steam上通过并行获得的性能,最好是通过ArrayList, HashMap, HashSet和ConcurrentHashMap实例,数组,int范围和long范围等。这些数据结构的共同之处在于,它们都可以精确而廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。

并行Stream不仅可能降低性能,包括活性失败,还可能导致结果出错,以及难以预计的行为(如安全失败)。使用映射器(mappers),过滤器(filters)和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全故障。例如,传到Stream的reduce操作的收集器函数和组合函数,必须是有关联、互不干扰、并且是无状态的。

切记:并行Stream是一项严格的性能优化。对于任何优化都必须在改变前后对性能进行测试,以确保值得这么做。一般来说,程序中所有并行Stream pipeline都是在一个通用的fork-join池中运行的。只要有一个pipeline运行异常,都会损害到系统中其他不相关部分的性能。

在适当条件下,给Stream pipeline添加parallel调用,确实可以在多核处理器下实现近乎线性的倍增。某些领域例如机器学习和数据处理。
总之,尽量不用并行Stream pipeline,除非有足够的理由相信它能保证计算的正确性,并且能够加快程序的运行速度。

发表评论

电子邮件地址不会被公开。