Java 8
提供了一个新的API
(称为“流”,Stream
),它支持许多处理数据的并行操作,其思路和在数据库查询语言中的思路类似——用更高级的方式表达想要的东西,而由“实现”(在这里是Streams
库)来选择最佳低级执行机制。这样就可以避免用synchronized
编写代码,这一代码不仅容易出错,而且在多核CPU
上执行所需的成本也比你想象的要高。
在Java 8
中加入Streams
可以看作把另外两项扩充加入Java 8
的直接原因:把代码传递给方法的简洁方式(方法引用、Lambda
)和接口中的默认方法。如果仅仅“把代码传递给方法”看作Streams
的一个结果,那就低估了它在Java 8
中的应用范围。它提供了一种新的方式,这种方式简洁地表达了行为参数化。比方说,你想要写两个只有几行代码不同的方法,那现在你只需要把不同的那部分代码作为参数传递进去就可以了。采用这种编程技巧,代码会更短、更清晰,也比常用的复制粘贴更不容易出错。
Java 8
里面将代码传递给方法的功能(同时也能够返回代码并将其包含在数据结构中)还让我们能够使用一整套新技巧,通常称为函数式编程。一言以蔽之,这种被函数式编程界称为函数的代码,可以被来回传递并加以组合,以产生强大的编程语汇。
流处理
流是一系列数据项,一次只生成一项。程序可以从输入流中一个一个读取数据项,然后以同样的方式将数据项写入输出流。一个程序的输出流很可能是另一个程序的输入流。一个实际的例子是在Unix
或Linux
中,很多程序都从标准输入(Unix
和C
中的stdin
,Java
中的System.in
)读取数据,然后把结果写入标准输出(Unix
和C
中的stdout
,Java
中的System.out
)。
首先我们来看一点点背景:Unix
的cat
命令会把两个文件连接起来创建一个流,tr
会转换流中的字符,sort
会对流中的行进行排序,而tail-3
则给出流的最后三行。Unix
命令行允许这些程序通过管道(|
)连接在一起,比如
cat file1 file2 | tr "[A- Z]" "[a- z]" | sort | tail -3
会(假设file1
和file2
中每行都只有一个词)先把字母转换成小写字母,然后打印出按照词典排序出现在最后的三个单词。我们说sort
把一个行流(字符流)作为输入,产生了另一个行流(进行排序)作为输出,如下图所示。
请注意在Unix
中,命令(cat
、tr
、sort
和tail
)是同时执行的,这样sort
就可以在cat
或tr
完成前先处理头几行。就像汽车组装流水线一样,汽车排队进入加工站,每个加工站会接收、修改汽车,然后将之传递给下一站做进一步的处理。尽管流水线实际上是一个序列,但不同加工站的运行一般是并行的。
基于这一思想,Java 8
在java.util.stream
中添加了一个Stream API
;Stream<T>
就是一系列T
类型的项目。你现在可以把它看成一种比较花哨的迭代器。Stream API
的很多方法可以链接起来形成一个复杂的流水线,就像先前例子里面链接起来的Unix
命令一样。
推动这种做法的关键在于,现在你可以在一个更高的抽象层次上写Java 8
程序了:思路变成了把这样的流变成那样的流(就像写数据库查询语句时的那种思路),而不是一次只处理一个项目。另一个好处是,Java 8
可以透明地把输入的不相关部分拿到几个CPU
内核上去分别执行你的Stream
操作流水线——这是几乎免费的并行,用不着去费劲搞Thread
了。
用行为参数化把代码传递给方法
Java 8
中增加的另一个编程概念是通过API
来传递代码的能力。
Java 8
增加了把方法(你的代码)作为参数传递给另一个方法的能力。下图(将compareUsingCustomerId
方法作为参数传给sort
)描绘了这种思路。我们把这一概念称为行为参数化。
Stream API
就是构建在通过传递代码使操作行为实现参数化的思想上的,当把compareUsingCustomerId
传进去,你就把sort
的行为参数化了。
其他改变让普通的东西更容易表达,比如,使用for-each
循环而不用暴露Iterator
里面的套路写法。Java 8
中的主要变化反映了它开始远离常侧重改变现有值的经典面向对象思想,而向函数式编程领域转变,在大面上考虑做什么(例如,创建一个值代表所有从A
到B
低于给定价格的交通线路)被认为是头等大事,并和如何实现(例如,扫描一个数据结构并修改某些元素)区分开来。
Java中的函数
Java 8
中新增了函数——值的一种新形式。它有助于使用流,有了它,Java 8
可以进行多核处理器上的并行编程。
这里介绍的Java 8
的第一个新功能是方法引用。比方说,你想要筛选一个目录中的所有隐藏文件。你需要编写一个方法,然后给它一个File
,它就会告诉你文件是不是隐藏的。幸好,File
类里面有一个叫作isHidden
的方法。我们可以把它看作一个函数,接受一个File
,返回一个布尔值。但要用它做筛选,你需要把它包在一个FileFilter
对象里,然后传递给File.listFiles
方法,如下所示:
File[] hiddenFiles = new File(".").listFiles( new FileFilter() {
public boolean accept( File file) {
return file.isHidden(); //← ─ 筛选 隐藏 文件
}
});
如今在Java 8
里,你可以把代码重写成这个样子:
File[] hiddenFiles = new File(".").listFiles( File:: isHidden);
你已经有了函数isHidden
,因此只需用Java 8
的方法引用: :
语法(即“把这个方法作为值”)将其传给listFiles
方法;这里也开始用函数代表方法了。一个好处是,你的代码现在读起来更接近问题的陈述了。方法不再是二等值了。与用对象引用传递对象类似(对象引用是用new
创建的),在Java 8
里写下File: : isHidden
的时候,你就创建了一个方法引用,你同样可以传递它。
除了允许(命名)函数成为一等值外,Java 8
还体现了更广义的将函数作为值的思想,包括Lambda
(或匿名函数)。比如,你现在可以写(int x) -> x+1
,表示“调用时给定参数x
,就返回x+1
值的函数”。
我们说使用这些概念的程序为函数式编程风格,这句话的意思是“编写把函数作为一等值来传递的程序”。
假设你有一个Apple
类,它有一个getColor
方法,还有一个变量inventory
保存着一个Apples
的列表。你可能想要选出所有的绿苹果,并返回一个列表。通常我们用筛选(filter
)一词来表达这个概念。在Java 8
之前,你可能会写这样一个方法filterGreenApples
:
public static List< Apple> filterGreenApples( List< Apple> inventory){
List< Apple> result = new ArrayList<>(); //←─result是用来累积结果的List,开始为空,然后一个个加入绿苹果
for (Apple apple: inventory){
if ("green".equals(apple.getColor())) { //←─高亮显示的代码会仅仅选出绿苹果
result.add(apple);
}
}
return result;
}
但是接下来,有人可能想要选出重的苹果,比如超过150
克,于是你心情沉重地写了下面这个方法,甚至用了复制粘贴:
public static List< Apple> filterHeavyApples( List< Apple> inventory){
List< Apple> result = new ArrayList<>();
for (Apple apple:inventory){
if (apple.getWeight()>150) { //←─高亮显示的代码会仅仅选出绿苹果
result.add(apple);
}
}
return result;
}
我们都知道软件工程中复制粘贴的危险——给一个做了更新和修正,却忘了另一个。这两个方法只有一行不同:if
里面高亮的那行条件。如果这两个高亮的方法之间的差异仅仅是接受的重量范围不同,那么你只要把接受的重量上下限作为参数传递给filter
就行了,比如指定(150,1000)
来选出重的苹果(超过150
克),或者指定(0,80)
来选出轻的苹果(低于80
克)。但是,Java 8
会把条件代码作为参数传递进去,这样可以避免filter
方法出现重复的代码。现在你可以写:
public static boolean isGreenApple( Apple apple) {
return "green".equals( apple.getColor());
}
public static boolean isHeavyApple( Apple apple) {
return apple.getWeight() > 150;
}
public interface Predicate< T>{ //←─写出来是为了清晰(平常只要从java.util.function导入就可以了)
boolean test( T t);
}
static List< Apple> filterApples( List< Apple> inventory, Predicate< Apple> p) { //←─方法作为Predicate参数p传递进去
List< Apple> result = new ArrayList<>();
for (Apple apple:inventory){
if (p.test(apple)) { //←─苹果符合p所代表的条件吗
result.add(apple);
}
}
return result;
}
要用它的话,你可以写:
filterApples( inventory, Apple:: isGreenApple);
或者
filterApples( inventory, Apple:: isHeavyApple);
现在你就可以在Java 8
里面传递方法了。
上述代码传递了方法Apple:: isGreenApple
(它接受参数Apple
并返回一个boolean
)给filterApples
,后者则希望接受一个Predicate<Apple>
参数。谓词(predicate
)在数学上常常用来代表一个类似函数的东西,它接受一个参数值,并返回true
或false
。
Java 8
也会允许你写Function<Apple,Boolean>
,但用Predicate<Apple>
是更标准的方式,效率也会更高一点儿,这避免了把boolean
封装在Boolean
里面。
把方法作为值来传递显然很有用,但要是为类似于isHeavyApple
和isGreenApple
这种可能只用一两次的短方法写一堆定义有点儿烦人。不过Java 8
也解决了这个问题,它引入了一套新记法(匿名函数或Lambda
),让你可以写
filterApples(inventory,(Apple a)->"green".equals(a.getColor()));
或者
filterApples(inventory,(Apple a)->a.getWeight()>150);
甚至
filterApples(inventory,(Apple a)->a.getWeight()<80||"brown".equals(a.getColor()));
所以,你甚至都不需要为只用一次的方法写定义;代码更干净、更清晰,因为你用不着去找自己到底传递了什么代码。但要是Lambda
的长度多于几行(它的行为也不是一目了然)的话,那你还是应该用方法引用来指向一个有描述性名称的方法,而不是使用匿名的Lambda
。你应该以代码的清晰度为准绳。
几乎每个Java
应用都会制造和处理集合。但集合用起来并不总是那么理想。比方说,你需要从一个列表中筛选金额较高的交易,然后按货币分组。你需要写一大堆套路化的代码来实现这个数据处理命令,并且很难一眼看出来这些代码时做什么的,因为有好几个嵌套的控制流指令。
有了Stream API
,你现在可以这样解决这个问题了:
import static java.util.stream.Collectors.toList;
Map<Currency,List<Transaction>>transactionsByCurrencies=
transactions.stream()
.filter((Transactiont)->t.getPrice()>1000) //←─筛选金额较高的交易
.collect(groupingBy(Transaction::getCurrency)); //←─按货币分组
和Collection API
相比,Stream API
处理数据的方式非常不同。
用集合的话,你得自己去做迭代的过程。你得用for-each
循环一个个去迭代元素,然后再处理元素。我们把这种数据迭代的方法称为外部迭代。
相反,有了Stream API
,你根本用不着操心循环的事情。数据处理完全是在库内部进行的。我们把这种思想叫作内部迭代。
使用集合的另一个头疼的地方是,想想看,要是你的交易量非常庞大,你要怎么处理这个巨大的列表呢?单个CPU
根本搞不定这么大量的数据,但你很可能已经有了一台多核电脑。理想的情况下,你可能想让这些CPU
内核共同分担处理工作,以缩短处理时间。理论上来说,要是你有八个核,那并行起来,处理数据的速度应该是单核的八倍。
通过多线程代码来利用并行(使用先前Java
版本中的Thread API
)并非易事。线程可能会同时访问并更新共享变量。因此,如果没有协调好,数据可能会被意外改变。相比一步步执行的顺序模型,这个模型不太好理解。比如,下图就展示了如果没有同步好,两个线程同时向共享变量sum
加上一个数时,可能出现的问题,结果是105
,而不是预想的108
。
Java 8
也用Stream API
(java.util.stream
)解决了这两个问题:集合处理时的套路和晦涩,以及难以利用多核。
这样设计的第一个原因是,有许多反复出现的数据处理模式,类似于前一节所说的filterApples
或SQL
等数据库查询语言里熟悉的操作,如果在库中有这些就会很方便:根据标准筛选数据(比如较重的苹果),提取数据(例如抽取列表中每个苹果的重量字段),或给数据分组(例如,将一个数字列表分组,奇数和偶数分别列表)等。
第二个原因是,这类操作常常可以并行化。例如,如下图所示,在两个CPU
上筛选列表,可以让一个CPU
处理列表的前一半,第二个CPU
处理后一半,这称为分支步骤。CPU随后对各自的半个列表做筛选。最后,一个CPU
会把两个结果合并起来(Google
搜索这么快就与此紧密相关,当然他们用的CPU
远远不止两个了)。
新的Stream API
和Java
现有的Collection API
的行为差不多:它们都能够访问数据项目的序列。
不过,现在最好记得,Collection
主要是为了存储和访问数据,而Stream
则主要用于描述对数据的计算。这里的关键点在于,Stream
允许并提倡并行处理一个Stream
中的元素。筛选一个Collection
(将前面的filterApples
应用在一个List
上)的最快方法常常是将其转换为Stream
,进行并行处理,然后再转换回List
。
下面演示一下如何利用Stream
和Lambda
表达式顺序或并行地从一个列表里筛选比较重的苹果。
- 顺序处理:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples=
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
- 并行处理:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples=
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
默认方法
Java 8
中加入默认方法主要是为了支持库设计师,让他们能够写出更容易改进的接口。
有很多的替代集合框架都用Collection API
实现了接口,但给接口加入一个新方法,意味着所有的实体类都必须为其提供一个实现。语言设计者没法控制Collections
所有现有的实现,这下就进退两难了,如何改变已发布的接口而不破坏已有的实现?
Java 8
的解决方法就是打破最后一环——接口如今可以包含实现类没有提供实现的方法签名了!那谁来实现它呢?缺失的方法主体随接口提供了(因此就有了默认实现),而不是由实现类提供。
这就给接口设计者提供了一个扩充接口的方式,而不会破坏现有的代码。Java 8
在接口声明中使用新的default
关键字来表示这一点。
例如,在Java 8
里,你现在可以直接对List
调用sort
方法。它是用Java 8
List
接口中如下所示的默认方法实现的,它会调用Collections.sort
静态方法:
default void sort(Comparator<? super E> c){
Collections.sort(this,c);
}
这意味着List
的任何实体类都不需要显式实现sort
,而在以前的Java
版本中,除非提供了sort
的实现,否则这些实体类在重新编译时都会失败。
那么,一个类可以实现多个接口了?如果在好几个接口里有多个默认实现,是否意味着Java
中有了某种形式的多重继承?是的,在某种程度上是这样。
来自函数式编程的好思想
- 将方法和
Lambda
作为一等值(核心思想) - 在没有可变共享状态时,函数或方法可以有效、安全地并行执行(核心思想)
- 通过使用更多的描述性数据类型来避免
null
。在Java 8
里有一个Optional<T>
类,如果你能一致地使用它的话,就可以帮助你避免出现NullPointer
异常。它是一个容器对象,可以包含,也可以不包含一个值。Optional<T>
中有方法来明确处理值不存在的情况,这样就可以避免NullPointer
异常了。换句话说,它使用类型系统,允许你表明我们知道一个变量可能会没有值。 - (结构)模式匹配。函数是分情况定义的,而不是使用
if-then-else
。在Java
中,你可以在这里写一个if-then-else
语句或一个switch
语句。其他语言表明,对于更复杂的数据类型,模式匹配可以比if-then-else
更简明地表达编程思想。对于这种数据类型,你也可以使用多态和方法重载来替代if-then-else
,但对于哪种方式更合适,就语言设计而言仍有一些争论。两者都是有用的工具,都应该掌握。不幸的是,Java 8
对模式匹配的支持并不完全。可以把模式匹配看作switch
的扩展形式,可以同时将一个数据类型分解成元素。Java
中的switch
语句限于原始类型和Strings
,函数式语言倾向于允许switch
用在更多的数据类型上,包括允许模式匹配。在面向对象设计中,常用的访客模式可以用来遍历一组类,并对每个访问的对象执行操作。模式匹配的一个优点是编译器可以报告常见错误,如“Brakers
类属于用来表示Car
类的组件的一族类,你忘记了要显式处理它。”