9.1 为改善可读性和灵活性重构代码

从本书的开篇我们就一直在强调,利用Lambda表达式,你可以写出更简洁、更灵活的代码。用“更简洁”来描述Lambda表达式是因为相较于匿名类,Lambda表达式可以帮助我们用更紧凑的方式描述程序的行为。第3章中也提到过,如果你希望将一个既有的方法作为参数传递给另一个方法,那么方法引用无疑是我们推荐的方法,利用这种方法能写出非常简洁的代码。

采用Lambda表达式之后,你的代码会变得更加灵活,因为Lambda表达式鼓励大家使用第2章中介绍过的行为参数化的方式。在这种方式下,应对需求的变化时,你的代码可以依据传入的参数动态选择和执行相应的行为。

这一节会将所有这些综合在一起,通过例子展示如何运用前几章介绍的Lambda表达式、方法引用以及Stream接口等特性重构遗留代码,改善程序的可读性和灵活性。

9.1.1 改善代码的可读性

改善代码的可读性到底意味着什么?我们很难定义什么是好的可读性,因为这可能非常主观。通常的理解是,“别人理解这段代码的难易程度”。改善可读性意味着你要确保你的代码能非常容易地被包括自己在内的所有人理解和维护。为了确保你的代码能被其他人理解,有几个步骤可以尝试,比如确保你的代码附有良好的文档,并严格遵守编程规范。

跟之前的版本相比较,Java 8的新特性也可以帮助提升代码的可读性。使用Java 8,你可以减少冗长的代码,让代码更易于理解。通过方法引用和Stream API,你的代码会变得更直观。

这里会介绍三种简单的重构,利用Lambda表达式、方法引用以及Stream改善程序代码的可读性:

  • 重构代码,用Lambda表达式取代匿名类;
  • 用方法引用重构Lambda表达式;
  • 用Stream API重构命令式的数据处理。

9.1.2 从匿名类到Lambda表达式的转换

你值得尝试的第一种重构,也是简单的方式,是将实现单一抽象方法的匿名类转换为Lambda表达式。为什么呢?前面几章的介绍应该足以说服你,因为匿名类是极其烦琐且容易出错的。采用Lambda表达式之后,你的代码会更简洁,可读性更好。比如第3章的例子,创建Runnable对象的匿名类,及其对应的Lambda表达式实现如下:

  1. Runnable r1 = new Runnable(){ ←---- 传统的方式,使用匿名类
  2. public void run(){
  3. System.out.println("Hello");
  4. }
  5. };
  6. Runnable r2 = () -> System.out.println("Hello"); ←---- 新的方式,使用Lambda表达式

但是在某些情况下,将匿名类转换为Lambda表达式可能是一个比较复杂的过程。1 首先,匿名类和Lambda表达式中的thissuper的含义是不同的。在匿名类中,this代表的是类自身,但是在Lambda中,它代表的是包含类。其次,匿名类可以屏蔽包含类的变量,而Lambda表达式不能(它们会导致编译错误),譬如下面这段代码:

1这篇文章对转换的整个过程进行了深入细致的描述,值得一读:http://dig.cs.illinois.edu/papers/lambdaRefactoring.pdf

  1. int a = 10;
  2. Runnable r1 = () -> {
  3. int a = 2; ←---- 编译错误
  4. System.out.println(a);
  5. };
  6. Runnable r2 = new Runnable(){
  7. public void run(){
  8. int a = 2; ←---- 一切正常!
  9. System.out.println(a);
  10. }
  11. };

最后,在涉及重载的上下文里,将匿名类转换为Lambda表达式可能导致最终的代码更加晦涩。实际上,匿名类的类型是在初始化时确定的,而Lambda的类型取决于它的上下文。通过下面这个例子,我们可以了解问题是如何发生的。假设你用与Runnable同样的签名声明了一个函数接口,我们称之为Task(你希望采用与你的业务模型更贴切的接口名时,就可能做这样的变更):

  1. interface Task{
  2. public void execute();
  3. }
  4. public static void doSomething(Runnable r){ r.run(); }
  5. public static void doSomething(Task a){ a.execute(); }

现在,你再传递一个匿名类实现的Task,不会碰到任何问题:

  1. doSomething(new Task() {
  2. public void execute() {
  3. System.out.println("Danger danger!!");
  4. }
  5. });

但是将这种匿名类转换为Lambda表达式时,就导致了一种晦涩的方法调用,因为RunnableTask都是合法的目标类型:

  1. doSomething(() -> System.out.println("Danger danger!!")); ←---- 麻烦来了:doSomething(Runnable)和doSomething(Task)都匹配该类型

