7.1 重构候选项
使用Lambda表达式重构代码有个时髦的称呼:Lambda化(读作lambda-fi-cation,执行重构的程序员叫做lamb-di-fiers或者有责任心的程序员)。Java 8中的核心类库就曾经历过这样一场重构。在选择内部设计模型时,想想以何种形式向外展示API是大有裨益的。
这里有一些要点,可以帮助读者确定什么时候应该Lambda化自己的应用或类库。其中的每一条都可看作一个局部的反模式或代码异味,借助于Lambda化可以修复。
7.1.1 进进出出、摇摇晃晃
例7-1是关于如何在程序中记录日志的,我在第4章多次提到这个代码。这段代码先调用isDebugEnabled方法抽取布尔值,用来检查是否启用调试级别,如果启用,则调用Logger对象的相应方法记录日志。如果你发现自己的代码不断地查询和操作某对象,目的只为了在最后给该对象设个值,那么这段代码就本该属于你所操作的对象。
例7-1 logger对象使用isDebugEnabled属性避免不必要的性能开销
Logger logger = new Logger();if (logger.isDebugEnabled()) {logger.debug("Look at this: " + expensiveOperation());}
记录日志本来就是一直以来很难实现的目标,因为地方不同,所需的行为也不一样。本例中,需要根据程序中记录日志的不同位置和要记录的内容生成不同的信息。
这种反模式通过传入代码即数据的方式很容易解决。与其查询并设置一个对象的值,不如传入一个Lambda表达式,该表达式按照计算得出的值执行相应的行为。我将原来的实现代码列在例7-2中,以示提醒。当程序处于调试级别,并且检查是否使用Lambda表达式的逻辑被封装在Logger对象中时,才会调用Lambda表达式。
例7-2 使用Lambda表达式简化记录日志代码
Logger logger = new Logger();logger.debug(() -> "Look at this: " + expensiveOperation());
上述记录日志的例子也展示了如何使用Lambda表达式更好地面向对象编程(OOP),面向对象编程的核心之一是封装局部状态,比如日志的级别。通常这点做得不是很好,isDebugEnabled方法暴露了内部状态。如果使用Lambda表达式,外面的代码根本不需要检查日志级别。
7.1.2 孤独的覆盖
这个代码异味是使用继承,其目的只是为了覆盖一个方法。ThreadLocal就是一个很好的例子。ThreadLocal能创建一个工厂,为每个线程最多只产生一个值。这是确保非线程安全的类在并发环境下安全使用的一种简单方式。假设要在数据库中查询一个艺术家,但希望每个线程只做一次这种查询,写出的代码可能如例7-3所示。
例7-3 在数据库中查找艺术家
ThreadLocal<Album> thisAlbum = new ThreadLocal<Album> () {@Override protected Album initialValue() {return database.lookupCurrentAlbum();}};
在Java 8中,可以为工厂方法withInitial传入一个Supplier对象的实例来创建对象,如例7-4所示。
例7-4 使用工厂方法
ThreadLocal<Album> thisAlbum= ThreadLocal.withInitial(() -> database.lookupCurrentAlbum());
我们认为第二个例子优于前一个有以下几个原因。首先,任何已有的Supplier实例不需要重新封装,就可以在此使用,这鼓励了重用和组合。
在其他都一样的情况下,代码短小精悍就是个优势。更重要的是,这是代码更加清晰的结果,阅读代码时,信噪比降低了。这意味着有更多时间来解决实际问题,而不是把时间花在继承的样板代码上。这样做还有一个优点,JVM会少加载一个类。
对每个试图阅读代码,弄明白代码意图的人来说,也清楚了很多。如果你试着大声念出第二个例子中的单词,能很容易听出是干嘛的,但第一个例子就不行了。
有趣的是,在Java 8以前,这并不是一个反模式,而是惯用的代码编写方式,就像使用匿名内部类传递行为一样,都不是反模式,而是在Java中表达你所想的唯一方式。随着语言的演进,编程习惯也要与时俱进。
7.1.3 同样的东西写两遍
不要重复你劳动(Don't Repeat Yourself,DRY)是一个众所周知的模式,它的反面是同样的东西写两遍(Write Everything Twice,WET)。这种代码异味多见于重复的样板代码,产生了更多需要测试的代码,这样的代码难于重构,一改就坏。
不是所有WET的情况都适合Lambda化。有时,重复是唯一可以避免系统过紧耦合的方式。什么时候该将WET的代码Lambda化?这里有一个信号可以参考。如果有一个整体上大概相似的模式,只是行为上有所不同,就可以试着加入一个Lambda表达式。
让我们看一个更具体的例子。回到我们有关音乐的问题,我想增加一个简单的Order类来计算用户购买专辑的一些有用属性,如计算音乐家人数、曲目和专辑时长等。如果使用命令式Java,编写出的代码如例7-5所示。
例7-5 Order类的命令式实现
public long countRunningTime() {long count = 0;for (Album album : albums) {for (Track track : album.getTrackList()) {count += track.getLength();}}return count;}public long countMusicians() {long count = 0;for (Album album : albums) {count += album.getMusicianList().size();}return count;}public long countTracks() {long count = 0;for (Album album : albums) {count += album.getTrackList().size();}return count;}
每个方法里,都有样板代码将每个专辑里的属性和总数相加,比如每首曲目的长度或音乐家的人数。我们没有重用共有的概念,写出了更多代码需要测试和维护。可以使用Stream来抽象,使用Java 8中的集合类库来重写上述代码,使之更紧凑。如果直接将上述命令式的代码翻译成使用流的形式,则形如例7-6。
例7-6 使用流重构命令式的Order类
public long countRunningTime() {return albums.stream().mapToLong(album -> album.getTracks().mapToLong(track -> track.getLength()).sum()).sum();}public long countMusicians() {return albums.stream().mapToLong(album -> album.getMusicians().count()).sum();}public long countTracks() {return albums.stream().mapToLong(album -> album.getTracks().count()).sum();}
然而这段代码仍然有重用可读性的问题,因为有一些抽象和共性只能使用领域内的知识来表达。流不会提供一个方法统计每张专辑上的信息——这是程序员要自己编写的领域知识。这也是在Java 8出现之前很难编写的领域方法,因为每个方法都不一样。
想一下如何实现这样一个函数。我们返回一个long,统计所有专辑的某些特征,还需要一个Lambda表达式,告诉我们统计专辑上的什么信息。也就是说我们的方法需要一个参数,该参数为每张专辑返回一个long,方便的是,Java 8核心类库中已经有了这样一个类型ToLongFunction。如图7-1所示,它的类型随参数类型,因此我们要使用的类型为ToLongFunction。
图7-1:ToLongFunction
这些都定下来之后,方法体就自然定下来了。我们将专辑转换成流,将专辑映射为long,然后求和。在实现直接面对客户的代码时,比如countTracks,传入一个代表了领域知识的Lambda表达式,在这里,就是将专辑映射为上面的曲目。例7-7是使用了这种方式转换之后的代码。
例7-7 使用领域方法重构Order类
public long countFeature(ToLongFunction<Album> function) {return albums.stream().mapToLong(function).sum();}public long countTracks() {return countFeature(album -> album.getTracks().count());}public long countRunningTime() {return countFeature(album -> album.getTracks().mapToLong(track -> track.getLength()).sum());}public long countMusicians() {return countFeature(album -> album.getMusicians().count());}
