21.1 回顾Java 8的语言特性
Java 8是一种实践性强、实用性好的语言,想要很好地理解它,方法之一是重温它的各种特性。本章不会简单地罗列Java 8的各种特性,而是会将这些特性串接起来,希望大家不仅能理解这些新特性,还能从语言设计的层面理解Java 8语言设计的连贯性。作为回顾内容,本章的另一个目标是阐释Java 8的这些新特性是如何促进Java函数式编程风格的发展的。请记住,这些新特性并非语言设计上的突发奇想,而是一种深思熟虑的设计,它着眼于软件发展的两种趋势,即第1章中提到的“模型中的气候变迁”。
- 对多核处理器处理能力的需求日益增长。虽然硅开发技术也在不断进步,但是依据摩尔定律每年倍增的晶体管数量已经到达一个极限,已经无法简单地通过增加单位面积集成的晶体管数量提升CPU核的计算速度了。简单来说,想让你的代码运行得更快,你的代码必须具备并行运算的能力。
- 以声明方式处理数据集合,简洁高效,正获得越来越多程序员的青睐。比如,创建数据源,找出符合约定条件的所有数据,对结果执行相关的操作(求和,或者生成新集合以便执行进一步处理)。要采用这种风格,你得使用不可变对象或集合,再利用它们进一步生成新的不可变数据。
然而,无论是传统编程、面向对象编程、还是命令式的编程,都无法很好地满足这两个诉求,因为它们都是通过迭代器访问和修改字段的。在CPU的一个核上修改数据,在另一个核上读取该数据的值,这种方式的开销非常大,此外,你还需要考虑容易出错的锁。同样,当你的思考局限于通过迭代访问和修改现存对象时,“类流”(stream-like)式编程看起来就非常地异类。不过,函数式编程能非常轻松地支持这两种新潮流,这也解释了为什么Java 8的重心是对我们已经熟知的Java进行大幅转型。
本章会从统一、宏观的角度回顾本书介绍的内容,并展示它们如何相互配合,创造出一个新的编程世界。
21.1.1 行为参数化(Lambda以及方法引用)
为了编写可重用的方法,比如filter,你需要为它指定一个参数,帮助它精确地描述过滤条件。虽然Java专家们使用老方法也能达到同样的目的(即将过滤条件封装成类的一个方法,传递该类的一个实例),但这种方案很难推广,因为它通常非常臃肿,既难于编写,也不易于维护。
第2章和第3章中介绍过,Java 8借鉴函数式程序设计的思想,通过一种全新的方式,即向方法传递代码片段,解决了这一问题。这种新的方式非常方便,它有两种变体:
- 传递一个Lambda表达式,即一段精简的代码片段,比如:
apple -> apple.getWeight() > 150
- 传递一个方法引用,该方法引用指向了一个现有方法,比如:
Apple::isHeavy
这些值具有类似Function、Predicate或者BiFunction这样的类型,值的接收方可以通过apply、test或其他类似的方法操作这些值。第3章中介绍过,这些类型被称作函数式接口(functional interface),它们都配有单一的抽象方法。Lambda表达式自身已经是一个相当酷炫的概念,Java 8将它们与全新的Stream API结合起来,最终让它们成为了新一代Java的核心。
21.1.2 流
集合类、迭代器,以及for-each结构在Java中由来已久,也为广大程序员所熟知。对Java 8的设计者而言,直接在集合类中添加filter或者map这样的方法,利用前面介绍的Lambda实现数据库查询这类操作要简单得多。然而他们并没有采用这种方式,而是引入了一套全新的Stream API(即第4~7章介绍的内容)——这值得我们深思,为什么他们要这么做呢?
集合到底有什么问题,以至于需要另起炉灶替换它们,或者说要通过一个类似却不同的概念Stream来增强它们。我们以接下来这个例子概略说明二者之间的差异:如果你有一个数据量庞大的集合,你需要对这个集合执行三个操作,比如对这个集合中的对象进行映射,计算其中的两个字段的和,这之后依据某种条件过滤出满足条件的和,最后对计算的结果进行排序,为得到结果你需要分三次遍历集合。与之相反,Stream API采用延迟算法将这些操作组成一个流水线,只通过单次流遍历,就可以一次性完成所有的操作。对大型数据集来说,这种操作方式高效很多。此外,还有一些别的因素,比如内存缓存的使用。数据集越大,减少遍历数据集的次数就越重要。
还有一些因素的影响也不容小视,比如元素的并发处理,这对高效利用多核处理器的能力至关重要。Stream,尤其是它的parallel方法能将一个Stream标记为适合并行处理。你一定还记得,并行处理与对象的可变状态是水火不容的,所以函数式的核心概念(比如第4章中介绍的无副作用的操作,通过Lambda表达式进行方法参数化,以及使用内部迭代替换外部迭代的方法引用)是围绕着如何充分发挥Stream的并发处理能力去执行 map、filter或者其他的方法。
现在,让我们看看这些思想(介绍Stream时使用过这些术语)怎样直接影响了CompletableFuture类的设计。
21.1.3 CompletableFuture
Java从版本5就提供了Future接口。Future可以帮助大家充分利用多核CPU的处理能力,因为它允许一个任务在新的核上生成新的子线程,新生成的任务可以和原来的任务同时运行。原任务需要结果时,可以通过get方法等待Future运行结束(获得其计算的结果值)。
第16章介绍了Java 8中Future的CompletableFuture实现。它再次利用了Lambda表达式。一个非常形象,不过不那么精确的说法是:“CompletableFuture对于Future的意义就像Stream之于Collection。”让我们比较一下这二者。
- 通过
Stream你可以用流水线串接一系列的操作,使用map、filter或者其他类似的方法进行“行为参数化”,它可有效避免采用迭代器时总是出现模板代码。 - 同样的,
CompletableFuture提供了thenCompose、thenCombine和allOf这样的操作,其能以函数式程序设计的方式对Future的通用模式进行细粒度的控制,帮助你避免采用命令式编程时常见的模板代码。
这种类型的操作,虽然大多数只能用于非常简单的场景,不过仍然适用于Java 8的Optional操作,我们一起来回顾下这部分内容。
21.1.4 Optional
Java 8的库提供了Optional类,这个类允许你在代码中指定哪一个变量的值既可能是类型T的值,也可能是由静态方法Optional.empty表示的缺失值。无论对理解程序逻辑,抑或是对编写产品文档而言,这都是一个重大的好消息,你现在可以使用一种数据类型表示显式缺失的值——使用空指针的问题在于你无法确切了解出现空指针的原因,它是预期的情况,还是由于之前某一次计算出错导致的一个偶然性的空值,有了Optional之后你就不需要再使用之前容易出错的空指针来表示缺失的值了。
正如第11章中所讨论的,如果在程序中始终如一地使用Optional,你的应用应该永远不会发生NullPointerException异常。你可以将这看成另一个绝无仅有的特性,它和Java 8中其他部分都不直接相关,问自己一个问题:“为什么用一种表示值缺失的形式替换另一种能帮助我们更好地编写程序?”进一步审视,我们发现Optional类提供了map、filter和ifPresent方法。这些方法和Stream类中的对应方法有着相似的行为,它们都能以函数式的结构串接计算,由于库自身提供了缺失值的检测机制,不再需要用户代码的干预。这种进行内部检测还是外部检测的选择,与在Stream库中进行内部迭代,还是在用户代码中进行外部迭代的选择极其类似。Java 9向Optional API中添加了各种新方法,包括stream()、or()和ifPresentOrElse()。
21.1.5 Flow API
Java 9对反应式流进行了标准化,基于拉模式的反应式背压协议能避免慢速消费者被一个或多个快速生产者压垮。Flow API包含四个核心接口,实现了这些接口的第三方反应式库能提供更好的兼容性支持,这四个接口分别是:Publisher、Subscriber、Subscription和Processor。
本节最终的话题中,我们关注的不是函数式编程,而是Java 8对后向兼容库的扩展支持,这是由软件工程需求所驱动的。
21.1.6 默认方法
Java 8中增加了不少新特性,但它们一般都不会对程序的表现形式带来太大的影响。然而,也有一个例外,那就是新增的默认方法。接口中新引入默认方法对类库的设计者而言简直是如鱼得水。Java 8之前,接口主要用于定义方法签名,现在它们还能为接口的使用者提供方法的默认实现,如果接口的设计者认为接口中的某个方法并不需要每一个接口用户都显式地为其提供实现,他就可以在接口的方法声明中将其定义为默认方法。
对类库的设计者而言,这是个伟大的新工具,原因很简单,它提供的能力可以帮助类库的设计者定义新的操作,增强接口的能力,类库的用户(即那些实现该接口的程序员们)不需要花费额外的精力重新实现该方法。因此,默认方法与库的用户也有关系,它屏蔽了将来的变化对用户的影响。第13章针对这一问题进行了深入的探讨。