你可以对Task尝试使用显式的类型转换来解决这种模棱两可的情况:

  1. doSomething((Task)() -> System.out.println("Danger danger!!"));

但是不要因此而放弃对Lambda的尝试。好消息是,目前大多数的集成开发环境,比如NetBeans、Eclipse和IntelliJ都支持这种重构,它们能自动地帮你检查,避免发生这些问题。

9.1.3 从Lambda表达式到方法引用的转换

Lambda表达式非常适用于需要传递代码片段的场景。不过,为了改善代码的可读性,也请尽量使用方法引用。因为方法名往往能更直观地表达代码的意图。比如,第6章中曾经展示过下面这段代码,它的功能是按照食物的热量级别对菜肴进行分类:

  1. Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
  2. menu.stream()
  3. .collect(
  4. groupingBy(dish -> {
  5. if (dish.getCalories() <= 400) return CaloricLevel.DIET;
  6. else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
  7. else return CaloricLevel.FAT;
  8. }));

你可以将Lambda表达式的内容抽取到一个单独的方法中,将其作为参数传递给groupingBy方法。变换之后,代码变得更加简洁,程序的意图也更加清晰了:

  1. Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
  2. menu.stream().collect(groupingBy(Dish::getCaloricLevel)); ←---- Lambda表达式抽取到一个方法内

为了实现这个方案,你还需要在Dish类中添加getCaloricLevel方法:

  1. public class Dish{
  2. ...
  3. public CaloricLevel getCaloricLevel(){
  4. if (this.getCalories() <= 400) return CaloricLevel.DIET;
  5. else if (this.getCalories() <= 700) return CaloricLevel.NORMAL;
  6. else return CaloricLevel.FAT;
  7. }
  8. }

除此之外,还应该尽量考虑使用静态辅助方法,比如comparingmaxBy。这些方法设计之初就考虑了会结合方法引用一起使用。通过示例,我们看到相对于第3章中的对应代码,优化过的代码更清晰地表达了它的设计意图:

  1. inventory.sort(
  2. (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); ←---- 你需要考虑如何实现比较算法
  3. inventory.sort(comparing(Apple::getWeight)); ←---- 读起来就像问题描述,非常清晰

此外,很多通用的归约操作,比如summaximum,都有内建的辅助方法可以和方法引用结合使用。在我们的示例代码中,使用Collectors接口可以轻松得到和或者最大值,与采用Lambda表达式和底层的归约操作比起来,这种方式要直观得多。与其编写:

  1. int totalCalories =
  2. menu.stream().map(Dish::getCalories)
  3. .reduce(0, (c1, c2) -> c1 + c2);

不如尝试使用内置的集合类,它能更清晰地表达问题陈述是什么。下面的代码中,我们使用了集合类summingInt(方法的名词很直观地解释了它的功能):

  1. int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

9.1.4 从命令式的数据处理切换到Stream

建议你将所有使用迭代器这种数据处理模式处理集合的代码都转换成Stream API的方式。为什么呢?因为Stream API能更清晰地表达数据处理管道的意图。除此之外,通过短路和延迟载入以及利用第7章介绍的现代计算机的多核架构,我们可以对Stream进行优化。

比如,下面的命令式代码使用了两种模式:筛选和抽取,这两种模式被混在了一起,这样的代码结构迫使程序员必须彻底搞清楚程序的每个细节才能理解代码的功能。此外,实现需要并行运行的程序所面对的困难也多得多(具体细节可以参考7.2节的分支/合并框架):

  1. List<String> dishNames = new ArrayList<>();
  2. for(Dish dish: menu){
  3. if(dish.getCalories() > 300){
  4. dishNames.add(dish.getName());
  5. }
  6. }

替代方案使用Stream API,采用这种方式编写的代码读起来更像是问题陈述,并行化也非常容易:

  1. menu.parallelStream()
  2. .filter(d -> d.getCalories() > 300)
  3. .map(Dish::getName)
  4. .collect(toList());

不幸的是,将命令式的代码结构转换为Stream API的形式是个困难的任务,因为你需要考虑控制流语句,比如breakcontinuereturn,并选择使用恰当的流操作。好消息是已经有一些工具,比如LambdaFicator,可以帮助我们完成这个任务。

9.1.5 增加代码的灵活性

第2章和第3章曾经介绍过Lambda表达式有利于行为参数化。你可以使用不同的 Lambda表示不同的行为,并将它们作为参数传递给函数去处理执行。这种方式可以帮助我们淡定从容地面对需求的变化。比如,我们可以用多种方式为Predicate创建筛选条件,或者使用Comparator对多种对象进行比较。现在,来看看哪些模式可以马上应用到你的代码中,让你享受Lambda表达式带来的便利。

  • 采用函数接口

首先,你必须意识到,没有函数接口,就无法使用Lambda表达式。因此,你需要在代码中引入函数接口。听起来很合理,但是在什么情况下使用它们呢?这里介绍两种通用的模式,你可以依照这两种模式重构代码,以利用Lambda表达式带来的灵活性,它们分别是:有条件的延迟执行和环绕执行。除此之外,下一节还将介绍一些基于面向对象的设计模式,比如策略模式或者模板方法,这些在使用Lambda表达式重写后会更简洁。

  • 有条件的延迟执行

我们经常看到这样的代码,控制语句被混杂在业务逻辑代码之中。典型的情况包括进行安全性检查以及日志输出。比如,下面的这段代码,它使用了Java语言内置的Logger类:

  1. if (logger.isLoggable(Log.FINER)){
  2. logger.finer("Problem: " + generateDiagnostic());
  3. }

这段代码有什么问题吗?其实问题不少。

  • 日志器的状态(它支持哪些日志等级)通过isLoggable方法暴露给了客户端代码。
  • 为什么要在每次输出一条日志之前都去查询日志器对象的状态?这只能搞砸你的代码。
    更好的方案是使用log方法,该方法在输出日志消息之前,会在内部检查日志对象是否已经设置为恰当的日志等级:
  1. logger.log(Level.FINER, "Problem: " + generateDiagnostic());

这种方法更好的原因是你不再需要在代码中插入那些条件判断,与此同时日志器的状态也不再被暴露出去。不过,这段代码依旧存在一个问题:日志消息的输出与否每次都需要判断,即使你已经传递了参数,不开启日志。

这就是Lambda表达式可以施展拳脚的地方。你需要做的仅仅是延迟消息构造,如此一来,日志就只会在某些特定的情况下才开启(以此为例,当日志器的级别设置为FINER时)。显然,Java 8 API的设计者们已经意识到这个问题,并由此引入了一个对log方法的重载版本,这个版本的log方法接受一个Supplier作为参数。这个替代版本的log方法的函数签名如下:

  1. public void log(Level level, Supplier<String> msgSupplier)

你可以通过下面的方式对它进行调用:

  1. logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());

