本章内容
- 如何使用Lambda表达式重构代码
- Lambda表达式对面向对象的设计模式的影响
- Lambda表达式的测试
- 如何调试使用Lambda表达式和Stream API的代码
1. 为改善可读性和灵活性重构代码
用“更简洁”来描述Lambda表达式是因为相较于匿名类,Lambda表达式可以帮助我们用更紧凑的方式描述程序的行为。
采用Lambda表达式之后,你的代码会变得更加灵活,因为Lambda表达式鼓励大家使用前面介绍过的行为参数化的方式。在这种方式下,应对需求的变化时,你的代码可以依据传入的参数动态选择和执行相应的行为。
1.1 改善代码的可读性
改善代码的可读性到底意味着什么?我们很难定义什么是好的可读性,因为这可能非常主观。通常的理解是,“别人理解这段代码的难易程度”。改善可读性意味着你要确保你的代码能非常容易地被包括自己在内的所有人理解和维护。为了确保你的代码能被其他人理解,有几个步骤可以尝试,比如确保你的代码附有良好的文档,并严格遵守编程规范。
跟之前的版本相比较,Java 8的新特性也可以帮助提升代码的可读性:
- 使用Java 8,你可以减少冗长的代码,让代码更易于理解
- 通过方法引用和Stream API,你的代码会变得更直观
这里我们会介绍三种简单的重构,利用Lambda表达式、方法引用以及Stream改善程序代码的可读性:
- 重构代码,用Lambda表达式取代匿名类
- 用方法引用重构Lambda表达式
- 用Stream API重构命令式的数据处理
1.2 从匿名类到 Lambda 表达式的转换
你值得尝试的第一种重构,也是简单的方式,是将实现单一抽象方法的匿名类转换为Lambda表达式。为什么呢?前面几章的介绍应该足以说服你,因为匿名类是极其繁琐且容易出错的。采用Lambda表达式之后,你的代码会更简洁,可读性更好。
创建Runnable对象的匿名类,这段代码及其对应的Lambda表达式实现如下:
// 传统方式
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
//使用lambda表达式
Runnable r2 = () -> {
System.out.println("hello1");
};
但是某些情况下,将匿名类转换为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表达式可能导致最终的代码更加晦涩。实际上,匿名类的类型是在初始化时确定的,而Lambda的类型取决于它的上下文。通过下面这个例子,我们可以了解问题是如何发生的。我们假设你用与Runnable同样的签名声明了一个函数接口,我们称之为Task(你希望采用与你的业务模型更贴切的接口名时,就可能做这样的变更):
interface Task{
public void execute();
}
public static void doSomething(Runnable r){
r.run(); }
public static void doSomething(Task a){
a.execute(); }
现在,你再传递一个匿名类实现的Task,不会碰到任何问题:
doSomething(new Task() {
public void execute() {
System.out.println("Danger danger!!");
}
});
但是将这种匿名类转换为Lambda表达式时,就导致了一种晦涩的方法调用,因为Runnable和Task都是合法的目标类型:
//麻烦来了: doSomething(Runnable) 和doSomething(Task)都匹配该类型
doSomething(() -> System.out.println("Danger danger!!"));
你可以对Task尝试使用显式的类型转换来解决这种模棱两可的情况:
doSomething((Task)() -> System.out.println("Danger danger!!"));
1.3 从 Lambda 表达式到方法引用的转换
Lambda表达式非常适用于需要传递代码片段的场景。不过,为了改善代码的可读性,也请尽量使用方法引用。因为方法名往往能更直观地表达代码的意图。
1.4 从命令式的数据处理切换到 Stream
;我们建议你将所有使用迭代器这种数据处理模式处理集合的代码都转换成Stream API的方式。为什么呢?Stream API能更清晰地表达数据处理管道的意图。除此之外,通过短路和延迟载入以及利用现代计算机的多核架构,我们可以对Stream进行优化。
比如,下面的命令式代码使用了两种模式:筛选和抽取,这两种模式被混在了一起,这样的代码结构迫使程序员必须彻底搞清楚程序的每个细节才能理解代码的功能。此外,实现需要并行运行的程序所面对的困难也多得多.。
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu){
if(dish.getCalories() > 300){
dishNames.add(dish.getName());
}
}
替代方案使用Stream API,采用这种方式编写的代码读起来更像是问题陈述,并行化也非常容易:
menu.parallelStream()
.filter(d -> d.getCalories() > 300)
.map(Dish::getName)
.collect(toList());
不幸的是,将命令式的代码结构转换为Stream API的形式是个困难的任务,因为你需要考虑控制流语句,比如break、continue、return,并选择使用恰当的流操作。好消息是已经有一些工具可以帮助我们完成这个任务。
1.5 增加代码的灵活性
我们曾经介绍过Lambda表达式有利于行为参数化。你可以使用不同的Lambda表示不同的行为,并将它们作为参数传递给函数去处理执行。这种方式可以帮助我们淡定从容地面对需求的变化。比如,我们可以用多种方式为Predicate创建筛选条件,或者使用Comparator对多种对象进行比较。现在,我们来看看哪些模式可以马上应用到你的代码中,让你享受Lambda表达式带来的便利。
1. 采用函数接口
首先,你必须意识到,没有函数接口,你就无法使用Lambda表达式。因此,你需要在代码中引入函数接口。听起来很合理,但是在什么情况下使用它们呢?这里我们介绍两种通用的模式,你可以依照这两种模式重构代码,利用Lambda表达式带来的灵活性,它们分别是:有条件的延迟执行和环绕执行。除此之外,在下一节,我们还将介绍一些基于面向对象的设计模式,比如策略模式或者模板方法,这些在使用Lambda表达式重写后会更简洁。
2. 有条件的延迟执行
我们经常看到这样的代码,控制语句被混杂在业务逻辑代码之中。典型的情况包括进行安全性检查以及日志输出。比如,下面的这段代码,它使用了Java语言内置的Logger类:
if (logger.isLoggable(Log.FINER)){
logger.finer("Problem: " + generateDiagnostic());
}
更好的方案是使用log方法,该方法在输出日志消息之前,会在内部检查日志对象是否已经设置为恰当的日志等级。
这种方式更好的原因是你不再需要在代码中插入那些条件判断,与此同时日志器的状态也不再被暴露出去。不过,这段代码依旧存在一个问题。日志消息的输出与否每次都需要判断,即使你已经传递了参数,不开启日志。
这就是Lambda表达式可以施展拳脚的地方。你需要做的仅仅是延迟消息构造,如此一来,日志就只会在某些特定的情况下才开启(以此为例,当日志器的级别设置为FINER时)。显然,Java 8的API设计者们已经意识到这个问题,并由此引入了一个对log方法的重载版本,这个版本的log方法接受一个Supplier作为参数。这个替代版本的log方法的函数签名如下:
public void log(Level level, Supplier<String> msgSupplier)
你可以通过下面的方式对它进行调用:
logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
如果日志器的级别设置恰当,log方法会在内部执行作为参数传递进来的Lambda表达式。这里介绍的Log方法的内部实现如下:
public void log(Level level, Supplier<String> msgSupplier){
if(logger.isLoggable(level)){
log(level, msgSupplier.get());
}
}
从这个故事里我们学到了什么呢?如果你发现你需要频繁地从客户端代码去查询一个对象的状态(比如前文例子中的日志器的状态),只是为了传递参数、调用该对象的一个方法(比如输出一条日志),那么可以考虑实现一个新的方法,以Lambda或者方法表达式作为参数,新方法在检查完该对象的状态之后才调用原来的方法。你的代码会因此而变得更易读(结构更清晰),封装性更好(对象的状态也不会暴露给客户端代码了)。
3.环绕执行
如果你发现虽然你的业务代码千差万别,但是它们拥有同样的准备和清理阶段,这时,你完全可以将这部分代码用Lambda实现。这种方式的好处是可以重用准备和清理阶段的逻辑,减少重复冗余的代码。下面这段代码
你在前面已经看过,我们再回顾一次。它在打开和关闭文件时使用了同样的逻辑,但在处理文件时可以使用不同的Lambda进行参数化。
2. 使用 Lambda 重构面向对象的设计模式
新的语言特性常常让现存的编程模式或设计黯然失色。比如, Java 5中引入了for-each循环,由于它的稳健性和简洁性,已经替代了很多显式使用迭代器的情形。Java 7中推出的菱形操作符(<>)让大家在创建实例时无需显式使用泛型,一定程度上推动了Java程序员们采用类型接(type interface)进行程序设计。
对设计经验的归纳总结被称为设计模式。设计软件时,如果你愿意,可以复用这些方式方法来解决一些常见问题。这看起来像传统建筑工程师的工作方式,对典型的场景(比如悬挂桥、拱桥等)都定义有可重用的解决方案。例如,访问者模式常用于分离程序的算法和它的操作对象。单例模式一般用于限制类的实例化,仅生成一份对象。
Lambda表达式为程序员的工具箱又新添了一件利器。它们为解决传统设计模式所面对的问题提供了新的解决方案,不但如此,采用这些方案往往更高效、更简单。使用Lambda表达式后,很多现存的略显臃肿的面向对象设计模式能够用更精简的方式实现了。这一节中,我们会针对五个设计模式展开讨论,它们分别是:
- 策略模式
- 模板方法
- 观察者模式
- 责任链模式
- 工厂模式
我们会展示Lambda表达式是如何另辟蹊径解决设计模式原来试图解决的问题的。
2.1 策略模式
策略模式代表了解决一类算法的通用解决方案,你可以在运行时选择使用哪种方案。在前面你已经简略地了解过这种模式了,当时我们介绍了如何使用不同的条件(比如苹果的重量,或者颜色)来筛选库存中的苹果。你可以将这一模式应用到更广泛的领域,比如使用不同的标准来验证输入的有效性,使用不同的方式来分析或者格式化输入。
策略模式包含三部分内容:
- 一个代表某个算法的接口(它是策略模式的接口)。
- 一个或多个该接口的具体实现,它们代表了算法的多种实现。
- 一个或多个使用策略对象的客户。
我们假设你希望验证输入的内容是否根据标准进行了恰当的格式化(比如只包含小写字母或数字)。你可以从定义一个验证文本(以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 IsNumberic implements ValidationStrategy {
@Override
public boolean execute(String s) {
return s.matches("\\d+");
}
}
之后,你就可以在你的程序中使用这些略有差异的验证策略了:
public class Validator {
private final ValidationStrategy strategy;
public Validator(ValidationStrategy strategy) {
this.strategy = strategy;
}
public boolean validate(String s) {
return strategy.execute(s);
}
}
测试:
public class ValidatorDemo {
public static void main(String[] args) {
Validator numbericValidator = new Validator(