编写能够应对变化的需求的代码并不容易。让我们来看一个例子,我们会逐步改进这个例子,以展示一些让代码更灵活的最佳做法。
就农场库存程序而言,你必须实现一个从列表中筛选绿苹果的功能。听起来很简单吧?
public static List<Apple> findGreenApple(List<Apple> apples) {
List<Apple> list = new ArrayList<>();
for (Apple apple :
apples) {
if ("green".equals(apple.getColor())) {
list.add(apple);
}
}
return list;
}
突出显示的行就是筛选绿苹果所需的条件。但是现在农民改主意了,他还想要筛选红苹果。你该怎么做呢?简单的解决办法就是复制这个方法,把名字改成 filterRedApples
,然后更改if条件来匹配红苹果。然而,要是农民想要筛选多种颜色:浅绿色、暗红色、黄色等,这种方法就应付不了了。一个良好的原则是在编写类似的代码之后,尝试将其抽象化。
一种做法是给方法加一个参数,把颜色变成参数,这样就能灵活地适应变化了
public static List<Apple> filterApplesByColor(List<Apple> apples, String color) {
List<Apple> list = new ArrayList<>();
for (Apple apple :
apples) {
if (color.equals(apple.getColor())) {
list.add(apple);
}
}
return list;
}
现在,只要像下面这样调用方法,农民朋友就会满意了:
List<Apple> greenApples = filterApplesByColor(inventory, "green");
List<Apple> redApples = filterApplesByColor(inventory, "red");
太简单了对吧?让我们把例子再弄得复杂一点儿。这位农民又跑回来和你说:“要是能区分轻的苹果和重的苹果就太好了。重的苹果一般是重量大于150克。”作为软件工程师,你早就想到农民可能会要改变重量,于是你写了下面的方法,用另一个参数来应对不同的重量:
public static List<Apple> filterApplesByWeight(List<Apple> apples, int weight) {
List<Apple> list = new ArrayList<>();
for (Apple apple :
apples) {
if (apple.getWeight() > weight) {
list.add(apple);
}
}
return list;
}
解决方案不错,但是请注意,你复制了大部分的代码来实现遍历库存,并对每个苹果应用筛选条件。这有点儿令人失望,因为它打破了DRY(Don’t Repeat Yourself,不要重复自己)的软件工程原则。如果你想要改变筛选遍历方式来提升性能呢?那就得修改所有方法的实现,而不是只改一个。从工程工作量的角度来看,这代价太大了。
你在上一节中已经看到了,你需要一种比添加很多参数更好的方法来应对变化的需求。让我们后退一步来看看更高层次的抽象。一种可能的解决方案是对你的选择标准建模:你考虑的是苹果,需要根据Apple的某些属性(比如它是绿色的吗?重量超过150克吗?)来返回一个boolean值。我们把它称为谓词(即一个返回boolean值的函数)。让我们定义一个接口来对选择标准建模:
public interface ApplePredicate{
boolean test (Apple apple);
}
现在你就可以用ApplePredicate
的多个实现代表不同的选择标准了
public class AppleHeavyWeightPredicate implements ApplePredicate{ //仅仅选出重的苹果
public boolean test(Apple apple){
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate{ //仅仅选出绿苹果
public boolean test(Apple apple){
return "green".equals(apple.getColor());
}
}
你可以把这些标准看作filter方法的不同行为。你刚做的这些和“策略设计模式”相关,它让你定义一族算法,把它们封装起来(称为“策略”),然后在运行时选择一个算法。在这里,算法族就是ApplePredicate
,不同的策略就是AppleHeavyWeightPredicate
和AppleGreenColorPredicate
。
利用ApplePredicate
改过之后,filter方法看起来是这样的:
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) { // // 谓词对象封装了测试苹果的条件
result.add(apple);
}
}
return result;
}
这里值得停下来小小地庆祝一下。这段代码比我们第一次尝试的时候灵活多了,读起来、用起来也更容易!现在你可以创建不同的ApplePredicate
对象,并将它们传递给filterApples
方法。免费的灵活性!比如,如果农民让你找出所有重量超过150克的红苹果,你只需要创建一个类来实现ApplePredicate
就行了。你的代码现在足够灵活,可以应对任何涉及苹果属性的需求变更了:
public class AppleRedAndHeavyPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return "red".equals(apple.getColor())
&& apple.getWeight() > 150;
}
}
List<Apple> redAndHeavyApples =
filterApples(inventory, new AppleRedAndHeavyPredicate());
你已经做成了一件很酷的事:filterApples
方法的行为取决于你通过ApplePredicate
对象传递的代码。换句话说,你把filterApples
方法的行为参数化了!
我们都知道,人们都不愿意用那些很麻烦的功能或概念。目前,当要把新的行为传递给filterApples
方法的时候,你不得不声明好几个实现ApplePredicate
接口的类,然后实例化好几个只会提到一次的ApplePredicate
对象。下面的程序总结了你目前看到的一切。这真是很啰嗦,很费时间!
public class AppleHeavyWeightPredicate implements ApplePredicate { //选择较重苹果的谓词
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate { //选择绿苹果的谓词
public boolean test(Apple apple) {
return "green".equals(apple.getColor());
}
}
public class FilteringApples {
public static void main(String... args) {
List<Apple> inventory = Arrays.asList(new Apple(80, "green"),
new Apple(155, "green"),
new Apple(120, "red"));
List<Apple> heavyApples =
filterApples(inventory, new AppleHeavyWeightPredicate()); //结果是一个包含一个155克Apple的List
List<Apple> greenApples =
filterApples(inventory, new AppleGreenColorPredicate()); //结果是一个包含两个绿Apple的List
}
public static List<Apple> filterApples(List<Apple> inventory,
ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
}
费这么大劲儿真没必要,能不能做得更好呢?Java有一个机制称为匿名类,它可以让你同时声明和实例化一个类。它可以帮助你进一步改善代码,让它变得更简洁。但这也不完全令人满意。
tips: 匿名类和你熟悉的Java局部类(块中定义的类)差不多,但匿名类没有名字。它允许你同时声明并实例化一个类。换句话说,它允许你随用随建。
下面的代码展示了如何通过创建一个用匿名类实现ApplePredicate
的对象,重写筛选的例子
List<Apple> redApples = filterApples(inventory, new ApplePredicate() { //直接内联参数化filterapples方法的行为
public boolean test(Apple apple) {
return "red".equals(apple.getColor());
}
});
但匿名类还是不够好。第一,它往往很笨重,因为它占用了很多空间。第二,很多程序员觉得它用起来很让人费解。
上面的代码在Java 8里可以用Lambda表达式重写为下面的样子
List<Apple> result =
filterApples(inventory, (Apple apple) -> "red".equals(apple.getColor()));
在通往抽象的路上,我们还可以更进一步。目前,filterApples
方法还只适用于Apple。你还可以将List类型抽象化,从而超越你眼前要处理的问题:
public interface Predicate<T> {
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) { //引入类型参数T
List<T> result = new ArrayList<>();
for (T e : list) {
if (p.test(e)) {
result.add(e);
}
}
return result;
}
酷不酷?你现在在灵活性和简洁性之间找到了最佳平衡点,这在Java 8之前是不可能做到的!
Java API中的很多方法都可以用不同的行为来参数化。这些方法往往与匿名类一起使用。
在Java 8中,List自带了一个sort方法(你也可以使用Collections.sort
)。sort的行为可以用java.util.Comparator
对象来参数化
因此,你可以随时创建Comparator的实现,用sort方法表现出不同的行为。比如,你可以使用匿名类,按照重量升序对库存排序:
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
如果农民改了主意,你可以随时创建一个Comparator来满足他的新要求,并把它传递给sort方法。而如何进行排序这一内部细节都被抽象掉了。用Lambda表达式的话,看起来就是这样:
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
线程就像是轻量级的进程:它们自己执行一个代码块。但是,怎么才能告诉线程要执行哪块代码呢?多个线程可能会运行不同的代码。我们需要一种方式来代表稍候执行的一段代码。在Java里,你可以使用Runnable接口表示一个要执行的代码块。请注意,代码不会返回任何结果(即void)
你可以像下面这样,使用这个接口创建执行不同行为的线程:
Thread t = new Thread(new Runnable() {
public void run() {
System.out.println("Hello world");
}
});
用Lambda表达式的话,看起来是这样:
Thread t = new Thread(() -> System.out.println("Hello world"));
Lambda的例子和使用案例。
使用案例 | Lambda示例 | 对应的函数式接口 |
---|---|---|
布尔表达式 | (List<String> list) -> list.isEmpty() | Predicate<List<String>> |
创建对象 | () -> new Apple("green") | Supplier<Apple> |
消费一个对象 | (Apple a) -> {System.out.println(a.getWeight());} | Consumer<Apple> |
从一个对象中选择/抽取 | (String s) -> s.length() | Function<String, Integer> 或 ToIntFunction<String> |
组合两个值 | (int a, int b) -> a * b | IntBinaryOperator |
比较两个值 | (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()) | Comparator<Apple> 或 BiFunction<Apple, Apple, Integer> 或 ToIntBiFunction<Apple, Apple> |
还记得你在第2章里,为了参数化filter方法的行为而创建的Predicate<T>
接口吗?它就是一个函数式接口!为什么呢?因为Predicate仅仅定义了一个抽象方法:
public interface Predicate<T>{
boolean test (T t);
}
一言以蔽之,函数式接口就是只定义一个抽象方法的接口。
注意 你将会在第9章中看到,接口现在还可以拥有默认方法(即在类没有对方法进行实现时,其主体为方法提供默认实现的方法)。哪怕有很多默认方法,只要接口只定义了一个抽象方法,它就仍然是一个函数式接口。
Java 8的库设计师帮你在java.util.function
包中引入了几个新的函数式接口。
java.util.function.Predicate<T>
接口定义了一个名叫test的抽象方法,它接受泛型T对象,并返回一个boolean。这恰恰和你先前创建的一样,现在就可以直接使用了。在你需要表示一个涉及类型T的布尔表达式时,就可以使用这个接口。比如,你可以定义一个接受String对象的Lambda表达式,如下所示。
@FunctionalInterface
public interface Predicate<T>{
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> results = new ArrayList<>();
for(T s: list){
if(p.test(s)){
results.add(s);
}
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty(); // Lambda是Predicate中test方法的实现
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);
java.util.function.Consumer<T>
定义了一个名叫accept的抽象方法,它接受泛型T的对象,没有返回(void)。你如果需要访问类型T的对象,并对其执行某些操作,就可以使用这个接口。比如,你可以用它来创建一个forEach方法,接受一个Integers的列表,并对其中每个元素执行操作。在下面的代码中,你就可以使用这个forEach方法,并配合Lambda来打印列表中的所有元素。
@FunctionalInterface
public interface Consumer<T>{
void accept(T t);
}
public static <T> void forEach(List<T> list, Consumer<T> c){
for(T i: list){
c.accept(i);
}
}
forEach(
Arrays.asList(1,2,3,4,5),
(Integer i) -> System.out.println(i) // Lambda是Consumer中accept方法的实现
);
java.util.function.Function<T, R>
接口定义了一个叫作apply的方法,它接受一个泛型T的对象,并返回一个泛型R的对象。如果你需要定义一个Lambda,将输入对象的信息映射到输出,就可以使用这个接口(比如提取苹果的重量,或把字符串映射为它的长度)。在下面的代码中,我们向你展示如何利用它来创建一个map方法,以将一个String列表映射到包含每个String长度的Integer列表。
@FunctionalInterface
public interface Function<T, R>{
R apply(T t);
}
public static <T, R> List<R> map(List<T> list,
Function<T, R> f) {
List<R> result = new ArrayList<>();
for(T s: list){
result.add(f.apply(s));
}
return result;
}
// [7, 2, 6]
List<Integer> l = map(
Arrays.asList("lambdas","in","action"),
(String s) -> s.length() // Lambda是Function接口的apply方法的实现
);
Java 8为我们前面所说的函数式接口带来了一个专门的版本,以便在输入和输出都是原始类型时避免自动装箱的操作。比如,在下面的代码中,使用IntPredicate就避免了对值1000进行装箱操作,但要是用Predicate<Integer>
就会把参数1000装箱到一个Integer对象中:
public interface IntPredicate{
boolean test(int t);
}
IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000); // true(无装箱)
Predicate<Integer> oddNumbers = (Integer i) -> i % 2 == 1;
oddNumbers.test(1000); // false(装箱)
一般来说,针对专门的输入参数类型的函数式接口的名称都要加上对应的原始类型前缀,比如DoublePredicate
、IntConsumer
、LongBinaryOperator
、IntFunction
等。Function接口还有针对输出参数类型的变种:ToIntFunction<T>
、IntToDoubleFunction
等。
方法引用让你可以重复使用现有的方法定义,并像Lambda一样传递它们。在一些情况下,比起使用Lambda表达式,它们似乎更易读,感觉也更自然。下面就是我们借助更新的Java 8 API(我们会在3.7节中更详细地讨论),用方法引用写的一个排序的例子:
以前:
inventory.sort((Apple a1, Apple a2)
-> a1.getWeight().compareTo(a2.getWeight()));
之后(使用方法引用和java.util.Comparator.comparing
):
inventory.sort(comparing(Apple::getWeight));
显式地指明方法的名称,你的代码的可读性会更好。它是如何工作的呢?当你需要使用方法引用时,目标引用放在分隔符::前,方法的名称放在后面。例如,Apple::getWeight
就是引用了Apple类中定义的方法getWeight。请记住,不需要括号,因为你没有实际调用这个方法。方法引用就是Lambda表达式(Apple a) ->a.getWeight()
的快捷写法。
Lambda及其等效方法引用的例子
Lambda | 等效的方法引用 |
---|---|
(Apple a) -> a.getWeight() | Apple::getWeight |
() -> Thread.currentThread.dumpStack() | Thread.currentThread()::dumpStack |
(str, i) -> str.substring(i) | String::substring |
(String s) -> System.out.println(s) | System.out::println |
方法引用主要有三类:
Integer::parseInt
)。String::length
)。expensiveTransaction::getValue
)。为了给这一章还有我们讨论的所有关于Lambda的内容收个尾,我们需要继续研究开始的那个问题——用不同的排序策略给一个Apple列表排序,并需要展示如何把一个原始粗暴的解决方案转变得更为简明。这会用到书中迄今讲到的所有概念和功能:行为参数化、匿名类、Lambda表达式和方法引用。
你很幸运,Java 8的API已经为你提供了一个List可用的sort方法,你不用自己去实现它。那么最困难的部分已经搞定了!但是,如何把排序策略传递给sort方法呢?你看,sort方法的签名是这样的:
void sort(Comparator<? super E> c)
它需要一个Comparator对象来比较两个Apple!这就是在Java中传递策略的方式:它们必须包裹在一个对象里。我们说sort的行为被参数化了:传递给它的排序策略不同,其行为也会不同。
你的第一个解决方案看上去是这样的:
public class AppleComparator implements Comparator<Apple> {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
}
inventory.sort(new AppleComparator());
你在前面看到了,你可以使用匿名类来改进解决方案,而不是实现一个Comparator却只实例化一次:
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
但你的解决方案仍然挺啰嗦的。Java 8引入了Lambda表达式,它提供了一种轻量级语法来实现相同的目标:传递代码。你看到了,在需要函数式接口的地方可以使用Lambda表达式。
我们回顾一下:函数式接口就是仅仅定义一个抽象方法的接口。抽象方法的签名(称为函数描述符)描述了Lambda表达式的签名。在这个例子里,Comparator代表了函数描述符(T,T) -> int
。因为你用的是苹果,所以它具体代表的就是(Apple, Apple) -> int
。
改进后的新解决方案看上去就是这样的了:
inventory.sort((Apple a1, Apple a2)
-> a1.getWeight().compareTo(a2.getWeight())
);
我们前面解释过了,Java编译器可以根据Lambda出现的上下文来推断Lambda表达式参数的类型。那么你的解决方案就可以重写成这样:
inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
你的代码还能变得更易读一点吗?Comparator具有一个叫作comparing的静态辅助方法,它可以接受一个Function来提取Comparable键值,并生成一个Comparator对象。它可以像下面这样用(注意你现在传递的Lambda只有一个参数:Lambda说明了如何从苹果中提取需要比较的键值):
Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
现在你可以把代码再改得紧凑一点了:
import static java.util.Comparator.comparing;
inventory.sort(comparing((a) -> a.getWeight()));
前面解释过,方法引用就是替代那些转发参数的Lambda表达式的语法糖。你可以用方法引用让你的代码更简洁(假设你静态导入了java.util.Comparator.comparing
):
inventory.sort(comparing(Apple::getWeight));
恭喜你,这就是你的最终解决方案!这比Java 8之前的代码好在哪儿呢?它比较短;它的意思也很明显,并且代码读起来和问题描述差不多:“对库存进行排序,比较苹果的重量。”
具体而言,许多函数式接口,比如用于传递Lambda表达式的Comparator、Function和Predicate都提供了允许你进行复合的方法。这是什么意思呢?在实践中,这意味着你可以把多个简单的Lambda复合成复杂的表达式。比如,你可以让两个谓词之间做一个or操作,组合成一个更大的谓词。而且,你还可以让一个函数的结果成为另一个函数的输入。
我们前面看到,你可以使用静态方法Comparator.comparing
,根据提取用于比较的键值的Function来返回一个Comparator
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
如果你想要对苹果按重量递减排序怎么办?用不着去建立另一个Comparator的实例。接口有一个默认方法reversed可以使给定的比较器逆序。因此仍然用开始的那个比较器,只要修改一下前一个例子就可以对苹果按重量递减排序:
inventory.sort(comparing(Apple::getWeight).reversed()); // 按重量递减排序
但如果发现有两个苹果一样重怎么办?哪个苹果应该排在前面呢?你可能需要再提供一个Comparator来进一步定义这个比较。比如,在按重量比较两个苹果之后,你可能想要按原产国排序。thenComparing
方法就是做这个用的。它接受一个函数作为参数(就像comparing方法一样),如果两个对象用第一个Comparator比较之后是一样的,就提供第二个Comparator。你又可以优雅地解决这个问题了:
inventory.sort(comparing(Apple::getWeight)
.reversed() // 按重量递减排序
.thenComparing(Apple::getCountry)); // 两个苹果一样重时,进一步按国家排序
谓词接口包括三个方法:negate、and和or,让你可以重用已有的Predicate来创建更复杂的谓词。比如,你可以使用negate方法来返回一个Predicate的非,比如苹果不是红的:
Predicate<Apple> notRedApple = redApple.negate(); // 产生现有Predicate对象非redApple的
你可能想要把两个Lambda用and方法组合起来,比如一个苹果既是红色又比较重:
Predicate<Apple> redAndHeavyApple =
redApple.and(a -> a.getWeight() > 150); // 链接两个谓词来生成另一个Predicate对象
你可以进一步组合谓词,表达要么是重(150克以上)的红苹果,要么是绿苹果:
Predicate<Apple> redAndHeavyAppleOrGreen =
redApple.and(a -> a.getWeight() > 150)
.or(a -> "green".equals(a.getColor())); // 链接Predicate的方法来构造更复杂Predicate对象
请注意,and和or方法是按照在表达式链中的位置,从左向右确定优先级的。因此,a.or(b).and(c)
可以看作(a || b) && c
。
最后,你还可以把Function接口所代表的Lambda表达式复合起来。Function接口为此配了andThen和compose两个默认方法,它们都会返回Function的一个实例。
那么在实际中这有什么用呢?比方说你有一系列工具方法,对用String表示的一封信做文本转换:
public class Letter {
public static String addHeader(String text) {
return "From Raoul, Mario and Alan: " + text;
}
public static String addFooter(String text) {
return text + " Kind regards";
}
public static String checkSpelling(String text) {
return text.replaceAll("labda", "lambda");
}
}
现在你可以通过复合这些工具方法来创建各种转型流水线了,比如创建一个流水线:先加上抬头,然后进行拼写检查,最后加上一个落款
Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
= addHeader.andThen(Letter::checkSpelling)
.andThen(Letter::addFooter);
第二个流水线可能只加抬头、落款,而不做拼写检查:
Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
= addHeader.andThen(Letter::addFooter);
流是Java API的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。就现在来说,你可以把它们看成遍历数据集的高级迭代器。此外,流还可以透明地并行处理,你无需写任何多线程代码了!
之前(Java 7):
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish d : menu) {
if (d.getCalories() < 400) { // 用累加器筛选元素
lowCaloricDishes.add(d);
}
}
Collections.sort(lowCaloricDishes, new Comparator<Dish>() { // 用匿名类对菜肴排序
public int compare(Dish d1, Dish d2) {
return Integer.compare(d1.getCalories(), d2.getCalories());
}
});
List<String> lowCaloricDishesName = new ArrayList<>();
for (Dish d : lowCaloricDishes) {
lowCaloricDishesName.add(d.getName()); // 处理排序后的菜名列表
}
在这段代码中,你用了一个“垃圾变量”lowCaloricDishes。它唯一的作用就是作为一次性的中间容器。在Java 8中,实现的细节被放在它本该归属的库里了。
之后(Java 8):
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
List<String> lowCaloricDishesName =
menu.stream()
.filter(d -> d.getCalories() < 400) // 选出400卡路里以下的菜肴
.sorted(comparing(Dish::getCalories)) // 按照卡路里排序
.map(Dish::getName) // 提取菜肴的名称
.collect(toList()); // 将所有名称保存在List中
说明:
toList()
就是将流转换为列表的方案。为了利用多核架构并行执行这段代码,你只需要把stream()
换成parallelStream()
:
menu.parallelStream() // 并行
请注意,和迭代器类似,流只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来重新遍历一遍,就像迭代器一样(这里假设它是集合之类的可重复的源,如果是I/O通道就没戏了)。例如,以下代码会抛出一个异常,说流已被消费掉了:
List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println); // 正常,打印标题中的每个单词
s.forEach(System.out::println); // 异常,java.lang.IllegalStateException:流已被操作或关闭
所以要记得,流只能消费一次!
使用Collection接口需要用户去做迭代(比如用for-each),这称为外部迭代。 相反,Streams库使用内部迭代——它帮你把迭代做了,还把得到的流值存在了某个地方,你只要给出一个函数说要干什么就可以了。下面的代码列表说明了这种区别。
List<String> names = new ArrayList<>();
for(Dish d: menu){ // 显式顺序迭代菜单列表
names.add(d.getName()); // 提取名称并将其添加到累加器
}
请注意,for-each还隐藏了迭代中的一些复杂性。for-each结构是一个语法糖,它背后的东西用Iterator对象表达出来更要丑陋得多。
List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) { // 显式迭代
Dish d = iterator.next();
names.add(d.getName());
}
List<String> names = menu.stream()
.map(Dish::getName) // 用getName 方法参数化map,提取菜名
.collect(toList()); // 开始执行操作流水线;没有迭代
java.util.stream.Stream
中的Stream接口定义了许多操作。它们可以分为两大类。
诸如filter或sorted等中间操作会返回另一个流。这让多个操作可以连接起来形成一个查询。重要的是,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理——它们很懒。这是因为中间操作一般都可以合并起来,在终端操作时一次性全部处理。
操作 | 类型 | 返回类型 | 操作参数 | 函数描述符 |
---|---|---|---|---|
filter | 中间 | Stream<T> | Predicate<T> | T -> boolean |
map | 中间 | Stream<T> | Function<T,R> | T -> R |
limit | 中间 | Stream<T> | ||
sorted | 中间 | Stream<T> | Comparator<T> | (T, T) -> int |
distinct | 中间 | Stream<T> |
终端操作会从流的流水线生成结果。其结果是任何不是流的值,比如List、Integer,甚至void。例如,在下面的流水线中,forEach是一个返回void的终端操作,它会对源中的每道菜应用一个Lambda。把System.out.println
传递给forEach,并要求它打印出由menu生成的流中的每一个Dish:
menu.stream().forEach(System.out::println);
操作 | 类型 | 目的 |
---|---|---|
forEach | 终端 | 消费流中的每个元素并对其应用Lambda。这操作返回void。 |
count | 终端 | 返回流中元素的个数。这操作返回long。 |
collect | 终端 | 把流归约成一个集合,比如List、Map甚至是Integer。 |
在本章中,你将会看到Stream API支持的许多操作。这些操作能让你快速完成复杂的数据查询,如筛选、切片、映射、查找、匹配和归约。接下来,我们会看看一些特殊的流:数值流、来自文件和数组等多种来源的流,最后是无限流
用谓词筛选,筛选出各不相同的元素,忽略流中的头几个元素,或将流截短至指定长度。
Streams接口支持filter方法(你现在应该很熟悉了)。该操作会接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。
List<Dish> vegetarianMenu = menu.stream()
.filter(Dish::isVegetarian) // 方法引用检查菜肴是否适合素食者,此属性为true或false
.collect(toList());
流还支持一个叫作distinct的方法,它会返回一个元素各异(根据流所生成元素的hashCode
和equals
方法实现)的流。例如,以下代码会筛选出列表中所有的偶数,并确保没有重复。
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
.filter(i -> i % 2 == 0)
.distinct()
.forEach(System.out::println);
流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。比如,你可以建立一个List,选出热量超过300卡路里的头三道菜:
List<Dish> dishes = menu.stream()
.filter(d -> d.getCalories() > 300)
.limit(3)
.collect(toList());
流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!例如,下面的代码将跳过超过300卡路里的头两道菜,并返回剩下的。
List<Dish> dishes = menu.stream()
.filter(d -> d.getCalories() > 300)
.skip(2)
.collect(toList());
一个非常常见的数据处理套路就是从某些对象中选择信息。比如在SQL里,你可以从表中选择一列。Stream API也通过map和flatMap方法提供了类似的工具。
流支持map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映射成一个新的元素(使用映射一词,是因为它和转换类似,但其中的细微差别在于它是“创建一个新版本”而不是去“修改”)。例如,下面的代码把方法引用Dish::getName
传给了map方法,来提取流中菜肴的名称:
List<String> dishNames = menu.stream()
.map(Dish::getName)
.collect(toList());
因为getName方法返回一个String,所以map方法输出的流的类型就是Stream<String>
。
现在让我们回到提取菜名的例子。如果你要找出每道菜的名称有多长,怎么做?你可以像下面这样,给map传递一个方法引用String::length来解决这个问题:
List<Integer> dishNameLengths = menu.stream()
.map(Dish::getName)
.map(String::length)
.collect(toList());
例如,给定单词列表[“Hello”,”World”],你想要返回列表[“H”,”e”,”l”, “o”,”W”,”r”,”d”]。
首先,你需要一个字符流,而不是数组流。有一个叫作Arrays.stream()
的方法可以接受一个数组并产生一个流,例如:
// 1. 尝试使用map和Arrays.stream()
String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfWords = Arrays.stream(arrayOfWords);
把它用在之前的那个流水线里
// 假设流水线是这样,你得到的是一个流的列表(Stream<String>)
List<Stream<String>> streamOfWords1 = streamOfWords.map(word -> word.split(""))
.map(Arrays::stream)
.distinct()
.collect(Collectors.toList());
你可以像下面这样使用flatMap来解决这个问题:
List<String> streamOfWords2 = streamOfWords.map(word -> word.split(""))
.flatMap(Arrays::stream)
.distinct()
.collect(Collectors.toList());
// streamOfWords2 = [G, o, d, b, y, e, W, r, l]
System.out.println("streamOfWords2 = " + streamOfWords2);
使用flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。所有使用map(Arrays::stream)
时生成的单个流都被合并起来,即扁平化为一个流。
一言以蔽之,flatmap方法让你把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。
另一个常见的数据处理套路是看看数据集中的某些元素是否匹配一个给定的属性。
anyMatch方法可以回答“流中是否有一个元素能匹配给定的谓词”。比如,你可以用它来看看菜单里面是否有素食可选择:
if (menu.stream().anyMatch(Dish::isVegetarian)) {
System.out.println("The menu is (somewhat) vegetarian friendly!!");
}
anyMatch方法返回一个boolean,因此是一个终端操作。
allMatch方法的工作原理和anyMatch类似,但它会看看流中的元素是否都能匹配给定的谓词。比如,你可以用它来看看菜品是否有利健康(即所有菜的热量都低于1000卡路里):
boolean isHealthy = menu.stream()
.allMatch(d -> d.getCalories() < 1000);
和allMatch相对的是noneMatch。它可以确保流中没有任何元素与给定的谓词匹配。比如,你可以用noneMatch重写前面的例子:
boolean isHealthy = menu.stream()
.noneMatch(d -> d.getCalories() >= 1000);
anyMatch、allMatch和noneMatch这三个操作都用到了我们所谓的短路,这就是大家熟悉的Java中&&和||运算符短路在流中的版本。
findAny方法将返回当前流中的任意元素。它可以与其他流操作结合使用。比如,你可能想找到一道素食菜肴。你可以结合使用filter和findAny方法来实现这个查询:
Optional<Dish> dish =
menu.stream()
.filter(Dish::isVegetarian)
.findAny();
此类查询需要将流中所有元素反复结合起来,得到一个值,比如一个Integer。这样的查询可以被归类为归约操作(将流归约成一个值)。用函数式编程语言的术语来说,这称为折叠(fold),因为你可以将这个操作看成把一张长长的纸(你的流)反复折叠成一个小方块,而这就是折叠操作的结果。
先来看看如何使用for-each循环来对数字列表中的元素求和:
int sum = 0;
for (int x : numbers) {
sum += x;
}
numbers中的每个元素都用加法运算符反复迭代来得到结果。通过反复使用加法,你把一个数字列表归约成了一个数字。这段代码中有两个参数:
要是还能把所有的数字相乘,而不必去复制粘贴这段代码,岂不是很好?这正是reduce操作的用武之地,它对这种重复应用的模式做了抽象。你可以像下面这样对流中所有的元素求和:
numbers.stream().reduce(0, (a, b) -> a + b);
reduce接受两个参数:
BinaryOperator<T>
来将两个元素结合起来产生一个新值,这里我们用的是lambda (a, b) -> a + b
。让我们深入研究一下reduce操作是如何对一个数字流求和的。首先,0作为Lambda(a)的第一个参数,从流中获得4作为第二个参数(b)。0 + 4得到4,它成了新的累积值。然后再用累积值和流中下一个元素5调用Lambda,产生新的累积值9。接下来,再用累积值和下一个元素3调用Lambda,得到12。最后,用12和流中最后一个元素9调用Lambda,得到最终结果21。(4,5,3,9)
你可以使用方法引用让这段代码更简洁。在Java 8中,Integer类现在有了一个静态的sum方法来对两个数求和
int sum = numbers.stream().reduce(0, Integer::sum);
reduce还有一个重载的变体,它不接受初始值,但是会返回一个Optional对象:
Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));
为什么它返回一个Optional<Integer>
呢?考虑流中没有任何元素的情况。reduce操作无法返回其和,因为它没有初始值。这就是为什么结果被包裹在一个Optional对象里,以表明和可能不存在。
只要用归约就可以计算最大值和最小值了!让我们来看看如何利用刚刚学到的reduce来计算流中最大或最小的元素。
Lambda是一步步用加法运算符应用到流中每个元素上的。因此,你需要一个给定两个元素能够返回最大值的Lambda。reduce操作会考虑新值和流中下一个元素,并产生一个新的最大值,直到整个流消耗完!
你可以像下面这样使用reduce来计算流中的最大值
Optional<Integer> max = numbers.stream().reduce(Integer::max);
计算流中的最小值
Optional<Integer> min = numbers.stream().reduce(Integer::min);
和数字打交道时,有一个常用的东西就是数值范围。比如,假设你想要生成1和100之间的所有数字。Java 8引入了两个可以用于IntStream和LongStream的静态方法,帮助生成这种范围:range和rangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但range是不包含结束值的,而rangeClosed则包含结束值。
IntStream evenNumbers = IntStream.rangeClosed(1, 100) // 表示范围[1, 100]
.filter(n -> n % 2 == 0); // 一个从1到100的偶数流
System.out.println(evenNumbers.count()); // 从1 到100 有50个偶数
这里我们用了rangeClosed方法来生成1到100之间的所有数字。它会产生一个流,然后你可以链接filter方法,只选出偶数。到目前为止还没有进行任何计算。最后,你对生成的流调用count。因为count是一个终端操作,所以它会处理流,并返回结果50,这正是1到100(包括两端)中所有偶数的个数。请注意,比较一下,如果改用IntStream.range(1, 100)
,则结果将会是49个偶数,因为range是不包含结束值的。
我们先来举一个简单的例子,利用counting工厂方法返回的收集器,数一数菜单里有多少种菜:
long howManyDishes = menu.stream().collect(Collectors.counting());
这还可以写得更为直接
long howManyDishes = menu.stream().count();
counting收集器在和其他收集器联合使用的时候特别有用,后面会谈到这一点。
假设你想要找出菜单中热量最高的菜。你可以使用两个收集器,Collectors.maxBy
和Collectors.minBy
,来计算流中的最大或最小值。这两个收集器接收一个Comparator参数来比较流中的元素。你可以创建一个Comparator来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy
Comparator<Dish> dishComparator = Comparator.comparingInt(Dish::getCalories);
menu.stream().collect(Collectors.maxBy(dishComparator))
.ifPresent(System.out::println);
Collectors类专门为汇总提供了一个工厂方法:Collectors.summingInt
。它可接受一个把对象映射为求和所需int的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。举个例子来说,你可以这样求出菜单列表的总热量:
int totalCalories = menu.stream().collect(Collectors.summingInt(Dish::getCalories));
在遍历流时,会把每一道菜都映射为其热量,然后把这个数字累加到一个累加器(这里的初始值0)。
Collectors.summingLong
和Collectors.summingDouble
方法的作用完全一样,可以用于求和字段为long或double的情况。
但汇总不仅仅是求和;还有Collectors.averagingInt
,连同对应的averagingLong
和averagingDouble
可以计算数值的平均数:
double avgCalories = menu.stream().collect(Collectors.averagingInt(Dish::getCalories));
不过很多时候,你可能想要得到两个或更多这样的结果,而且你希望只需一次操作就可以完成。在这种情况下,你可以使用summarizingInt
工厂方法返回的收集器。例如,通过一次summarizing操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics = menu.stream().collect(Collectors.summarizingInt(Dish::getCalories));
这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics
的类里,它提供了方便的取值(getter)方法来访问结果。打印menuStatisticobject
会得到以下输出:
IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}
joining工厂方法返回的收集器会把对流中每一个对象应用toString方法得到的所有字符串连接成一个字符串。这意味着你把菜单中所有菜肴的名称连接起来,如下所示:
menu.stream().map(Dish::getName).collect(Collectors.joining())
请注意,joining在内部使用了StringBuilder来把生成的字符串逐个追加起来。此外还要注意,如果Dish类有一个toString方法来返回菜肴的名称,那你无需用提取每一道菜名称的函数来对原流做映射就能够得到相同的结果
但该字符串的可读性并不好。幸好,joining工厂方法有一个重载版本可以接受元素之间的分界符,这样你就可以得到一个逗号分隔的菜肴名称列表:
menu.stream().map(Dish::getName).collect(Collectors.joining(","))
事实上,我们已经讨论的所有收集器,都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。Collectors.reducing
工厂方法是所有这些特殊情况的一般化。
例如,可以用reducing方法创建的收集器来计算你菜单的总热量,如下所示:
Integer result = menu.stream()
.collect(Collectors.reducing(0, Dish::getCalories, (d1, d2) -> d1 + d2));
它需要三个参数:
BinaryOperator
,将两个项目累积成一个同类型的值。这里它就是对两个int求和。同样,你可以使用下面这样单参数形式的reducing来找到热量最高的菜,如下所示:
menu.stream().collect(Collectors.reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2)).ifPresent(System.out::println);
你还可以进一步简化前面使用reducing收集器的求和例子——引用Integer类的sum方法,而不用去写一个表达同一操作的Lambda表达式。这会得到以下程序:
Integer result = menu.stream()
.collect(Collectors.reducing(0, Dish::getCalories, Integer::sum));
我们来看看这个功能的第二个例子:假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。
用Collectors.groupingBy
工厂方法返回的收集器就可以轻松地完成这项任务,如下所示:
menu.stream().collect(Collectors.groupingBy(Dish::getType))
这里,你给groupingBy
方法传递了一个Function(以方法引用的形式),它提取了流中每一道Dish的Dish.Type
。我们把这个Function叫作分类函数,因为它用来把流中的元素分成不同的组。
但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。例如,你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。由于Dish类的作者没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:
/**
* @Description: 枚举等级
* @Author: Ray
* @Date: 2020/7/20 0020 10:42
**/
public enum CaloricLevel { DIET, NORMAL, FAT};
/**
* @Description: 按等级分组
* 你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。
* @Author: Ray
* @Date: 2020/7/20 0020 10:41
**/
private static void testDishesByCaloricLevel() {
System.out.println("testDishesByCaloricLevel");
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(Collectors.groupingBy(dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}));
System.out.println(dishesByCaloricLevel);
}
要实现多级分组,我们可以使用一个由双参数版本的Collectors.groupingBy
工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层groupingBy
传递给外层groupingBy
,并定义一个为流中项目分类的二级标准
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().collect(
Collectors.groupingBy(Dish::getType,
Collectors.groupingBy(dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}))
);
这里的外层Map的键就是第一级分类函数生成的值:“fish, meat, other”,而这个Map的值又是一个Map,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级map的值是流中元素构成的List,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、pizza…” 这种多级分组操作可以扩展至任意层级,n 级分组就会得到一个代表n* 级树形结构的 n 级Map。
一般来说,把groupingBy看作“桶”比较容易明白。第一个groupingBy给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到 n 级分组。
但进一步说,传递给第一个groupingBy的第二个收集器可以是任何类型,而不一定是另一个groupingBy。例如,要数一数菜单中每类菜有多少个,可以传递counting收集器作为groupingBy收集器的第二个参数:
menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.counting()))
还要注意,普通的单参数groupingBy(f)
(其中f是分类函数)实际上是groupingBy(f, toList())
的简便写法。
再举一个例子,你可以把前面用于查找菜单中热量最高的菜肴的收集器改一改,按照菜的类型分类:
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(Collectors.groupingBy(Dish::getType,
Collectors.maxBy(Comparator.comparingInt(Dish::getCalories))));
这个分组的结果显然是一个map,以Dish的类型作为键,以包装了该类型中热量最高的Dish的Optional<Dish>
作为值
注意:这个Map中的值是Optional,因为这是maxBy工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的Dish,这个类型就不会对应一个Optional.empty()
值,而且根本不会出现在Map的键中。
因为分组操作的Map结果中的每个值上包装的Optional没什么用,所以你可能想要把它们去掉。要做到这一点,或者更一般地来说,把收集器返回的结果转换为另一种类型,你可以使用Collectors.collectingAndThen
工厂方法返回的收集器
Map<Dish.Type, Dish> mostCaloricByType2 = menu.stream()
.collect(Collectors.groupingBy(Dish::getType, // 分组函数
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)), // 包装后的收集器
Optional::get))); // 转换函数
这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get
则把返回的Optional中的值提取出来。前面已经说过,这个操作放在这里是安全的,因为reducing收集器永远都不会返回Optional.empty()
。
一般来说,通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作。例如,你还重用求出所有菜肴热量总和的收集器,不过这次是对每一组Dish求和:
Map<Dish.Type, Integer> totalCaloriesByType = menu.stream().collect(Collectors.groupingBy(Dish::getType,
Collectors.summingInt(Dish::getCalories)));
然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型的对象。我们来看一个使用这个收集器的实际例子。比方说你想要知道,对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType = menu.stream().collect(
Collectors.groupingBy(Dish::getType, Collectors.mapping(
dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
},
Collectors.toSet()
))
);
这里,就像我们前面见到过的,传递给映射方法的转换函数将Dish映射成了它的CaloricLevel:生成的CaloricLevel流传递给一个toSet收集器,它和toList类似,不过是把流中的元素累积到一个Set而不是List中,以便仅保留各不相同的值。
请注意在上一个示例中,对于返回的Set是什么类型并没有任何保证。但通过使用toCollection,你就可以有更多的控制。例如,你可以给它传递一个构造函数引用来要求HashSet:
Map<Dish.Type, HashSet<CaloricLevel>> caloricLevelsByType2 = menu.stream().collect(
Collectors.groupingBy(Dish::getType, Collectors.mapping(
dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
},
Collectors.toCollection(HashSet::new)
))
);
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组。例如,如果你是素食者或是请了一位素食的朋友来共进晚餐,可能会想要把菜单按照素食和非素食分开:
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian)); // 分区函数
那么通过Map中键为true的值,就可以找出所有的素食菜肴了:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
请注意,用同样的分区谓词,对菜单List创建的流作筛选,然后把结果收集到另外一个List中也可以获得相同的结果:
List<Dish> vegetarianDishes = menu.stream().filter(Dish::isVegetarian).collect(Collectors.toList());
分区的好处在于保留了分区函数返回true或false的两套流元素列表。在上一个例子中,要得到非素食Dish的List,你可以使用两个筛选操作来访问partitionedMenu这个Map中false键的值:一个利用谓词,一个利用该谓词的非。而且就像你在分组中看到的,partitioningBy工厂方法有一个重载版本,可以像下面这样传递第二个收集器
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian,
Collectors.groupingBy(Dish::getType)));
这里,对于分区产生的素食和非素食子流,分别按类型对菜肴分组,得到了一个二级Map
再举一个例子,你可以重用前面的代码来找到素食和非素食中热量最高的菜:
Map<Boolean, Dish> mostCaloricPartitioneByVegetarian = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)),
Optional::get
)));
首先让我们在下面的列表中看看Collector接口的定义,它列出了接口的签名以及声明的五个方法。
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Collector.Characteristics> characteristics();
}
本列表适用以下定义。
例如,你可以实现一个ToListCollector<T>
类,将Stream<T>
中的所有元素收集到一个List<T>
里,它的签名如下:
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
通过分析,你会注意到,前四个方法都会返回一个会被collect方法调用的函数,而第五个方法characteristics则提供了一系列特征,也就是一个提示列表,告诉collect方法在执行归约操作的时候可以应用哪些优化(比如并行化)。
supplier方法必须返回一个结果为空的Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。很明显,对于将累加器本身作为结果返回的收集器,比如我们的ToListCollector,在对空流执行操作的时候,这个空的累加器也代表了收集过程的结果。在我们的ToListCollector中,supplier返回一个空的List,如下所示:
@Override
public Supplier<List<T>> supplier() {
log("supplier");
return ArrayList::new;
}
accumulator方法会返回执行归约操作的函数。当遍历到流中第 n 个元素时,这个函数执行时会有两个参数:保存归约结果的累加器(已收集了流中的前 n-1 个项目),还有第 n 个元素本身。该函数将返回void,因为累加器是原位更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果。对于ToListCollector,这个函数仅仅会把当前项目添加至已经遍历过的项目的列表:
@Override
public BiConsumer<List<T>, T> accumulator() {
log("accumulator");
return List::add;
}
在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。通常,就像ToListCollector的情况一样,累加器对象恰好符合预期的最终结果,因此无需进行转换。所以finisher方法只需返回identity函数:
@Override
public Function<List<T>, List<T>> finisher() {
log("finisher");
return Function.identity();
}
这三个方法已经足以对流进行顺序归约,至少从逻辑上看可以按图6-7进行。实践中的实现细节可能还要复杂一点,一方面是因为流的延迟性质,可能在collect操作之前还需要完成其他中间操作的流水线,另一方面则是理论上可能要进行并行归约。
四个方法中的最后一个——combiner方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList而言,这个方法的实现非常简单,只要把从流的第二个部分收集到的项目列表加到遍历第一部分时得到的列表后面就行了
@Override
public BinaryOperator<List<T>> combiner() {
log("combiner");
return (list1, list2) -> {
list1.addAll(list2);
return list1;
};
}
有了这第四个方法,就可以对流进行并行归约了。它会用到Java 7中引入的分支/合并框架和Spliterator抽象,我们会在下一章中讲到。这个过程类似于图6-8所示,这里会详细介绍
最后一个方法——characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。Characteristics是一个包含三个项目的枚举。
我们迄今开发的ToListCollector是IDENTITY_FINISH的,因为用来累积流中元素的List已经是我们要的最终结果,用不着进一步转换了,但它并不是UNORDERED,因为用在有序流上的时候,我们还是希望顺序能够保留在得到的List中。最后,它是CONCURRENT的,但我们刚才说过了,仅仅在背后的数据源无序时才会并行处理。
public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
private void log(final String str) {
System.out.println(Thread.currentThread().getName() + "-" +str);
}
@Override
public Supplier<List<T>> supplier() {
log("supplier 创建集合操作的起始点");
return ArrayList::new;
}
@Override
public BiConsumer<List<T>, T> accumulator() {
log("accumulator 累积遍历过的项目,原位修改累加器");
return List::add;
}
@Override
public BinaryOperator<List<T>> combiner() {
log("combiner 修改第一个累加器,讲其与第二个累加器的内容合并");
return (list1, list2) -> {
list1.addAll(list2);
return list1;
};
}
@Override
public Function<List<T>, List<T>> finisher() {
log("finisher 恒等函数");
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
log("characteristics 为收集器添加IDENTITY_FINISH和CONCURRENT标志");
return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT));
}
}
请注意,这个实现与Collectors.toList
方法并不完全相同,但区别仅仅是一些小的优化。这些优化的一个主要方面是Java API所提供的收集器在需要返回空列表时使用了Collections.emptyList()
这个单例(singleton)。
使用示例
Collector<String, List<String>, List<String>> collector = new ToListCollector<>();
String[] arrays = new String[]{"A", "BB", "CCC", "DDDD", "EEEEE", "FFFFFF", "GGGGGGG"};
List<String> result = Arrays.stream(arrays)
.filter(s -> s.length() > 5)
.collect(collector);
构造之间的其他差异在于toList
是一个工厂,而ToListCollector
必须用new来实例化。
Java 7引入了一个叫作分支/合并的框架,让这些操作更稳定、更不易出错。
我们简要地提到了Stream接口可以让你非常方便地处理它的元素:可以通过对收集源调用parallelStream方法来把集合转换为并行流。并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样一来,你就可以自动把给定操作的工作负荷分配给多核处理器的所有内核,让它们都忙起来。让
请注意,在现实中,对顺序流调用parallel方法并不意味着流本身有任何实际的变化。它在内部实际上就是设了一个boolean标志,表示你想让调用parallel之后进行的所有操作都并行执行。类似地,你只需要对并行流调用sequential方法就可以把它变成顺序流。
stream.parallel()
.filter(...)
.sequential()
.map(...)
.parallel()
.reduce();
但最后一次parallel或sequential调用会影响整个流水线。在本例中,流水线会并行执行,因为最后调用的是它。
看看流的parallel方法,你可能会想,并行流用的线程是从哪儿来的?有多少个?怎么自定义这个过程呢?
并行流内部使用了默认的ForkJoinPool(7.2节会进一步讲到分支/合并框架),它默认的线程数量就是你的处理器数量,这个值是由下面代码得到的。
Runtime.getRuntime().availableProcessors()
但是你可以通过系统属性java.util.concurrent.ForkJoinPool.common.parallelism
来改变线程池大小,如下所示:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12");
这是一个全局设置,因此它将影响代码中所有的并行流。反过来说,目前还无法专为某个并行流指定这个值。一般而言,让ForkJoinPool的大小等于处理器数量是个不错的默认值,除非你有很好的理由,否则我们强烈建议你不要修改它。
先准备顺序流和并行流
/**
* @Description: 顺序流
* @Author: Ray
* @Date: 2020/7/20 0020 16:00
**/
private static long sequentialSum(long n) {
return Stream.iterate(1L, i -> i + 1) // 生成自然数无限流
.limit(n) // 限制到前 n 个数
.reduce(0L, Long::sum); // 对所有数字求和来归纳流
}
/**
* @Description: 并行流
* @Author: Ray
* @Date: 2020/7/20 0020 16:01
**/
private static long parallelSum(long n) {
return Stream.iterate(1L, i -> i + 1)
.limit(n)
.parallel() // 将流转换为并行流
.reduce(0L, Long::sum);
}
/**
* @Description: 传统 for 循环
* @Author: Ray
* @Date: 2020/7/20 0020 16:05
**/
private static long iterativeSum(long n) {
long result = 0;
for (long i = 1L; i <= n; i++) {
result += i;
}
return result;
}
测量对前 n 个自然数求和的函数的性能
/**
* @Description: 测量对前 n 个自然数求和的函数的性能
* 这个方法接受一个函数和一个long作为参数。
* @Author: Ray
* @Date: 2020/7/20 0020 15:56
**/
private static long measureSumPerf(Function<Long, Long> adder, long n) {
long fastest = Long.MAX_VALUE;
for (int i = 0; i < 10; i++) {
long start = System.nanoTime();
long sum = adder.apply(n);
long duration = (System.nanoTime() - start) / 1_000_000;
System.out.println("Result: " + sum);
if (duration < fastest) {
fastest = duration; // 返回最短的一次执行时间
}
}
return fastest;
}
这个方法接受一个函数和一个long作为参数。它会对传给方法的long应用函数10次,记录每次执行的时间(以毫秒为单位),并返回最短的一次执行时间。可以用这个框架来测试顺序加法器函数对前一千万个自然数求和要用多久:
System.out.println("Sequential sum done in: " +
measureSumPerf(StreamPerformanceTest::sequentialSum, 10_000_000) + " msecs");
// Sequential sum done in: 108 msecs
用传统for循环的迭代版本执行起来应该会快很多,因为它更为底层,更重要的是不需要对原始类型做任何装箱或拆箱操作。如果你试着测量它的性能
System.out.println("Iterative sum done in: " +
measureSumPerf(StreamPerformanceTest::iterativeSum, 10_000_000) + " msecs");
// Iterative sum done in: 6 msecs
再来看看并行的性能
System.out.println("Parallel sum done in: " +
measureSumPerf(StreamPerformanceTest::parallelSum, 10_000_000) + " msecs");
// Parallel sum done in: 82 msecs
这相当令人失望,求和方法的并行版本比顺序版本的性能差不多。
这里实际上有两个问题:
那到底要怎么利用多核处理器,用流来高效地并行求和呢?我们在第5章中讨论了一个叫LongStream.rangeClosed
的方法。这个方法与iterate相比有两个优点。
LongStream.rangeClosed
直接产生原始类型的long数字,没有装箱拆箱的开销。LongStream.rangeClosed
会生成数字范围,很容易拆分为独立的小块。例如,范围1 /**
* @Description: LongStream.rangeClosed的方法
* @Author: Ray
* @Date: 2020/7/20 0020 16:16
**/
private static long rangeSum(long n) {
return LongStream.rangeClosed(1, n)
.reduce(0L, Long::sum);
}
System.out.println("Ranged sum done in: " +
measureSumPerf(StreamPerformanceTest::rangeSum, 10_000_000) + " msecs");
// Ranged sum done in: 4 msecs
这个数值流比前面那个用iterate工厂方法生成数字的顺序执行版本要快得多,因为数值流避免了非针对性流那些没必要的自动装箱和拆箱操作。由此可见,选择适当的数据结构往往比并行化算法更重要。
但要是对这个新版本应用并行流呢?
/**
* @Description: LongStream.rangeClosed的方法 + 并行
* @Author: Ray
* @Date: 2020/7/20 0020 16:16
**/
private static long parallelRangeSum(long n) {
return LongStream.rangeClosed(1, n)
.parallel()
.reduce(0L, Long::sum);
}
System.out.println("Parallel range sum done in: " +
measureSumPerf(StreamPerformanceTest::parallelRangeSum, 10_000_000) + " msecs");
// Parallel range sum done in: 2 msecs
终于,我们得到了一个比顺序执行更快的并行归纳,因为这一次归纳操作可以像图那样执行了。这也表明,使用正确的数据结构然后使其并行工作能够保证最佳的性能
尽管如此,请记住,并行化并不是没有代价的。并行化过程本身需要对流做递归划分,把每个子流的归纳操作分配到不同的线程,然后把这些操作的结果合并成一个值。但在多个内核之间移动数据的代价也可能比你想的要大,所以很重要的一点是要保证在内核中并行执行工作的时间比在内核之间传输数据的时间长。
总而言之,很多情况下不可能或不方便并行化。然而,在使用并行Stream加速代码之前,你必须确保用得对;如果结果错了,算得快就毫无意义了。
分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。
示例:
/**
* @Description: RecursiveAction 没有返回值
* @Author Ray
* @Date 2020/7/14 0014 15:55
* @Version 1.0
*/
public class AccumulatorRecursiveAction extends RecursiveAction {
// 开始结束
private final int start;
private final int end;
// 数据
private final int[] data;
// 满3个拆分
private final int LIMIT = 3;
public AccumulatorRecursiveAction(int start, int end, int[] data) {
this.start = start;
this.end = end;
this.data = data;
}
@Override
protected void compute() {
if ((end - start) <= LIMIT) {
//int result = 0;
for (int i = start; i < end; i++) {
//result += data[i];
AccumulatorHelper.accumulate(data[i]);
}
//TODO
} else {
int mid = (start + end) / 2;
AccumulatorRecursiveAction left = new AccumulatorRecursiveAction(start, mid, data);
AccumulatorRecursiveAction right = new AccumulatorRecursiveAction(mid, end, data);
left.fork();
right.fork();
left.join();
right.join();
}
}
static class AccumulatorHelper {
private static final AtomicInteger result = new AtomicInteger(0);
static void accumulate(int value) {
result.getAndAdd(value);
}
public static int getResult() {
return result.get();
}
static void reset() {
result.set(0);
}
}
}
/**
* @Description: RecursiveTask 有返回值
* @Author Ray
* @Date 2020/7/14 0014 15:45
* @Version 1.0
*/
public class AccumulatorRecursiveTask extends RecursiveTask<Integer> {
// 开始结束
private final int start;
private final int end;
// 数据
private final int[] data;
// 满3个拆分
private final int LIMIT = 3;
public AccumulatorRecursiveTask(int start, int end, int[] data) {
this.start = start;
this.end = end;
this.data = data;
}
@Override
protected Integer compute() {
if ((end - start) <= LIMIT) {
int result = 0;
for (int i = start; i < end; i++) {
result += data[i];
}
return result;
}
// 中间的变量
int mid = (start + end) / 2;
AccumulatorRecursiveTask left = new AccumulatorRecursiveTask(start, mid, data);
AccumulatorRecursiveTask right = new AccumulatorRecursiveTask(mid, end, data);
left.fork();
Integer rightResult = right.compute();
Integer leftResult = left.join();
return rightResult + leftResult;
}
}
/**
* @Description: ForkJoin 分而治之(互不干扰,独立的)
* @Author Ray
* @Date 2020/7/14 0014 15:42
* @Version 1.0
*/
public class ForkJoinPoolTest {
private static int[] data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
public static void main(String[] args) {
System.out.println("calc result = " + calc());
AccumulatorRecursiveTask task = new AccumulatorRecursiveTask(0, data.length, data);
ForkJoinPool forkJoinPool = new ForkJoinPool();
Integer result = forkJoinPool.invoke(task);
System.out.println("AccumulatorRecursiveTask result = " + result);
AccumulatorRecursiveAction action = new AccumulatorRecursiveAction(0, data.length, data);
forkJoinPool.invoke(action);
System.out.println("AccumulatorRecursiveAction result = " + AccumulatorRecursiveAction.AccumulatorHelper.getResult());
}
/**
* @Description: 普通实现
* @Author: Ray
* @Date: 2020/7/14 0014 15:44
**/
private static int calc() {
int result = 0;
for (int i = 0; i < data.length; i++) {
result += data[i];
}
return result;
}
}
你值得尝试的第一种重构,也是简单的方式,是将实现单一抽象方法的匿名类转换为Lambda表达式。为什么呢?前面几章的介绍应该足以说服你,因为匿名类是极其繁琐且容易出错的。采用Lambda表达式之后,你的代码会更简洁,可读性更好。比如,第3章的例子就是一个创建Runnable对象的匿名类,这段代码及其对应的Lambda表达式实现如下:
Runnable r1 = new Runnable() { // 传统的方式,使用匿名类
public void run() {
System.out.println("Hello");
}
};
Runnable r2 = () -> System.out.println("Hello"); // 新的方式,使用Lambda表达式
但是某些情况下,将匿名类转换为Lambda表达式可能是一个比较复杂的过程 。
首先,匿名类和Lambda表达式中的this和super的含义是不同的。在匿名类中,this代表的是类自身,但是在Lambda中,它代表的是包含类。其次,匿名类可以屏蔽包含类的变量,而Lambda表达式不能(它们会导致编译错误) 。
int a = 10;
Runnable r1 = () -> {
int a = 2; // 编译错误!
System.out.println(a);
};
Runnable r2 = new Runnable() {
public void run() {
int a = 2; // 一切正常
System.out.println(a);
}
};
Lambda表达式非常适用于需要传递代码片段的场景。不过,为了改善代码的可读性,也请尽量使用方法引用。因为方法名往往能更直观地表达代码的意图。比如,第6章中我们曾经展示过下面这段代码,它的功能是按照食物的热量级别对菜肴进行分类:
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(Collectors.groupingBy(dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}));
你可以将Lambda表达式的内容抽取到一个单独的方法中,将其作为参数传递给groupingBy方法。变换之后,代码变得更加简洁,程序的意图也更加清晰了:
一、在 Dish 类中添加 getCaloricLevel 方法
public class Dish {
...
public enum CaloricLevel {DIET, NORMAL, FAT}
public CaloricLevel getCaloricLevel() {
if (this.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (this.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}
}
二、将 Lambda 表达式抽取到一个单独的方法中
Map<Dish.CaloricLevel, List<Dish>> dishesByCaloricLevel2 = menu.stream().collect(Collectors.groupingBy(Dish::getCaloricLevel));
除此之外,我们还应该尽量考虑使用静态辅助方法,比如comparing、maxBy。这些方法设计之初就考虑了会结合方法引用一起使用。通过示例,我们看到相对于第3章中的对应代码,优化过的代码更清晰地表达了它的设计意图:
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); // 你需要考虑如何实现比较算法
inventory.sort(comparing(Apple::getWeight)); // 读起来就像问题描述,非常清晰
策略模式代表了解决一类算法的通用解决方案,你可以在运行时选择使用哪种方案。
你可以将这一模式应用到更广泛的领域,比如使用不同的标准来验证输入的有效性,使用不同的方式来分析或者格式化输入。
策略模式包含三部分内容:
我们假设你希望验证输入的内容是否根据标准进行了恰当的格式化(比如只包含小写字母或数字)。你可以从定义一个验证文本(以String的形式表示)的接口入手:
public interface ValidationStrategy {
boolean execute(String s);
}
其次,你定义了该接口的一个或多个具体实现:
public class IsAllLowerCase implements ValidationStrategy {
@Override
public boolean execute(String s) {
return s.matches("[a-z]+");
}
}
public class IsNumeric implements ValidationStrategy {
@Override
public boolean execute(String s) {
return s.matches("\\d+");
}
}
之后,你就可以在你的程序中使用这些略有差异的验证策略了:
Validator numericValidator = new Validator(new IsNumeric());
boolean b1 = numericValidator.validate("aaa");
System.out.println("b1 = " + b1); // b1 = false
Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
boolean b2 = lowerCaseValidator.validate("bbb");
System.out.println("b2 = " + b2); // b2 = true
到现在为止,你应该已经意识到ValidationStrategy是一个函数接口了(除此之外,它还与Predicate<String>
具有同样的函数描述)。这意味着我们不需要声明新的类来实现不同的策略,通过直接传递Lambda表达式就能达到同样的目的,并且还更简洁:
Validator numericValidator2 = new Validator((String s) -> s.matches("\\d+"));
boolean b11 = numericValidator2.validate("aaa");
System.out.println("b11 = " + b11); // b11 = false
Validator lowerCaseValidator2 = new Validator((String s) -> s.matches("[a-z]+"));
boolean b22 = lowerCaseValidator2.validate("bbb");
System.out.println("b22 = " + b22); // b22 = true
正如你看到的,Lambda表达式避免了采用策略设计模式时僵化的模板代码。如果你仔细分析一下个中缘由,可能会发现,Lambda表达式实际已经对部分代码(或策略)进行了封装,而这就是创建策略设计模式的初衷。因此,我们强烈建议对类似的问题,你应该尽量使用Lambda表达式来解决。
如果你需要采用某个算法的框架,同时又希望有一定的灵活度,能对它的某些部分进行改进,那么采用模板方法设计模式是比较通用的方案。好吧,这样讲听起来有些抽象。换句话说,模板方法模式在你“希望使用这个算法,但是需要对其中的某些行进行改进,才能达到希望的效果”时是非常有用的。
让我们从一个例子着手,看看这个模式是如何工作的。假设你需要编写一个简单的在线银行应用。通常,用户需要输入一个用户账户,之后应用才能从银行的数据库中得到用户的详细信息,最终完成一些让用户满意的操作。不同分行的在线银行应用让客户满意的方式可能还略有不同,比如给客户的账户发放红利,或者仅仅是少发送一些推广文件。你可能通过下面的抽象类方式来实现在线银行应用:
abstract class OnlineBanking {
public void processCustomer(int id) {
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy(c);
}
abstract void makeCustomerHappy(Customer c);
}
processCustomer方法搭建了在线银行算法的框架:获取客户提供的ID,然后提供服务让用户满意。不同的支行可以通过继承OnlineBanking类,对该方法提供差异化的实现。
使用你偏爱的Lambda表达式同样也可以解决这些问题(创建算法框架,让具体的实现插入某些部分)。你想要插入的不同算法组件可以通过Lambda表达式或者方法引用的方式实现。
这里我们向processCustomer方法引入了第二个参数,它是一个Consumer<Customer>
类型的参数,与前文定义的makeCustomerHappy的特征保持一致:
public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy.accept(c);
}
现在,你可以很方便地通过传递Lambda表达式,直接插入不同的行为,不再需要继承OnlineBanking类了:
new OnlineBankingLambda().processCustomer(1337, (Customer c) ->
System.out.println("Hello " + c.getName());
这是又一个例子,佐证了Lamba表达式能帮助你解决设计模式与生俱来的设计僵化问题。
观察者模式是一种比较常见的方案,某些事件发生时(比如状态转变),如果一个对象(通常我们称之为主题)需要自动地通知其他多个对象(称为观察者),就会采用该方案。
让我们写点儿代码来看看观察者模式在实际中多么有用。你需要为Twitter这样的应用设计并实现一个定制化的通知系统。想法很简单:好几家报纸机构,比如《纽约时报》《卫报》以及《世界报》都订阅了新闻,他们希望当接收的新闻中包含他们感兴趣的关键字时,能得到特别通知。
首先,你需要一个观察者接口,它将不同的观察者聚合在一起。它仅有一个名为notify的方法,一旦接收到一条新的新闻,该方法就会被调用:
public interface Observer {
void notify(String tweet);
}
现在,你可以声明不同的观察者(比如,这里是三家不同的报纸机构),依据新闻中不同的关键字分别定义不同的行为:
public class NYTimes implements Observer {
@Override
public void notify(String tweet) {
if (tweet != null && tweet.contains("money")) {
System.out.println("Breaking news in NY! " + tweet);
}
}
}
public class LeMonde implements Observer {
@Override
public void notify(String tweet) {
if (tweet != null && tweet.contains("wine")) {
System.out.println("Today cheese, wine and news! " + tweet);
}
}
}
public class Guardian implements Observer {
@Override
public void notify(String tweet) {
if (tweet != null && tweet.contains("queen")) {
System.out.println("Yet another news in London... " + tweet);
}
}
}
你还遗漏了最重要的部分:Subject!让我们为它定义一个接口:
public interface Subject {
void registerObserver(Observer o); // 注册
void notifyObservers(String tweet); // 通知
}
Subject使用registerObserver方法可以注册一个新的观察者,使用notifyObservers方法通知它的观察者一个新闻的到来。让我们更进一步,实现Feed类:
public class Feed implements Subject {
private final List<Observer> observers = new ArrayList<>();
@Override
public void registerObserver(Observer o) {
this.observers.add(o);
}
@Override
public void notifyObservers(String tweet) {
observers.forEach(o -> o.notify(tweet));
}
public static void main(String[] args) {
Feed f = new Feed();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
String tweet = "The queen said her favourite book is Java 8 in Action!";
f.notifyObservers(tweet);
}
}
这是一个非常直观的实现:Feed类在内部维护了一个观察者列表,一条新闻到达时,它就进行通知。
毫不意外,《卫报》会特别关注这条新闻!
你可能会疑惑Lambda表达式在观察者设计模式中如何发挥它的作用。不知道你有没有注意到,Observer接口的所有实现类都提供了一个方法:notify。新闻到达时,它们都只是对同一段代码封装执行。Lambda表达式的设计初衷就是要消除这样的僵化代码。使用Lambda表达式后,你无需显式地实例化三个观察者对象,直接传递Lambda表达式表示需要执行的行为即可:
// lambda
Feed f2 = new Feed();
f2.registerObserver((String tweet2) -> {
if (tweet2 != null && tweet2.contains("money")) {
System.out.println("Breaking news in NY! " + tweet2);
}
});
f2.registerObserver((String tweet2) -> {
if (tweet2 != null && tweet2.contains("queen")) {
System.out.println("Yet another news in London... " + tweet2);
}
});
f2.notifyObservers(tweet);
那么,是否我们随时随地都可以使用Lambda表达式呢?答案是否定的!我们前文介绍的例子中,Lambda适配得很好,那是因为需要执行的动作都很简单,因此才能很方便地消除僵化代码。但是,观察者的逻辑有可能十分复杂,它们可能还持有状态,抑或定义了多个方法,诸如此类。在这些情形下,你还是应该继续使用类的方式。
责任链模式是一种创建处理对象序列(比如操作序列)的通用方案。一个处理对象可能需要在完成一些工作之后,将结果传递给另一个对象,这个对象接着做一些工作,再转交给下一个处理对象,以此类推。
通常,这种模式是通过定义一个代表处理对象的抽象类来实现的,在抽象类中会定义一个字段来记录后续对象。一旦对象完成它的工作,处理对象就会将它的工作转交给它的后继。代码中,这段逻辑看起来是下面这样:
public abstract class ProcessingObject<T> {
protected ProcessingObject<T> successor;
public void setSuccessor(ProcessingObject<T> successor) {
this.successor = successor;
}
public T handle(T input) {
T r = handleWork(input);
if (successor != null) {
return successor.handle(r);
}
return r;
}
abstract protected T handleWork(T input);
}
handle方法提供了如何进行工作处理的框架。不同的处理对象可以通过继承ProcessingObject类,提供handleWork方法来进行创建。
下面让我们看看如何使用该设计模式。你可以创建两个处理对象,它们的功能是进行一些文本处理工作。
public class HeaderTextProcessing extends ProcessingObject<String> {
@Override
protected String handleWork(String input) {
return "From Raoul, Mario and Alan: " + input;
}
}
public class SpellCheckerProcessing extends ProcessingObject<String> {
@Override
protected String handleWork(String input) {
return input.replaceAll("labda", "lambda");
}
}
现在你就可以将这两个处理对象结合起来,构造一个操作序列!
ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2); // 将两个处理对象链接起来
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println("result = " + result);
// result = From Raoul, Mario and Alan: Aren't lambdas really sexy?!!
稍等!这个模式看起来像是在链接(也即是构造) 函数。第3章中我们探讨过如何构造Lambda表达式。你可以将处理对象作为函数的一个实例,或者更确切地说作为UnaryOperator<String>
的一个实例。为了链接这些函数,你需要使用andThen方法对其进行构造。
UnaryOperator<String> headerProcessing = (String text) -> "From raoul, Mario and Alan: " + text; // 第一个处理对象
UnaryOperator<String> spellCheckerProcessing = (String text) -> text.replaceAll("labda", "lambda"); // 第二个处理对象
Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing); // 将两个方法结合起来,结果就是一个操作链
String result2 = pipeline.apply("Aren't labdas really sexy?!!");
System.out.println("result2 = " + result2);
使用工厂模式,你无需向客户暴露实例化的逻辑就能完成对象的创建。比如,我们假定你为一家银行工作,他们需要一种方式创建不同的金融产品:贷款、期权、股票,等等。
通常,你会创建一个工厂类,它包含一个负责实现不同对象的方法,如下所示:
public class ProductFactory {
public static Product createProduct(String name) {
switch (name) {
case "loan":
return new Loan();
case "stock":
return new Stock();
case "bond":
return new Bond();
default:
throw new RuntimeException("No such product " + name);
}
}
}
这里贷款(Loan)、股票(Stock)和债券(Bond)都是产品(Product)的子类。createProduct方法可以通过附加的逻辑来设置每个创建的产品。但是带来的好处也显而易见,你在创建对象时不用再担心会将构造函数或者配置暴露给客户,这使得客户创建产品时更加简单:
Product p = ProductFactory.createProduct("loan");
第3章中,我们已经知道可以像引用方法一样引用构造函数。比如,下面就是一个引用贷款(Loan)构造函数的示例:
Supplier<Product> loanSupplier = Loan::new;
Loan loan = loanSupplier.get();
通过这种方式,你可以重构之前的代码,创建一个Map,将产品名映射到对应的构造函数:
final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
map.put("loan", Loan::new);
map.put("stock", Stock::new);
map.put("bond", Bond::new);
}
现在,你可以像之前使用工厂设计模式那样,利用这个Map来实例化不同的产品。
public static Product createProduct(String name) {
Supplier<Product> p = map.get(name);
if (p != null) return p.get();
throw new IllegalArgumentException("No such product " + name);
}
这是个全新的尝试,它使用Java 8中的新特性达到了传统工厂模式同样的效果。但是,如果工厂方法createProduct需要接收多个传递给产品构造方法的参数,这种方式的扩展性不是很好。你不得不提供不同的函数接口,无法采用之前统一使用一个简单接口的方式。
Java 8中的接口现在
支持在声明方法的同时提供实现,这听起来让人惊讶!通过两种方式可以完成这种操作。其一,Java 8允许在接口内声明静态方法。其二,Java 8引入了一个新功能,叫默认方法,通过默认方法你可以指定接口方法的默认实现。换句话说,接口能提供方法的具体实现。因此,实现接口的类如果不显式地提供该方法的具体实现,就会自动继承默认的实现。这种机制可以使你平滑地进行接口的优化和演进。
我们看到的List接口中的sort方法是Java 8中全新的方法,它的定义如下:
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
请注意返回类型之前的新default修饰符。通过它,我们能够知道一个方法是否为默认方法。
简而言之,向接口添加方法是诸多问题的罪恶之源;一旦接口发生变化,实现这些接口的类往往也需要更新,提供新添方法的实现才能适配接口的变化。如果你对接口以及它所有相关的实现有完全的控制,这可能不是个大问题。但是这种情况是极少的。这就是引入默认方法的目的:它让类可以自动地继承接口的一个默认实现
如果你作为Java程序员曾经遭遇过NullPointerException,请举起手。如果这是你最常遭遇的异常,请继续举手。非常可惜,这个时刻,我们无法看到对方,但是我相信很多人的手这个时刻是举着的。我们还猜想你可能也有这样的想法:“毫无疑问,我承认,对任何一位Java程序员来说,无论是初出茅庐的新人,还是久经江湖的专家,NullPointerException都是他心中的痛,可是我们又无能为力,因为这就是我们为了使用方便甚至不可避免的像null引用这样的构造所付出的代价。”这就是程序设计世界里大家都持有的观点,然而,这可能并非事实的全部真相,只是我们根深蒂固的一种偏见。
假设你需要处理下面这样的嵌套对象,这是一个拥有汽车及汽车保险的客户。
public class Insurance {
private String name;
public String getName() {
return name;
}
}
public class Car {
private Insurance insurance;
public Insurance getInsurance() {
return insurance;
}
}
public class Person {
private Car car;
public Car getCar() {
return car;
}
}
那么,下面这段代码存在怎样的问题呢?
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
这段代码看起来相当正常,但是现实生活中很多人没有车。所以调用getCar方法的结果会怎样呢?在实践中,一种比较常见的做法是返回一个null引用,表示该值的缺失,即用户没有车。而接下来,对getInsurance的调用会返回null引用的insurance,这会导致运行时出现一个NullPointerException,终止程序的运行。但这还不是全部。如果返回的person值为null会怎样?如果getInsurance的返回值也是null,结果又会怎样?
怎样做才能避免这种不期而至的NullPointerException呢?通常,你可以在需要的地方添加null的检查(过于激进的防御式检查甚至会在不太需要的地方添加检测代码),并且添加的方式往往各有不同。下面这个例子是我们试图在方法中避免NullPointerException的第一次尝试
// 每个 null 检查都会增加调用链上剩余代码的嵌套层数
public String getCarInsuranceName1(Person person) {
if (person != null) {
Car car = person.getCar();
if (car != null) {
Insurance insurance = car.getInsurance();
if (insurance != null) {
return insurance.getName();
}
}
}
return "Unknown";
}
这个方法每次引用一个变量都会做一次null检查,如果引用链上的任何一个遍历的解变量值为null,它就返回一个值为“Unknown”的字符串。
我们将上面代码标记为“深层质疑”,原因是它不断重复着一种模式:每次你不确定一个变量是否为null时,都需要添加一个进一步嵌套的if块,也增加了代码缩进的层数。很明显,这种方式不具备扩展性,同时还牺牲了代码的可读性。面对这种窘境,你也许愿意尝试另一种方案。
// 每个 null 检查都会添加新的退出点
public String getCarInsuranceName2(Person person) {
if (person == null) {
return "Unknown";
}
Car car = person.getCar();
if (car == null) {
return "Unknown";
}
Insurance insurance = car.getInsurance();
if (insurance == null) {
return "Unknown";
}
return insurance.getName();
}
第二种尝试中,你试图避免深层递归的if语句块,采用了一种不同的策略:每次你遭遇null变量,都返回一个字符串常量“Unknown”。然而,这种方案远非理想,现在这个方法有了四个截然不同的退出点,使得代码的维护异常艰难。更糟的是,发生null时返回的默认值,即字符串“Unknown”在三个不同的地方重复出现——出现拼写错误的概率不小!当然,你可能会说,我们可以用把它们抽取到一个常量中的方式避免这种问题。
Java 8中引入了一个新的类java.util.Optional<T>
。这是一个封装Optional值的类。
举例来说,使用新的类意味着,如果你知道一个人可能有也可能没有车,那么Person类内部的car变量就不应该声明为Car,遭遇某人没有车时把null引用赋值给它,而是应该像图10-1那样直接将其声明为Optional<Car>
类型。
变量存在时,Optional类只是对类简单封装。变量不存在时,缺失的值会被建模成一个“空”的Optional对象,由方法Optional.empty()返回。Optional.empty()方法是一个静态工厂方法,它返回Optional类的特定单一实例。你可能还有疑惑,null引用和Optional.empty()有什么本质的区别吗?从语义上,你可以把它们当作一回事儿,但是实际中它们之间的差别非常大:如果你尝试解引用一个null,一定会触发NullPointerException,不过使用Optional.empty()就完全没事儿,它是Optional类的一个有效对象,多种场景都能调用,非常有用。关于这一点,接下来的部分会详细介绍。
使用Optional重新定义Person/Car的数据模型
public class CarOptional {
private Optional<Insurance> insurance; // 车可能进行了保险,也可能没有保险,所以将这个字段声明为Optional
public Optional<Insurance> getInsurance() {
return insurance;
}
}
public class PersonOptional {
private Optional<CarOptional> car; // 人可能有车,也可能没有车,因此将这个字段声明为Optional
public Optional<CarOptional> getCar() {
return car;
}
}
发现Optional是如何丰富你模型的语义了吧。代码中person引用的是Optional<Car>
,而car引用的是Optional<Insurance>
,这种方式非常清晰地表达了你的模型中一个person可能拥有也可能没有car的情形,同样,car可能进行了保险,也可能没有保险
与此同时,我们看到insurance公司的名称被声明成String类型,而不是Optional<String>
,这非常清楚地表明声明为insurance公司的类型必须提供公司名称。使用这种方式,一旦解引用insurance公司名称时发生NullPointerException,你就能非常确定地知道出错的原因,不再需要为其添加null的检查,因为null的检查只会掩盖问题,并未真正地修复问题。insurance公司必须有个名字,所以,如果你遇到一个公司没有名称,你需要调查你的数据出了什么问题,而不应该再添加一段代码,将这个问题隐藏。
使用Optional之前,你首先需要学习的是如何创建Optional对象。完成这一任务有多种方法。
正如前文已经提到,你可以通过静态工厂方法Optional.empty
,创建一个空的Optional对象:
Optional<Car> optCar = Optional.empty();
你还可以使用静态工厂方法Optional.of
,依据一个非空值创建一个Optional对象:
Car car = null;
Optional<Car> optCar = Optional.of(car);
如果car是一个null,这段代码会立即抛出一个NullPointerException,而不是等到你试图访问car的属性值时才返回一个错误。
最后,使用静态工厂方法Optional.ofNullable
,你可以创建一个允许null值的Optional对象:
Car car = null;
Optional<Car> optCar = Optional.ofNullable(car);
如果car是null,那么得到的Optional对象就是个空对象
从对象中提取信息是一种比较常见的模式。比如,你可能想要从insurance公司对象中提取公司的名称。提取名称之前,你需要检查insurance对象是否为null,代码如下所示:
String name = null;
if (insurance != null) {
name = insurance.getName();
}
为了支持这种模式,Optional提供了一个map方法。它的工作方式如下
Insurance insurance = new Insurance();
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
从概念上,这与我们在第4章和第5章中看到的流的map方法相差无几。map操作会将提供的函数应用于流的每个元素。你可以把Optional对象看成一种特殊的集合数据,它至多包含一个元素。如果Optional包含一个值,那函数就将该值作为参数传递给map,对该值进行转换。如果Optional为空,就什么也不做。图10-2对这种相似性进行了说明,展示了把一个将正方形转换为三角形的函数,分别传递给正方形和Optional正方形流的map方法之后的结果。
前文的代码里用安全的方式链接了多个方法。
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
为了达到这个目的,我们需要求助Optional提供的另一个方法flatMap。
由于我们刚刚学习了如何使用map,你的第一反应可能是我们可以利用map重写之前的代码,如下所示:
PersonOptional person = null;
Optional<PersonOptional> optPerson = Optional.ofNullable(person);
Optional<String> name = optPerson
.map(PersonOptional::getCar)
.map(CarOptional::getInsurance)
.map(Insurance::getName);
不幸的是,这段代码无法通过编译。为什么呢?optPerson是Optional<Person>
类型的变量, 调用map方法应该没有问题。但getCar返回的是一个Optional<Car>
类型的对象,这意味着map操作的结果是一个Optional<Optional<Car>>
类型的对象。因此,它对getInsurance的调用是非法的,因为最外层的optional对象包含了另一个optional对象的值,而它当然不会支持getInsurance方法。图10-3说明了你会遭遇的嵌套式optional结构。
所以,我们该如何解决这个问题呢?让我们再回顾一下你刚刚在流上使用过的模式:flatMap方法。使用流时,flatMap方法接受一个函数作为参数,这个函数的返回值是另一个流。这个方法会应用到流中的每一个元素,最终形成一个新的流的流。但是flagMap会用流的内容替换每个新生成的流。换句话说,由方法生成的各个流会被合并或者扁平化为一个单一的流。这里你希望的结果其实也是类似的,但是你想要的是将两层的optional合并为一个。
这个例子中,传递给流的flatMap方法会将每个正方形转换为另一个流中的两个三角形。那么,map操作的结果就包含有三个新的流,每一个流包含两个三角形,但flatMap方法会将这种两层的流合并为一个包含六个三角形的单一流。类似地,传递给optional的flatMap方法的函数会将原始包含正方形的optional对象转换为包含三角形的optional对象。如果将该方法传递给map方法,结果会是一个Optional对象,而这个Optional对象中包含了三角形;但flatMap方法会将这种两层的Optional对象转换为包含三角形的单一Optional对象。
public String getCarInsuranceNameOptional(Optional<PersonOptional> person) {
return person
.flatMap(PersonOptional::getCar) // Optional<com.ray.xxx.Car>
.flatMap(CarOptional::getInsurance) // Optional<com.ray.xxx.Insurance>
.map(Insurance::getName) // Optional<java.lang.String>
.orElse("Unknown"); // 如果Optional的结果值为空,设置默认值
}
由Optional<Person>
对象,我们可以结合使用之前介绍的map和flatMap方法,从Person中解引用出Car,从Car中解引用出Insurance,从Insurance对象中解引用出包含insurance公司名称的字符串。
我们决定采用orElse方法读取这个变量的值,使用这种方式你还可以定义一个默认值,遭遇空的Optional变量时,默认值会作为该方法的调用返回值。
Optional类提供了多种方法读取Optional实例中的变量值。
变量值 | 说明 |
---|---|
get() | get()是这些方法中最简单但又最不安全的方法。如果变量存在,它直接返回封装的变量值,否则就抛出一个NoSuchElementException异常。所以,除非你非常确定Optional变量一定包含值,否则使用这个方法是个相当糟糕的主意。此外,这种方式即便相对于嵌套式的null检查,也并未体现出多大的改进。 |
orElse(T other) | orElse(T other)正如之前提到的,它允许你在Optional对象不包含值时提供一个默认值。 |
orElseGet(Supplier<? extends T> other) | orElseGet(Supplier<? extends T> other)是orElse方法的延迟调用版,Supplier方法只有在Optional对象不含值时才执行调用。如果创建默认值是件耗时费力的工作,你应该考虑采用这种方式(借此提升程序的性能),或者你需要非常确定某个方法仅在Optional为空时才进行调用,也可以考虑该方式(这种情况有严格的限制条件) |
orElseThrow(Supplier<? extends X> exceptionSupplier) | orElseThrow(Supplier<? extends X> exceptionSupplier)和get方法非常类似,它们遭遇Optional对象为空时都会抛出一个异常,但是使用orElseThrow你可以定制希望抛出的异常类型。 |
ifPresent(Consumer<? super T>) | ifPresent(Consumer<? super T>)让你能在变量值存在时执行一个作为参数传入。 |
Optional类和Stream接口的相似之处,远不止map和flatMap这两个方法。还有第三个方法filter,它的行为在两种类型之间也极其相似
你经常需要调用某个对象的方法,查看它的某些属性。比如,你可能需要检查保险公司的名称是否为“Cambridge-Insurance”。为了以一种安全的方式进行这些操作,你首先需要确定引用指向的Insurance对象是否为null,之后再调用它的getName方法,如下所示:
String str = "CambridgeInsurance";
Insurance insurance = null;
if (insurance != null && str.equals(insurance.getName())) {
System.out.println("ok");
}
使用Optional对象的filter方法,这段代码可以重构如下:
String str = "CambridgeInsurance";
Optional<Insurance> optInsurance = null;
optInsurance.filter(i -> str.equals(i.getName()))
.ifPresent(x -> System.out.println("ok"));
filter方法接受一个谓词作为参数。如果Optional对象的值存在,并且它符合谓词的条件,filter方法就返回其值;否则它就返回一个空的Optional对象。如果你还记得我们可以将Optional看成最多包含一个元素的Stream对象,这个方法的行为就非常清晰了。如果Optional对象为空,它不做任何操作,反之,它就对Optional对象中包含的值施加谓词操作。如果该操作的结果为true,它不做任何改变,直接返回该Optional对象,否则就将该值过滤掉,将Optional的值置空。
Optional类中的方法进行了分类和概括。
方法 | 描述 |
---|---|
empty | 返回一个空的Optional实例 |
filter | 如果值存在并且满足提供的谓词,就返回包含该值的Optional对象;否则返回一个空的Optional对象 |
flatMap | 如果值存在,就对该值执行提供的mapping函数调用,返回一个Optional类型的值,否则就返回一个空的Optional对象 |
get | 如果该值存在,将该值用Optional封装返回,否则抛出一个NoSuchElementException异常 |
ifPresent | 如果值存在,就执行使用该值的方法调用,否则什么也不做 |
isPresent | 如果值存在就返回true,否则返回false |
map | 如果值存在,就对该值执行提供的mapping函数调用 |
of | 将指定值用Optional封装之后返回,如果该值为null,则抛出一个NullPointerException异常 |
ofNullable | 将指定值用Optional封装之后返回,如果该值为null,则返回一个空的Optional对象 |
orElse | 如果有值则将其返回,否则返回一个默认值 |
orElseGet | 如果有值则将其返回,否则返回一个由指定的Supplier接口生成的值 |
orElseThrow | 如果有值则将其返回,否则抛出一个由指定的Supplier接口生成的异常 |
比如,如果Map中不含指定的键对应的值,它的get方法会返回一个null。但是,正如我们之前介绍的,大多数情况下,你可能希望这些方法能返回一个Optional对象。你无法修改这些方法的签名,但是你很容易用Optional对这些方法的返回值进行封装。我们接着用Map做例子,假设你有一个Map<String, Object>方法,访问由key索引的值时,如果map中没有与key关联的值,该次调用就会返回一个null。
Object value = map.get("key");
使用Optional封装map的返回值,你可以对这段代码进行优化。要达到这个目的有两种方式:你可以使用笨拙的if-then-else判断语句,毫无疑问这种方式会增加代码的复杂度;或者你可以采用我们前文介绍的Optional.ofNullable
方法:
Optional<Object> value = Optional.ofNullable(map.get("key"));
每次你希望安全地对潜在为null的对象进行转换,将其替换为Optional对象时,都可以考虑使用这种方法。
Future接口在Java 5中被引入,设计初衷是对将来某个时刻会发生的结果进行建模。它建模了一种异步计算,返回一个执行运算结果的引用,当运算结束后,这个引用被返回给调用方。在Future中触发那些潜在耗时的操作把调用线程解放出来,让它能继续执行其他有价值的工作,不再需要呆呆等待耗时的操作完成。
打个比方,你可以把它想象成这样的场景:你拿了一袋子衣服到你中意的干洗店去洗。干洗店的员工会给你张发票,告诉你什么时候你的衣服会洗好(这就是一个Future事件)。衣服干洗的同时,你可以去做其他的事情。
Future的另一个优点是它比更底层的Thread更易用。要使用Future,通常你只需要将耗时的操作封装在一个Callable对象中,再将它提交给ExecutorService,就万事大吉了。
下面这段代码展示了Java 8之前使用Future的一个例子。
public class FutureDemo1 {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool(); // 创建Executor-Service,通过它你可以向线程池提交任务
Future<Double> future = executor.submit(new Callable<Double>() { // 向Executor-Service提交一个Callable对象
@Override
public Double call() throws Exception {
return doSomeLongComputation(); // 以异步方式在新的线程中执行耗时的操作
}
});
doSomethingElse(); // 异步操作进行的同时,你可以做其他的事情
try {
Double result = future.get(1, TimeUnit.SECONDS); // 获取异步操作的结果,如果最终被阻塞,无法得到结果,那么在最多等待1秒钟之后退出
System.out.println("result = " + result);
} catch (ExecutionException ee) {
// 计算抛出一个异常
} catch (InterruptedException ie) {
// 当前线程在等待过程中被中断
} catch (TimeoutException te) {
// 在Future对象完成之前超过已过期
}
}
private static void doSomethingElse() {
System.out.println("doSomethingElse start");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("doSomethingElse end");
}
public static Double doSomeLongComputation() {
System.out.println("doSomeLongComputation start");
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("doSomeLongComputation end");
return 1.0;
}
}
这种编程方式让你的线程可以在ExecutorService以并发方式调用另一个线程执行耗时操作的同时,去执行一些其他的任务。接着,如果你已经运行到没有异步操作的结果就无法继续任何有意义的工作时,可以调用它的get方法去获取操作的结果。如果操作已经完成,该方法会立刻返回操作的结果,否则它会阻塞你的线程,直到操作完成,返回相应的结果。
你能想象这种场景存在怎样的问题吗?如果该长时间运行的操作永远不返回了会怎样?为了处理这种可能性,虽然Future提供了一个无需任何参数的get方法,我们还是推荐大家使用重载版本的get方法,它接受一个超时的参数,通过它,你可以定义你的线程等待Future结果的最长时间,而不是永无止境地等待下去。
为了展示CompletableFuture的强大特性,我们会创建一个名为“最佳价格查询器”(best-price-finder)的应用,它会查询多个在线商店,依据给定的产品或服务找出最低的价格。这个过程中,你会学到几个重要的技能。
为了实现最佳价格查询器应用,让我们从每个商店都应该提供的API定义入手。首先,商店应该声明依据指定产品名称返回价格的方法:
public class Shop {
public double getPrice(String product) {
// 待实现
return 0.0;
}
}
该方法的内部实现会查询商店的数据库,但也有可能执行一些其他耗时的任务,比如联系其他外部服务(比如,商店的供应商,或者跟制造商相关的推广折扣)。我们在本章剩下的内容中,采用delay方法模拟这些长期运行的方法的执行,它会人为地引入1秒钟的延迟,方法声明如下。
public static void delay() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
//e.printStackTrace();
throw new RuntimeException(e);
}
}
为了介绍本章的内容,getPrice方法会调用delay方法,并返回一个随机计算的值,代码清单如下所示。返回随机计算的价格这段代码看起来有些取巧。它使用charAt,依据产品的名称,生成一个随机值作为价格。
private static final Random RANDOM = new Random(System.currentTimeMillis());
public double getPrice(String product) {
return calculatePrice(product);
}
private double calculatePrice(String product) {
delay();
return RANDOM.nextDouble() * product.charAt(0) + product.charAt(1);
}
很明显,这个API的使用者(这个例子中为最佳价格查询器)调用该方法时,它依旧会被阻塞。为等待同步事件完成而等待1秒钟,这是无法接受的,尤其是考虑到最佳价格查询器对网络中的所有商店都要重复这种操作。
为了实现这个目标,你首先需要将getPrice转换为getPriceAsync方法,并修改它的返回值:
public Future<Double> getPriceAsync(String product) { ... }
Java 5 引入了java.util.concurrent.Future接口表示一个异步计算(即调用线程可以继续运行,不会因为调用方法而阻塞)的结果。这意味着Future是一个暂时还不可知值的处理器,这个值在计算完成后,可以通过调用它的get方法取得。因为这样的设计,getPriceAsync方法才能立刻返回,给调用线程一个机会,能在同一时间去执行其他有价值的计算任务。新的CompletableFuture类提供了大量的方法,让我们有机会以多种可能的方式轻松地实现这个方法,比如下面就是这样一段实现代码。
public Future<Double> getPriceAsync(String product) {
CompletableFuture<Double> futurePrice = new CompletableFuture<>(); // 创建CompletableFuture对象,它会包含计算的结果
new Thread(() -> { // 在另一个线程中以异步方式执行计算
double price = calculatePrice(product);
futurePrice.complete(price); // 需长时间计算的任务结束并得出结果时,设置Future的返回值
}).start();
return futurePrice; // 无需等待还没结束的计算,直接返回Future对象
}
在这段代码中,你创建了一个代表异步计算的CompletableFuture对象实例,它在计算完成时会包含计算的结果。接着,你调用fork创建了另一个线程去执行实际的价格计算工作,不等该耗时计算任务结束,直接返回一个Future实例。当请求的产品价格最终计算得出时,你可以使用它的complete方法,结束completableFuture对象的运行,并设置变量的值。很显然,这个新版Future的名称也解释了它所具有的特性。使用这个API的客户端,可以通过下面的这段代码对其进行调用。
private static void doSomethingElse() {
System.out.println("doSomethingElse start");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("doSomethingElse end");
}
public static void main(String[] args) {
String product = "pig";
Shop shop = new Shop();
// 同步方式,必须等到getPrice方法执行完毕才能执行doSomethingElse方法
/*double price = shop.getPrice(product);
System.out.println("price = " + price);
doSomethingElse();*/
// 异步方式,立刻返回Future对象,继续执行doSomethingElse方法,执行完成后再执行Future的get方法
Future<Double> futurePrice = shop.getPriceAsync(product);
// 可以干其他事情了
doSomethingElse();
try {
Double priceAsync = futurePrice.get();
System.out.println("priceAsync = " + priceAsync);
} catch (Exception e) {
e.printStackTrace();
}
}
我们看到这段代码中,客户向商店查询了某种商品的价格。由于商店提供了异步API,该次调用立刻返回了一个Future对象,通过该对象客户可以在将来的某个时刻取得商品的价格。
这种方式下,客户在进行商品价格查询的同时,还能执行一些其他的任务,比如查询其他家商店中商品的价格,不会呆呆地阻塞在那里等待第一家商店返回请求的结果。
最后,如果所有有意义的工作都已经完成,客户所有要执行的工作都依赖于商品价格时,再调用Future的get方法。执行了这个操作后,客户要么获得Future中封装的值(如果异步任务已经完成),要么发生阻塞,直到该异步任务完成,期望的值能够访问。
你可以通过静态工厂方法of创建一个LocalDate实例。LocalDate实例提供了多种方法来读取常用的值,比如年份、月份、星期几等,如下所示。
LocalDate date = LocalDate.of(2014, 3, 18); //2014-03-18
int year = date.getYear(); //2014
Month month = date.getMonth(); //MARCH
int day = date.getDayOfMonth(); //18
DayOfWeek dow = date.getDayOfWeek(); //TUESDAY
int len = date.lengthOfMonth(); //31 (days in March)
boolean leap = date.isLeapYear(); //false (not a leap year)
你还可以使用工厂方法从系统时钟中获取当前的日期:
LocalDate today = LocalDate.now();
类似地,一天中的时间,比如13:45:20,可以使用LocalTime类表示。你可以使用of重载的两个工厂方法创建LocalTime的实例。第一个重载函数接收小时和分钟,第二个重载函数同时还接收秒。同LocalDate一样,LocalTime类也提供了一些getter方法访问这些变量的值,如下所示
LocalTime time = LocalTime.of(13, 45, 20); //13:45:20
int hour = time.getHour(); //13
int minute = time.getMinute(); //45
int second = time.getSecond(); //20
LocalDate和LocalTime都可以通过解析代表它们的字符串创建。使用静态方法parse,你可以实现这一目的:
LocalDate dateParse = LocalDate.parse("2014-03-18");
LocalTime timeParse = LocalTime.parse("13:45:20");
你可以向parse方法传递一个DateTimeFormatter。该类的实例定义了如何格式化一个日期或者时间对象。正如我们之前所介绍的,它是替换老版java.util.DateFormat的推荐替代品。
这个复合类名叫LocalDateTime,是LocalDate和LocalTime的合体。它同时表示了日期和时间,但不带有时区信息,你可以直接创建,也可以通过合并日期和时间对象构造,如下所示。
LocalDate date = LocalDate.parse("2014-03-18");
LocalTime time = LocalTime.parse("13:45:20");
// 2014-03-18T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);
注意,通过它们各自的atTime或者atDate方法,向LocalDate传递一个时间对象,或者向LocalTime传递一个日期对象的方式,你可以创建一个LocalDateTime对象。你也可以使用toLocalDate或者toLocalTime方法,从LocalDateTime中提取LocalDate或 者LocalTime组件:
LocalDate date1 = dt1.toLocalDate(); //2014-03-18
LocalTime time1 = dt1.toLocalTime(); //13:45:20
如果你已经有一个LocalDate对象,想要创建它的一个修改版,最直接也最简单的方法是使用withAttribute方法。withAttribute方法会创建对象的一个副本,并按照需要修改它的属性。注意,下面的这段代码中所有的方法都返回一个修改了属性的对象。它们都不会修改原来的对象!
以比较直观的方式操纵LocalDate的属性
LocalDate date1 = LocalDate.of(2014, 3, 18); //2014-03-18
LocalDate date2 = date1.withYear(2011); //2011-03-18
LocalDate date3 = date2.withDayOfMonth(25); //2011-03-25
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 9); //2011-09-25
它甚至能以声明的方式操纵LocalDate对象。比如,你可以像下面这段代码那样加上或者减去一段时间。
以相对方式修改LocalDate对象的属性
LocalDate date1 = LocalDate.of(2014, 3, 18); //2014-03-18
LocalDate date2 = date1.plusWeeks(1); //2014-03-25
LocalDate date3 = date2.minusYears(3); //2011-03-25
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); //2011-09-25
处理日期和时间对象时,格式化以及解析日期-时间对象是另一个非常重要的功能。新的java.time.format包就是特别为这个目的而设计的。这个包中,最重要的类是DateTimeFormatter。创建格式器最简单的方法是通过它的静态工厂方法以及常量。像BASIC_ISO_DATE和ISO_LOCAL_DATE这样的常量是DateTimeFormatter类的预定义实例。所有的DateTimeFormatter实例都能用于以一定的格式创建代表特定日期或时间的字符串。比如,下面的这个例子中,我们使用了两个不同的格式器生成了字符串:
LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); //20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); //2014-03-18
你也可以通过解析代表日期或时间的字符串重新创建该日期对象。所有的日期和时间API都提供了表示时间点或者时间段的工厂方法,你可以使用工厂方法parse达到重创该日期对象的目的
LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);
和老的java.util.DateFormat相比较,所有的DateTimeFormatter实例都是线程安全的。所以,你能够以单例模式创建格式器实例,就像DateTimeFormatter所定义的那些常量,并能在多个线程间共享这些实例。DateTimeFormatter类还支持一个静态工厂方法,它可以按照某个特定的模式创建格式器
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);
这段代码中,LocalDate的formate方法使用指定的模式生成了一个代表该日期的字符串。紧接着,静态的parse方法使用同样的格式器解析了刚才生成的字符串,并重建了该日期对象。ofPattern方法也提供了一个重载的版本,使用它你可以创建某个Locale的格式器
DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date.format(italianFormatter); // 18. marzo 2014
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);
最后,如果你还需要更加细粒度的控制,DateTimeFormatterBuilder类还提供了更复杂的格式器,你可以选择恰当的方法,一步一步地构造自己的格式器。另外,它还提供了非常强大的解析功能,比如区分大小写的解析、柔性解析(允许解析器使用启发式的机制去解析输入,不精确地匹配指定的模式)、填充,以及在格式器中指定可选节。