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接口,如下所示:
Collections.sort(persons, new Comparator<Person>() {public int compare(Person p1, Person p2) {return p1.getAge() - p2.getAge();}});
与在本书中看到的其他例子一样,你可以用更紧凑的Lambda表达式替换内部类:
Collections.sort(people, (p1, p2) -> p1.getAge() - p2.getAge());
采用这一技巧极大地改善了你代码的可读性,减少了那些无关痛痒的干扰因素对你理解代码逻辑的影响2。不过,Java也提供了一系列的静态工具方法,它们可以帮助你以更具可读性的方式创建Comparator对象。这些静态方法包含在Comparator接口中。通过静态导入Comparator.comparing方法,你可以重写上述排序示例,如下所示:
2原文中的术语为“信噪比”。
Collections.sort(persons, comparing(p -> p.getAge()));
你甚至可以更进一步,用方法引用替换掉代码中的Lambda:
Collections.sort(persons, comparing(Person::getAge));
这种方法能带来的好处还可以更大。如果你想按照年龄的逆序对人进行排序,那么可以尝试使用实例方法reverse(这也是在Java 8中引入的新特性):
Collections.sort(persons, comparing(Person::getAge).reverse());
更进一步,如果你希望同样年龄的人可以按照姓名的字母顺序排序,那么可以构造一个Comparator,对人的名字进行比较:
Collections.sort(persons, comparing(Person::getAge).thenComparing(Person::getName));
最后,你可以使用List接口中新增的sort方法对排序对象做进一步整理:
persons.sort(comparing(Person::getAge).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 以命令式的风格读取日志文件中的错误行
List<String> errors = new ArrayList<>();int errorCount = 0;BufferedReader bufferedReader= new BufferedReader(new FileReader(fileName));String line = bufferedReader.readLine();while (errorCount < 40 && line != null) {if (line.startsWith("ERROR")) {errors.add(line);errorCount++;}line = bufferedReader.readLine();}
为简洁起见,上述代码没有进行任何错误处理。即便如此,这段代码看起来还是异常臃肿,它的意图并不简洁明了。另一个破坏可读性和可维护性的因素是它没有执行关注点隔离。我们可以观察到,同样功能的代码散落于多个语句中。譬如,以逐行方式读取文件的代码出现在了三个地方,分别位于:
FileReader创建的地方;while循环的第二个条件判断,检查文件是否已经到了结尾;while循环的末尾,它需要读取文件的下一行。
类似地,限制列表只收集前40行结果的代码也散落在三个语句中:
- 初始化变量
errorCount时; while循环的第一个条件;- 发现日志中存在以“ERROR”打头的行时递增计数器的语句。
借助于Stream接口,我们能以更加函数式的风格达到同样的效果,并且更简单且更紧凑,代码清单如下。
代码清单 10-2 以函数式的风格读取日志文件中的错误行
List<String> errors = Files.lines(Paths.get(fileName)) ←---- 打开文件并创建一个字符串流,每个字符串对应于文件中的一行.filter(line -> line.startsWith("ERROR")) ←---- 过滤出以“ERROR”打头的行.limit(40) ←---- 对结果做限制,只取前40行.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)。譬如,你可以对汽车列表进行分组,先按照它们的品牌,然后按照它们的颜色,如下所示:
Map<String, Map<Color, List<Car>>> carsByBrandAndColor =cars.stream().collect(groupingBy(Car::getBrand,groupingBy(Car::getColor)));
与你曾经连接两个Comparator的方式比较起来,你注意到这里有什么不同吗?通过流畅方式组合两个Comparator,你定义了一个多域的Comparator:
Comparator<Person> comparator =comparing(Person::getAge).thenComparing(Person::getName);
而Collectors API允许你以嵌套Collector的方式创建多层的Collector:
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>carGroupingCollector =groupingBy(Car::getBrand, groupingBy(Car::getColor));
通常情况下,我们认为流畅风格比嵌套风格的可读性好很多,这一点在组合涉及三个及以上组件时就愈加明显了。这种风格上的差异是为了别具一格吗?事实上,它反映的是一个刻意的设计选择,因为最内层的Collector需要首先执行,然而逻辑上,它是最后被执行的一组。这个例子中,我们通过几个静态方法嵌套了Collector的创建,而没有使用流畅的连接方式,所以最内层的分组会首先执行,然而从代码上一眼看过去,它似乎应该是最后执行的。
实现一个代理groupingBy工厂方法的GroupingBuilder可能更容易一些(除了在定义中使用泛型),然而你需要允许多个分组操作可以流畅地组合。代码清单如下。
代码清单 10-3 一个流畅分组的
collectors构建器
import static java.util.stream.Collectors.groupingBy;public class GroupingBuilder<T, D, K> {private final Collector<? super T, ?, Map<K, D>> collector;private GroupingBuilder(Collector<? super T, ?, Map<K, D>> collector) {this.collector = collector;}public Collector<? super T, ?, Map<K, D>> get() {return collector;}public <J> GroupingBuilder<T, Map<K, D>, J>after(Function<? super T, ? extends J> classifier) {return new GroupingBuilder<>(groupingBy(classifier, collector));}public static <T, D, K> GroupingBuilder<T, List<T>, K>groupOn(Function<? super T, ? extends K> classifier) {return new GroupingBuilder<>(groupingBy(classifier));}}
这个流畅构建器有什么问题吗?如果你尝试用一下,就发现问题了:
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>carGroupingCollector =groupOn(Car::getColor).after(Car::getBrand).get()
如你所见,这个工具类的使用不太直观,因为分组函数需要以嵌套分组层次的逆序方式书写。如果你试图修复这个次序问题,重构这个流畅函数,就会发现非常不幸,Java的类型系统不允许你这么做!
通过更近距离观察原生Java API以及它们设计决策背后的逻辑,你已经逐渐学习了一些可以帮助你创建可读性好的DSL的模式和技巧。接下来的一节会继续学习开发有效DSL的技巧。
