文章目录
1.前言
Java 8自2014年3月发布以来,受到广泛关注,Java 8中添加新功能使编程更加简单。例如对苹果库存根据苹果重量排序,Java8之前代码可能如下:
Collections.sort(inventory, new Comparator<Apple>() {
@Override public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
而在Java8中可以编写为更加简洁的代码,这种代码更接近问题的描述,如下代码读起来就是“给库存排序,比较苹果的重量”。
inventory.sort(comparing(Apple::getWeight));
另外一点是Java对硬件的影响,现如今大多数电脑为多核处理器,而大多数Java程序可能都是仅使用了电脑的一个内核(CPU),如何充分利用其它闲置CPU呢?常用方式是使用并发编程。从Java1.0引入线程和锁,以及内存模式到Java5的线程池,以及Java7中添加的fork/join
框架。Java一直致力于让并发编程更容易和出错更少,但编写并发代码还是有些困难。在Java8中用更简洁代码有效使用多核处理器。
Java8中有三个比较重要的概念:Stream API、向方法传递代码的技巧、接口中的默认方法。其中Stream API能支持许多处理数据的并行操作,可以将它看做是把代码传递给方法的简洁方式(方法引用
、Lambda
)和接口中的默认方法的直接原因。代码传递给方法实际上表达了行为参数化
。比如有部分代码不同的两个方法,可以重构为一个方法,然后将不同部分作为参数传递。这种编程方式更清晰,不易出错。这种行为参数化的功能通常被称为函数式编程。这种被函数式编程界称为函数的代码,可以被来回传递并加以组合,以产生强大的编程语汇。
2.Java演变
世上没有完美的通用编程语言,某些语言只是更适合某些方面,如C和C++是构建操作系统和各种嵌入式系统的流行工具,用它们编写的程序运行时占用资源少,缺点是程序安全性不佳,可能会导致程序意外崩溃,将安全漏洞暴露给病毒和其他东西。而Java和C#等安全性语言在诸多的运行资源不太紧张的应用中已经取代了C和C++。新的编程语言会代替现有的语言,除非后者演变得快,能更上节奏。
Java从一开始就被设计为面向对象的语言,它有许多有用的库,支持线程,锁和并发编程。实际上Java虚拟机可能比Java语言本身重要,因为对有些应用来说,Java可能会被同样运行在JVM上的竞争对象语言(Scale或者Groovy)取代,而且JVM最新更新(如JDK7中的invokedynamic
字节码)都在帮助这些竞争对手语言在JVM上顺利运行,并与Java交互操作。如今Java也已成功占领了如智能卡,烤面包机,机顶盒到汽车制动系统等嵌入式计算的若干领域。
有些语言适合编写特定的应用,新的硬件和新的编程因素也会影响编程语言的选择,新出现的语言必定要适应现在。现存语言也需要不断演化,才能不被替换。例如现在越来越多大数据集合的处理,希望利用多核计算机或者计算机集群来有效处理,这意味着需要使用并行处理,这在Java以前是不存在的。而Java8提供了更多的编程工具和概念,能以更快,更简洁,更易于维护的方式解决新的或现有的编程问题。
Java版本迭代使得编写代码更容易和易读,例如从引入泛型,在编译时就可以发现错误,而使用for-each循环,就不需要Iterator里面的套路写法。Java8主要变化反映了它开始远离和改变现有的经典面向对象思想,向函数式编程领域改变,它的重点在做什么,而不是如何实现。极端点来看,传统的面向对象编程和函数式编程看起来是冲突的。但它背后理念是获得两种编程思想中最好的东西。总之语言需要不断改进以跟进硬件的更新或者满足开发人员期待,Java语言要保持生存,必须通过增加新功能来改进。而且只有新功能被人使用,变化才有意义。
3 Java8中编程概念
3.1 流处理
第一个概念是流处理,流是一系列的数据项,一次只生成一个项。程序可以从输入流中逐个读取数据项,然后以同样的方式将数据项写入输出流。一个程序的输出流很可能是另一个程序的输入流。一个实际例子是在Unix或者Linux中,很多程序都从标准输入读取数据,然后把结果写入标准输出。如下面Unix命令:
cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
其中cat
命令会把两个文件连接起来创建一个流,tr
会转换流中的字符,sort
会对流中的行进行排序,tail -3
是则是给出流的最后三行,Unix命令允许这些程序通过管道(|)
连接起来。操作流的Unix命令图如下。
值得注意的是在Unix中,命令(cat
、tr
、sort
和tail
)实际上是同时执行的,这样sort
可以在cat
或tr
完成前先处理头几行,也就是说虽然上面流操作是一个序列,但不同操作命令是可以并行。基于这个思想,Java8中java.util.stream
中添加了一个Steam API,它里面很多方法可以链接起来形成一个复杂的流水线。现在可以从更高的抽象层次上写Java8程序了,思路变成了从这样流变成了那样的流,而不是一次只处理一个项目。另外一个好处是Java8可以透明地把输入的不相关部分拿到几个CPU内核上去并行执行Stream操作流,不需要再费劲地编写Thread了。
3.2 用行为参数化把代码传递给方法
Java8中增加的另外一个编程概念是通过API来传递代码的能力,例如,经常听到这样的需求,一个数据集先根据某字段排序,然后再根据其他字段进行排序。这种情况,可以定义一个排序的方法,然后将方法传给sort方法。这在Java8之前,没办法将一个方法传递给另外一个方法,因此实现方式可能是创建一个Comparator匿名函数,然后传递给sort,但这种方式很啰嗦。Java8中增加了把方法作为参数传递给另外一个方法的能力。这种概念称为行为参数化。
3.3 并行
第三个编程概念更晦涩一点,即所谓的"几乎免费的并行"。代码能够同时对不同的输入安全地执行,一般情况下意味着,代码中不能访问共享的可变的数据。这些函数有时被称为"纯函数"或"无状态函数",“无副作用函数”。用Java8中的流实现并行比Java现有的线程API更容易,因此尽管可以通过synchronized
来打破"不能有共享的可变数据"这一股则,但这相当于和整个体系作对,因为它使所有围绕这一规则做出的优化都失去意义了。在多个处理器内核之间使用 synchronized
,其代价往往比预期的要大得多,因为同步迫使代码按照顺序执行,而这与并行处理的宗旨相悖。没有共享的可变数据,将方法或者函数传递给其他方法的能力,即常说的是函数式编程范式的基石。相反,在命令式编程范式中,程序是一系列改变状态的指令。"不能有共享的可变数据"的要求意味方法行为就像一个数学函数,没有可见的副作用。
4.Java中的函数
编程语言中函数一词通常指的是方法,尤其是静态方法,在Java8中谈到的函数跟数学函数一致,是无副作用的。Java中将函数作为值的形式添加,这促使流的使用,进而Java8可以进行多核处理器上的并行编程。Java程序中操作值,首先是原始值(如int类型42),其次值可以是对象(严格来说是对象引用),获得对象的唯一途径是new,通过工厂方法后者库函数实现,对象引用指向的是类的实例。如String类型的"abc"。
因此可以说编程语言的整个目的就是在操作值,按照历史上编程语言的传统,这两个类值可称为一等值,编程语言中的其他概念(如类和方法)许有助于值的结构,但在程序执行期间不能传递,因而是二等值。但在运行时传递方法可以将方法变成一等值,这在编程中非常有用,Java8中引入了此功能。
4.1 方法和Lambda作为一等值。
Java8设计者允许方法作为值,这使得编程更加容易,这也是Java8其他功能的基础。Java8中第一个新功能是方法引用。如筛选一个目录中的所有隐藏文件,Java8之前通常,通常会将一个FileFilter
匿名类对象传递给File.listFiles
方法,如
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
@Override public boolean accept(File file) {
return file.isHidden();
}
});
可以看到File
已经有isHidden()
方法,将此方法包裹在一个FileFilter并实例化显得有点啰嗦,而在Java8中只需要通过方法引用::语法
(意味着"将方法作为值使用")将其传递个listFiles方法即可。
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
现在这种编写代码方式更像是问题的陈述,方法不再是二等值,在Java8中通过File::isHidden
方式就创建了一个方法引用,与用对象引用传递对象类似,同样可以传递方法引用。
4.2 Lambda——匿名函数
除了允许函数称为一等值外,Java8体现了更广义的将函数作为值的思想,包括Lambda(或者匿名函数)。比如现在可以写(int x)->x+1
,表示的是"调用时给定参数x,返回x+1值的函数",你可能想到往MyMathsUtils里面添加add方法,然后写方法引用 MyMathsUtils::add
,这种方式确实可以,但是在没有方便的方法和类可用时,Lambda语法更加简洁。这种方式可以称为函数式编程,“将函数作为一等值来传递的程序”。
假设有一个Apple类,它有一个方法是getColor()方法,还有一个inventory保存Apples的列表,需求是从inventory中选出所有的绿苹果,并返回一个列表。通常我们使用筛选(filter)
一词来表达这个概念。Java8之前通过编写一个filterGreenApples方法:
private static List<Apple> filterGreenApple(List<Apple> inventory) {
//result是用来累积结果的List,开始为空,然后一个个加入绿苹果
List<Apple> result = new ArrayList<>();
for(Apple apple:inventory){
if("Green".equals(apple.getColor())) {
result.add(apple);
}
}
return result;
}
现在如果想要选出重的苹果,比如大于150克,代码可能为这样。
private static List<Apple> filterWeight(List<Apple> inventory){
List<Apple> result = new ArrayList<>();
for(Apple apple:inventory){
if(apple.getWeight()>150){
result.add(apple);
}
}
return result;
}
观察这两个方法,可以发现只有if条件语句不一致,因此可以对上面的代码进行重构,由于Java8中可以将条件代码作为参数传递,为避免方法重复,代码可以编写为:
public static boolean isGreenApple(Apple apple) {
return "Green".equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
return apple.getWeight() > 150;
}
//方法isGreenApple和isHeavyApple可以作为Predicate参数传递。
static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
要使用它的话,可以这样写:
filterApples(inventory, Apple::isGreenApple);
filterApples(inventory, Apple::isHeavyApple);
【谓词】:如前面所见将 Apple::isGreenApple(接收参数Apple,返回值为boolean)传递给了filterApples,方法filterApples接收一个 Predicate 参数,谓词(Predicate)类似于数学上函数,它接收一个参数,返回值为true和false。因此可能考虑用Function<Apple,Boolean>,但这种方式会有一个将boolean装箱为Boolean操作,性能上不如Predicate。
4.3 从传递方法到Lambda
将方法作为值来传递很有用,但编写类似isHeavyApple 和 isGreenApple 这种可能只用一两次的短方法比较繁琐,Java8中引入一个套新的表示法(匿名函数或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长度多于几行(它的行为不是一目了然)的话,还是应该使用方法引用来指向一个描述性名称的方法,而不是使用匿名的Lambda,应该以代码清晰为准绳。
5.流
几乎每个Java应用都会创建和处理集合,但集合使用起来并不总是那么理想,例如,从一个列表中筛选出金额大于1000的交易,然后按照货币类型分组,传统方式需要编写一大堆套路化的代码来实现数据处理命令。如下:
private static Map<String, List<Transaction>> transactionByCurrency(List<Transaction> transactions) {
Map<String, List<Transaction>> transactionByCurrency = new HashMap<>();//建立累积交易分组的Map
for (Transaction transaction : transactions) {//遍历交易的List
if (transaction.getPrice() > 1000) {//筛选
String currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionByCurrency.get(currency);
if (transactionsForCurrency == null) {//如果这个货币的分组Map是空的,那就建立一个
transactionsForCurrency = new ArrayList<>();
transactionByCurrency.put(currency, transactionsForCurrency);
}
//将当前遍历的交易添加到具有同一货币的交易List中
transactionsForCurrency.add(transaction);
}
}
return transactionByCurrency;
}
可以看出这段的最大问题有好几个嵌套的控制流指令,而有了Stream API,可以这样解决问题。
private static Map<String, List<Transaction>> transactionByCurrency2(ArrayList<Transaction> transactions) {
return transactions.stream()
.filter(transaction -> transaction.getPrice() > 1000)//筛选
.collect(Collectors.groupingBy(Transaction::getCurrency));//分组
}
两段代码相比较,流Stream API和集合Collection API处理数据的方式非常不同。用集合的话,需要自己去用for-each逐个去迭代元素,然后再处理元素,这种数据迭代方式称为外部迭代。相反,有了Stream API不用操行循环的事情,数据处理完全在库内完成的,这种思想称为内部迭代。使用集合另外问题是,如果数据集过大,单个CPU根本处理不了,现在大多电脑都有个多个CPU,可以利用多个CPU内核共同分担处理工作,以缩短时间处理。而经典Java程序只能利用其中一个CPU(内核),其他内核处理能力都浪费了,Java8提供了新的编程风格,可以更好地利用多核计算机。
Java8之前如果考虑通过多线程Thread API利用并行,这并非易事,因为多线程可能会访问并更新共享变量,如果没有协调好,数据可能会被意外改变。例如,两个线程同时向共享变量加上一个数时,在没有做好同步时,就有可能出现问题。
Java8使用Stream API解决了两个问题:集合处理时的套路和晦涩,以及难以利用的多核。这样的设计原因在于,第一原因是有许多反复出现的是数据处理模式,如前面的filterApples操作。如果在Java库中存在这些将会很方便:根据标准筛选数据(比如较重的苹果),提取数据(如抽取列表中每个苹果的重量字段),或者数据分组(如将一个数组列表分组,奇数和偶数分组)。第二个原因是这类操作可以并行化。例如下图中国在两个CPU上筛选列表,可以让一个CPU处理列表的前一半,第二个CPU处理后一半,这称为分支步骤(1)。CPU随后对各自的半个列表做筛选(2)。最后在一个CPU会把两个结果合并起来(3)。
到这里,我们所提到的Stream API和Java现有的集合API行为差不多:它们都能访问数据项目的序列。但Collection主要是为了存储和访问数据,而Stream则主要用于描述对数据的计算。有个关键点在于Stream允许并提倡并行处理一个Stream中的元素,筛选一个Collection的最快方法是将其准换为Stream,进行并行处理,然后再转回List。下面对List使用Stream API的顺序处理和并行处理都是这种处理思路。
//顺序处理:stream
List<Apple> heavyApples =
inventory.stream().filter((Apple a) -> a.getWeight() > 150).collect(Collectors.toList());
//并行处理:parallelStream
List<Apple> heavyApples =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150).collect(Collectors.toList());
【Java中的并行与无共享状态】
Java里面并行实现比较难,而且和synchronized相关代码都可能出现问题,Java8中解决方式:首先库会负责分块,即将大的流分成几个小的流,以便并行处理。其次流提供的这个几乎免费的并行,只有在传递给filter之类的库方法的方法之间不会互动(比如有可变的共享对象)是才能工作。虽然函数式编程中的函数的主要意思是"把函数作为一等值",不过它也常常隐含这第二层意思,即"执行时在元素之间无互动"。
6.默认方法
Java8中向接口加入默认方法主要是为了支持库设计师,让他们能够写出更容易的改进的接口。这种方式只是有助于程序改进,而不是用于编写任何具体的程序。
List<Apple> heavyApples =
inventory.stream().filter((Apple a) -> a.getWeight() > 150).collect(Collectors.toList());
List<Apple> heavyApples =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150).collect(Collectors.toList());
看这段代码,就会发现问题,在Java8之前List<T>
中没有stream
这样的方法,它实现的Collection<T>
接口也没有。为了让这段代码不报错,我们可能想到最简单的解决方案是让Java8设计者将stream
方法将加入Collection
接口,并加入ArrayList
类的实现。可这样做就有一个刺手的问题,如果向Collection
接口添加一个新方法,意味着此解耦所有的实现类都必须为此方法提供一个实现。语言设计者没法控制Collections
所现有的实现,如何改变已有发布的接口而不破坏已有的实现?Java8采取的解决方式是接口可以包含实现类没有提供实现的方法签名了,缺失的方法主体随接口提供了(因此就有了默认实现),而不是由实现类提供。
这就给接口设计者提供了一个扩充接口的方法,而不会破坏现有的代码, Java8在接口声明中使用新的default
关键字表示这一点。例如在Java8中向List
接口添加了sort
方法,而它底层会调用Collectons.sort()
方法,因此List的实现类不需要显式实现sort
方法,这在Java8之前,除了提供sort
的实现,否则这些实现类在重新编译时都会失败。
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
7.编程式函数其他好思想
Java8中引入了一个Optional<T>
类,它是一个容器类,可以包含或者不包含一个值。由于Optional<T>
有方法明确处理值不存在的情况,因此可以避免NullPointerException
异常。
第二个想法是模式匹配,这在数学中也有使用,如:
f(0) = 1
f(n) = n*f(n-1) otherwise
在 Java中可以使用if-then-else语句或者switch语句来表示,其他编程语言实践表明,对于更复杂的数据类型,模式匹配可以比 if-then-else 更简明地表达编程思想。然而Java8中还不支持模式匹配。如下面使用Scale语言来演示一个模式匹配代码:
def simplifyExpression(expr: Expr): Expr = expr match {
case BinOp("+", e, Number(0)) => e // 加0
case BinOp("*", e, Number(1)) => e // 乘以1
case BinOp("/", e, Number(1)) => e // 除以1
case _ => expr // 不能简化expr
}
它表达的意思是:"检查 expr 是否为 BinOp ,抽取它的三个组成部分( opname 、 left 、right ),紧接着对这些组成部分分别进行模式匹配:第一个部分匹配 String+ ,第二个部分匹配变量 e (它总是匹配),第三个部分匹配模式 Number(0) "。模式匹配为操纵类树型数据结构提供了一个极其详细又极富表现力的方式。构建编译器或者处理商务规则的引擎时,这一工具尤其有用。Scala的语法为:
Expression match { case Pattern => Expression ... }
和Java的语法非常相似
switch (Expression) { case Constant : Statement ... }
两者区别是Java中模式的判断标签被限制在了某些基础类型、枚举类型、封装基础类型的类以及 String 类型。支持模式匹配的语言实践表明,模式匹配可以避免出现大量嵌套的 switch或者 if-then-else 语句和字段选择操作相互交织。
8.总结
1.编程语言面临要么改变,要么衰亡的压力,Java语言不例外,需要不断添加新功能。
2.Java8引入了一些新的编程概念,如流处理,行为参数化以及并行等。这使得编写代码更加简单,而且能有效利用多核处理器。
3.让方法作为值也构成了其他若干Java 8功能(如 Stream )的基础,Java中Streams的概念使得 Collections 的许多方面得以推广,让代码更为易读,并允许并行处理流元素。
4.可以在接口中使用默认方法,在实现类没有实现方法时提供方法内容。
5.其他来自函数式编程的思想,包括处理 null 和使用模式匹配。