10.2 现代Java API中的小型DSL

最先使用Java新的函数式能力的API是原生Java API自身。Java 8之前,原生Java API就已经有几个带单一抽象方法的接口,不过使用这些抽象接口需要实现匿名内部类,10.1节分析过,采用这种方式语法非常臃肿。Lambda表达式以及方法引用(从DSL的角度而言,这可能是更重要的因素)的引入改变了游戏的规则,让函数式接口成为了Java API设计的基石。

Java 8中的Comparator接口已经更新采用了这种新的方法。在本书第13章中,你会知道接口可以同时包含静态方法和默认方法。目前而言,Comparator接口是一个很好的例子,其能帮助我们理解Lambda是怎样改善原生Java API的可重用性和组合性的。

假设你有一个表示人(Persons)的对象列表,你希望按照人的年龄对这些对象排序。Lambda出现之前,你只能通过内部类实现Comparator接口,如下所示:

  1. Collections.sort(persons, new Comparator<Person>() {
  2. public int compare(Person p1, Person p2) {
  3. return p1.getAge() - p2.getAge();
  4. }
  5. });

与在本书中看到的其他例子一样,你可以用更紧凑的Lambda表达式替换内部类:

  1. Collections.sort(people, (p1, p2) -> p1.getAge() - p2.getAge());

采用这一技巧极大地改善了你代码的可读性,减少了那些无关痛痒的干扰因素对你理解代码逻辑的影响2。不过,Java也提供了一系列的静态工具方法,它们可以帮助你以更具可读性的方式创建Comparator对象。这些静态方法包含在Comparator接口中。通过静态导入Comparator.comparing方法,你可以重写上述排序示例,如下所示:

2原文中的术语为“信噪比”。

  1. Collections.sort(persons, comparing(p -> p.getAge()));

你甚至可以更进一步,用方法引用替换掉代码中的Lambda:

  1. Collections.sort(persons, comparing(Person::getAge));

这种方法能带来的好处还可以更大。如果你想按照年龄的逆序对人进行排序,那么可以尝试使用实例方法reverse(这也是在Java 8中引入的新特性):

  1. Collections.sort(persons, comparing(Person::getAge).reverse());

更进一步,如果你希望同样年龄的人可以按照姓名的字母顺序排序,那么可以构造一个Comparator,对人的名字进行比较:

  1. Collections.sort(persons, comparing(Person::getAge)
  2. .thenComparing(Person::getName));

最后,你可以使用List接口中新增的sort方法对排序对象做进一步整理:

  1. persons.sort(comparing(Person::getAge)
  2. .thenComparing(Person::getName));

这个小小的API可以被看成是集合排序领域的一个微型DSL。虽然它的范畴很有限,但是已经显示了良好的设计,它利用Lambda和方法引用改善了你代码的可读性、重用性以及可组合性。

接下来的一节会探讨Java 8中用法最多、运用最广泛、可读性改善也更明显的类:Stream API。

10.2.1 把Stream API当成DSL去操作集合

Stream接口是小型内部DSL引入原生Java API非常好的一个例子。事实上,Stream是一个紧凑却强大的DSL,它可以对集合中的元素进行过滤、排序、转换、归并等操作。假设你需要读取一个日志文件,取出其中包含“ERROR”关键字的前40行,并将其存放到一个List对象中。你可以以命令式的风格执行这项任务,代码清单如下。

代码清单 10-1 以命令式的风格读取日志文件中的错误行

  1. List<String> errors = new ArrayList<>();
  2. int errorCount = 0;
  3. BufferedReader bufferedReader
  4. = new BufferedReader(new FileReader(fileName));
  5. String line = bufferedReader.readLine();
  6. while (errorCount < 40 && line != null) {
  7. if (line.startsWith("ERROR")) {
  8. errors.add(line);
  9. errorCount++;
  10. }
  11. line = bufferedReader.readLine();
  12. }

为简洁起见,上述代码没有进行任何错误处理。即便如此,这段代码看起来还是异常臃肿,它的意图并不简洁明了。另一个破坏可读性和可维护性的因素是它没有执行关注点隔离。我们可以观察到,同样功能的代码散落于多个语句中。譬如,以逐行方式读取文件的代码出现在了三个地方,分别位于:

  • FileReader创建的地方;
  • while循环的第二个条件判断,检查文件是否已经到了结尾;
  • while循环的末尾,它需要读取文件的下一行。

类似地,限制列表只收集前40行结果的代码也散落在三个语句中:

  • 初始化变量errorCount时;
  • while循环的第一个条件;
  • 发现日志中存在以“ERROR”打头的行时递增计数器的语句。

借助于Stream接口,我们能以更加函数式的风格达到同样的效果,并且更简单且更紧凑,代码清单如下。