如果日志器的级别设置恰当,log方法会在内部执行作为参数传递进来的Lambda表达式。这里介绍的log方法的内部实现如下:

  1. public void log(Level level, Supplier<String> msgSupplier){
  2. if(logger.isLoggable(level)){
  3. log(level, msgSupplier.get()); ←---- 执行Lambda表达式
  4. }
  5. }

从这个故事里我们学到了什么呢?如果你发现你需要频繁地从客户端代码去查询一个对象的状态(比如前文例子中的日志器的状态),只是为了传递参数、调用该对象的一个方法(比如输出一条日志),那么可以考虑实现一个新的方法,以Lambda或者方法引用作为参数,新方法在检查完该对象的状态之后才调用原来的方法。你的代码会因此而变得更易读(结构更清晰),封装性更好(对象的状态也不会暴露给客户端代码了)。

  • 环绕执行

第3章介绍过另一种值得考虑的模式,那就是环绕执行。如果你发现虽然你的业务代码千差万别,但是它们拥有同样的准备和清理阶段,这时,你完全可以将这部分代码用Lambda实现。这种方式的好处是可以重用准备和清理阶段的逻辑,减少重复冗余的代码。

下面这段代码你在第3章中已经看过,再回顾一次。它在打开和关闭文件时使用了同样的逻辑,但在处理文件时可以使用不同的Lambda进行参数化。

  1. String oneLine =
  2. processFile((BufferedReader b) -> b.readLine()); ←---- 传入一个Lambda表达式
  3. String twoLines =
  4. processFile((BufferedReader b) -> b.readLine() + b.readLine()); ←---- 传入另一个Lambda表达式
  5. public static String processFile(BufferedReaderProcessor p) throws
  6. IOException {
  7. try(BufferedReader br = new BufferedReader(new
  8. FileReader("ModernJavaInAction/chap9/data.txt"))) {
  9. return p.process(br); ←---- BufferedReaderProcessor作为执行参数传入
  10. }
  11. }
  12. public interface BufferedReaderProcessor { ←---- 使用Lambda表达式的函数接口,该接口能够抛出一个IOException
  13. String process(BufferedReader b) throws IOException;
  14. }

这一优化是凭借函数式接口BufferedReaderProcessor达成的,通过这个接口,你可以传递各种Lamba表达式对BufferedReader对象进行处理。

通过这一节,你已经了解了如何通过不同方式来改善代码的可读性和灵活性。接下来,你会了解Lambada表达式如何避免常规面向对象设计中的僵化的模板代码。