代码清单 10-2 以函数式的风格读取日志文件中的错误行

  1. List<String> errors = Files.lines(Paths.get(fileName)) ←---- 打开文件并创建一个字符串流,每个字符串对应于文件中的一行
  2. .filter(line -> line.startsWith("ERROR")) ←---- 过滤出以“ERROR”打头的行
  3. .limit(40) ←---- 对结果做限制,只取前40
  4. .collect(toList()); ←---- 收集结果,将字符串归并到一个列表

Files.lines是一个静态工具方法,它返回一个Stream,每一个String代表解析文件中的一行。这里是代码中唯一一处以逐行方式读取文件的地方。同样的,limit(40)这一行语句就完成了对结果数据的限制,我们只收集前40行错误日志。异常简洁明了!你还能想到更具可读性的方式吗?

Stream API的流畅风格是另一个值得探讨的话题,这也是设计良好的DSL的典型。所有的中间操作都是延迟的,返回值是一个可以进行流水线的操作序列。终止操作都是积极式的(eager),会触发计算整个流水线的结果。

是时候探讨另一个小型DSL的API了。接下来要讨论的是与Stream接口的collect方法经常一起使用的API:Collectors API。

10.2.2 将Collectors作为DSL汇总数据

通过前面的学习,你已经知道Stream接口可以作为DSL操作数据列表。同样,Collector接口也可以作为DSL,对数据进行分析汇总。第6章介绍过Collector接口,包括如何使用它对流中的元素进行收集、分组和划分。我们也介绍过通过Collectors类的静态工厂方法,可以非常方便地创建各种类型的Collector对象,并汇总它们。现在,让我们从DSL的角度审视下这些方法是如何设计的。特别是,Comparator接口可以整合在一起支持多字段排序,而Collector接口也可以整合在一起支持多层分组(multilevel grouping)。譬如,你可以对汽车列表进行分组,先按照它们的品牌,然后按照它们的颜色,如下所示:

  1. Map<String, Map<Color, List<Car>>> carsByBrandAndColor =
  2. cars.stream().collect(groupingBy(Car::getBrand,
  3. groupingBy(Car::getColor)));

与你曾经连接两个Comparator的方式比较起来,你注意到这里有什么不同吗?通过流畅方式组合两个Comparator,你定义了一个多域的Comparator

  1. Comparator<Person> comparator =
  2. comparing(Person::getAge).thenComparing(Person::getName);

而Collectors API允许你以嵌套Collector的方式创建多层的Collector

  1. Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>
  2. carGroupingCollector =
  3. groupingBy(Car::getBrand, groupingBy(Car::getColor));

通常情况下,我们认为流畅风格比嵌套风格的可读性好很多,这一点在组合涉及三个及以上组件时就愈加明显了。这种风格上的差异是为了别具一格吗?事实上,它反映的是一个刻意的设计选择,因为最内层的Collector需要首先执行,然而逻辑上,它是最后被执行的一组。这个例子中,我们通过几个静态方法嵌套了Collector的创建,而没有使用流畅的连接方式,所以最内层的分组会首先执行,然而从代码上一眼看过去,它似乎应该是最后执行的。

实现一个代理groupingBy工厂方法的GroupingBuilder可能更容易一些(除了在定义中使用泛型),然而你需要允许多个分组操作可以流畅地组合。代码清单如下。

代码清单 10-3 一个流畅分组的collectors构建器

  1. import static java.util.stream.Collectors.groupingBy;
  2. public class GroupingBuilder<T, D, K> {
  3. private final Collector<? super T, ?, Map<K, D>> collector;
  4. private GroupingBuilder(Collector<? super T, ?, Map<K, D>> collector) {
  5. this.collector = collector;
  6. }
  7. public Collector<? super T, ?, Map<K, D>> get() {
  8. return collector;
  9. }
  10. public <J> GroupingBuilder<T, Map<K, D>, J>
  11. after(Function<? super T, ? extends J> classifier) {
  12. return new GroupingBuilder<>(groupingBy(classifier, collector));
  13. }
  14. public static <T, D, K> GroupingBuilder<T, List<T>, K>
  15. groupOn(Function<? super T, ? extends K> classifier) {
  16. return new GroupingBuilder<>(groupingBy(classifier));
  17. }
  18. }

这个流畅构建器有什么问题吗?如果你尝试用一下,就发现问题了:

  1. Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>
  2. carGroupingCollector =
  3. groupOn(Car::getColor).after(Car::getBrand).get()

如你所见,这个工具类的使用不太直观,因为分组函数需要以嵌套分组层次的逆序方式书写。如果你试图修复这个次序问题,重构这个流畅函数,就会发现非常不幸,Java的类型系统不允许你这么做!

通过更近距离观察原生Java API以及它们设计决策背后的逻辑,你已经逐渐学习了一些可以帮助你创建可读性好的DSL的模式和技巧。接下来的一节会继续学习开发有效DSL的技巧。