Java循环不变表达式外提_Java8-Lambdas-functional-programming

#测试、调试和重构

重构、测试驱动开发`(TDD)`和持续集成`(CI)`越来越流行,如果我们需要将 `Lambda` 表 达式应用于日常编程工作中,就得学会如何为它编写单元测试。

本章主要探讨如何在代码中使用 `Lambda` 表达式的技术,也会说明什么情况下不应该(直 接)使用 `Lambda` 表达式。本章还讲述了如何调试大量使用 `Lambda` 表达式和流的程序。

##7.1 重构候选项

这里有一些要点,可以帮助读者确定什么时候应该 `Lambda` 化自己的应用或类库。其中的 每一条都可看作一个局部的反模式或代码异味,借助于 `Lambda` 化可以修复。

###7.1.1 进进出出、摇摇晃晃

如下代码是关于如何在程序中记录日志的,我在第 4 章多次提到这个代码。这段代码先调用 `isDebugEnabled` 方法抽取布尔值,用来检查是否启用调试级别,如果启用,则调用 `Logger` 对象的相应方法记录日志。如果你发现自己的代码不断地查询和操作某对象,目的只为了在最后给该对象设个值,那么这段代码就本该属于你所操作的对象。

- `logger` 对象使用 `isDebugEnabled` 属性避免不必要的性能开销

```java

Logger logger = new Logger();

if (logger.isDebugEnabled()) {

logger.debug("Look at this: " + expensiveOperation());

}

```

记录日志本来就是一直以来很难实现的目标,因为地方不同,所需的行为也不一样。本例中,需要根据程序中记录日志的不同位置和要记录的内容生成不同的信息。

这种反模式通过传入代码即数据的方式很容易解决。与其查询并设置一个对象的值,不如传入一个 `Lambda` 表达式,该表达式按照计算得出的值执行相应的行为。我将原来的实现 代码列在下面代码中,以示提醒。当程序处于调试级别,并且检查是否使用 `Lambda` 表达式的逻辑被封装在 `Logger` 对象中时,才会调用 `Lambda` 表达式。

- 使用 `Lambda` 表达式简化记录日志代码

```java

Logger logger = new Logger();

logger.debug(() -> "Look at this: " + expensiveOperation());

```

上述记录日志的例子也展示了如何使用 `Lambda` 表达式更好地面向对象编程`(OOP)`,面向对象编程的核心之一是封装局部状态,比如日志的级别。通常这点做得不是很好, `isDebugEnabled` 方法暴露了内部状态。如果使用 `Lambda` 表达式,外面的代码根本不需要检查日志级别。

###7.1.2 孤独的覆盖

这个代码异味是使用继承,其目的只是为了覆盖一个方法。`ThreadLocal` 就是一个很好的 例子。`ThreadLocal` 能创建一个工厂,为每个线程最多只产生一个值。这是确保非线程安全的类在并发环境下安全使用的一种简单方式。假设要在数据库中查询一个艺术家,但希望每个线程只做一次这种查询,写出的代码可能如例下所示。

- 在数据库中查找艺术家

```java

ThreadLocal thisAlbum = new ThreadLocal () {

@Override protected Album initialValue() {

return database.lookupCurrentAlbum(); }

};

```

在 `Java 8` 中,可以为工厂方法 `withInitial` 传入一个 `Supplier` 对象的实例来创建对象,如下代码所示。

- 使用工厂方法

```java

ThreadLocal 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` 表达式。(意思就是把行为相同的代码块抽成 `Lambda` 表达式)

让我们看一个更具体的例子。回到我们有关音乐的问题,我想增加一个简单的 `Order` 类来 计算用户购买专辑的一些有用属性,如计算音乐家人数、曲目和专辑时长等。如果使用命 令式 `Java`,编写出的代码如下代码。

- Order 类的命令式实现

```java

public static long countRunningTime(List albums) {

long count = 0;

for (Album album : albums) {

for (Track track : album.getTrackList()) {

count += track.getLength();

}

}

return count;

}

public long countMusicians(List albums) {

long count = 0;

for (Album album : albums) {

count += album.getMusicianList().size();

}

return count;

}

public long countTracks(List albums) {

long count = 0;

for (Album album : albums) {

count += album.getTrackList().size();

}

return count;

}

```

每个方法里,都有样板代码将每个专辑里的属性和总数相加,比如每首曲目的长度或音乐家的人数。我们没有重用共有的概念,写出了更多代码需要测试和维护。可以使用 `Stream` 来抽象,使用`Java 8`中的集合类库来重写上述代码,使之更紧凑。如果直接将上述命令式 的代码翻译成使用流的形式,则形如下。

- 使用流重构命令式的 `Order` 类

```java

public static long countRunningTimeStream(List albums) {

return albums.stream()

.mapToLong(album -> album.getTracks()

.mapToLong(track -> track.getLength()).sum())

.sum();

}

public long countMusiciansStream(List albums) {

return albums.stream()

.mapToLong(album -> album.getMusicians().count())

.sum();

}

public long countTracksStream(List albums) {

return albums.stream()

.mapToLong(album -> album.getTracks().count())

.sum();

}

```

然而这段代码仍然有重用可读性的问题,因为有一些抽象和共性只能使用领域内的知识来表达。流不会提供一个方法统计每张专辑上的信息——这是程序员要自己编写的领域知识。这也是在 `Java 8` 出现之前很难编写的领域方法,因为每个方法都不一样。

想一下如何实现这样一个函数。我们返回一个 `long`,统计所有专辑的某些特征,还需要 一个 `Lambda` 表达式,告诉我们统计专辑上的什么信息。也就是说我们的方法需要一个参数,该参数为每张专辑返回一个`long`,方便的是,`Java 8`核心类库中已经有了这样一 个类型 `ToLongFunction`。如下图所示,它的类型随参数类型,因此我们要使用的类型为 `ToLongFunction`。

![alt](https://www.zhangaoo.com/upload/2018/11/sknlthsabiijequ1vm362gfk4r.png)

这些都定下来之后,方法体就自然定下来了。我们将专辑转换成流,将专辑映射为 `long`, 然后求和。在实现直接面对客户的代码时,比如 `countTracks`,传入一个代表了领域知识 的 `Lambda` 表达式,在这里,就是将专辑映射为上面的曲目。如下代码使用了这种方式转换之后的代码。

- 使用领域方法重构 `Order` 类

```java

public long countFeature(ToLongFunction function) {

return albums.stream()

.mapToLong(function)

.sum();

}

```

```java

public long countTracks() {

return countFeature(album -> album.getTracks().count());

}

```

```java

public long countRunningTime() {

return countFeature(album -> album.getTracks()

.mapToLong(track -> track.getLength())

.sum());

}

```

```java

public long countMusicians() {

return countFeature(album -> album.getMusicians().count());

}

```

##7.2 Lambda表达式的单元测试

通常,在编写单元测试时,怎么在应用中调用该方法,就怎么在测试中调用。给定一些输入或测试替身,调用这些方法,然后验证结果是否和预期的行为一致。

`Lambda` 表达式给单元测试带来了一些麻烦,`Lambda` 表达式没有名字,无法直接在测试代 码中调用。

你可以在测试代码中复制 `Lambda` 表达式来测试,但这种方式的副作用是测试的不是真正的实现。假设你修改了实现代码,测试仍然通过,而实现可能早已在做另一件事了。

解决该问题有两种方式。第一种是将 `Lambda` 表达式放入一个方法测试,这种方式要测那个方法,而不是 `Lambda` 表达式本身。如下代码是一个将一组字符串转换成大写的方法。

- 将字符串转换为大写形式

```java

public static List allToUpperCase(List words) { return words.stream()

.map(string -> string.toUpperCase())

.collect(Collectors.toList());

}

```

在这段代码中,`Lambda` 表达式唯一的作用就是调用一个 `Java` 方法。将该 `Lambda` 表达式 单独测试是不值得的,它的行为太简单了。

如果换我来测试这段代码,我会将重点放在方法的行为上。如下代码测试了流中有多个单词的情况,它们都被转换成对应的大写

```java

@Test

public void multipleWordsToUppercase() {

List input = Arrays.asList("a", "b", "hello");

List result = Testing.allToUpperCase(input);

assertEquals(asList("A", "B", "HELLO"), result);

}

```

有时候 `Lambda` 表达式实现了复杂的功能,它可能包含多个边界情况、使用了多个属性来 计算一个非常重要的值。你非常想测试该段代码的行为,但它是一个 `Lambda` 表达式,无法引用。

作为例子,让我们来看一个比大写转换更复杂一点的方法。我们要把字符串的第一个字母转换成大写,其他部分保持不变。使用流和 `Lambda` 表达式,编写的代码形如下代码所示。在①处使用 `Lambda` 表达式做转换。

- 将列表中元素的第一个字母转换成大写

```java

public static List elementFirstToUpperCaseLambdas(List words) {

return words.stream()

.map(value -> {①

char firstChar = Character.toUpperCase(value.charAt(0));

return firstChar + value.substring(1);

}).collect(Collectors.toList());

}

```

如果要测试这段代码,我们必须创建一个列表,然后将想要测试的各种情况都测试到。如下代码展示了这种方式有多么繁琐,别担心,我们有办法!

- 测试字符串包含两个字符的情况,第一个字母被转换为大写

```java

@Test

public void twoLetterStringConvertedToUppercaseLambdas() {

List input = Arrays.asList("ab");

List result = Testing.elementFirstToUpperCaseLambdas(input);

assertEquals(asList("Ab"), result);

}

```

别用 `Lambda` 表达式。我知道,在一本介绍如何使用 `Lambda` 表达式的书里,这个建议有点奇怪,但是方楔子钉不进圆孔。既然如此,大家一定会问如何测试代码,同时享有 `Lambda` 表达式带来的便利?

请用方法引用。任何 `Lambda` 表达式都能被改写为普通方法,然后使用方法引用直接引用。

如下代码将 `Lambda` 表达式重构为一个方法,然后在主程序中使用,主程序负责转换字符串。

- 将首字母转换为大写,应用到所有列表元素

```java

public static List elementFirstToUppercase(List words) {

return words.stream()

.map(Testing::firstToUppercase)

.collect(Collectors.toList());

}

public static String firstToUppercase(String value) { ①

char firstChar = Character.toUpperCase(value.charAt(0));

return firstChar + value.substring(1);

}

```

把处理字符串的的逻辑抽取成一个方法后,就可以测试该方法,把所有的边界情况都覆盖到。新的测试用例如下代码所示。

- 测试单独的方法

```java

@Test

public void twoLetterStringConvertedToUppercase() {

String input = "ab";

String result = Testing.firstToUppercase(input);

assertEquals("Ab", result);

}

```

##7.3 在测试替身时使用Lambda表达式

编写单元测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。这种方式很有用,因为单元测试可以脱离其他模块来测试你的类或方法,测试替身让你能用单元测试来实现这种隔离。

**测试替身也常被称为模拟,事实上测试存根和模拟都属于测试替身。区别是模拟可以验证代码的行为。读者若想了解更多有关这方面的信息,请阅读 MartinFowler 的相关文章(http://martinfowler.com/articles/mocksArentStubs.html)。**

测试代码时,使用 `Lambda` 表达式的最简单方式是实现轻量级的测试存根。如果交互的类本身就是一个函数接口,实现这样的存根就非常简单和自然。

在上面的代码中讨论过如何将通用的领域逻辑重构为一个 `countFeature` 方法,然后使用 `Lambda` 表达式实现不同的统计行为。如下代码展示了如何对此编写单元测试。

- 使用 `Lambda` 表达式编写测试替身,传给 `countFeature` 方法

```java

@Test

public void canCountFeatures() {

OrderDomain order = new OrderDomain(asList(

newAlbum("Exile on Main St."),

newAlbum("Beggars Banquet"),

newAlbum("Aftermath"),

newAlbum("Let it Bleed")));

assertEquals(8, order.countFeature(album -> 2));

}

```

对于 `countFeature` 方法的期望行为是为传入的专辑返回某个数值。这里传入 `4` 张专辑,测 试存根中为每张专辑返回 `2`,然后断言该方法返回 `8`,即 `2×4`。如果要向代码传入一个 `Lambda` 表达式,最好确保 `Lambda` 表达式也通过测试。

多数的测试替身都很复杂,使用 `Mockito` 这样的框架有助于更容易地产生测试替身。让我们考虑一种简单情形,为 `List` 生成测试替身。我们不想返回 `List` 本上的长度,而是返回另一个 `List` 的长度,为了模拟 `List` 的 `size` 方法,我们不想只给出答案,还想做一些操作,因此传入一个 `Lambda` 表达式,如下代码所示。

- 结合 `Mockito` 框架使用 `Lambda` 表达式

```java

//这里使用 otherList 的 size 来代替 list 测试

List list = mock(List.class);

List otherList = Arrays.asList("123","456","789");

Mockito.when(list.size()).thenAnswer(inv -> otherList.size());

assertEquals(3, list.size());

```

`Mockito` 使用 `Answer` 接口允许用户提供其他行为,换句话说,这是我们的老朋友:代码即 数据。之所以在这里能使用 `Lambda` 表达式,是因为 `Answer` 本身就是一个函数接口。

##7.4 惰性求值和调试

调试时通常会设置断点,单步跟踪程序的每一步。使用流时,调试可能会变得更加复杂,因为迭代已交由类库控制,而且很多流操作是惰性求值的。

在传统的命令式编程看来,代码就是达到某种目的的一系列行动,在行动前后查看程序状态是有意义的。在 `Java 8` 中,你仍然可以使用 `IDE` 提供的各种调试工具,但有时需要调整 实现方式,以期达到更好的结果。

##7.5 日志和打印消息

假设你要在集合上进行大量操作,你要调试代码,你希望看到每一步操作的结果是什么。可以在每一步打印出集合中的值,这在流中很难做到,因为一些中间步骤是惰性求值的。

让我们通过第 3 章介绍的命令式版本的国际报告程序,看看如何记录中间值。考虑到读者可能已经忘记这个程序,我们再来解释一下这个程序的意图,该程序找出了专辑上每位艺术家来自哪个国家。在例如下代码中,我们将找到的国家信息记录到日志中。

- 记录中间值,以便调试 `for` 循环

```java

Set nationalities = new HashSet<>();

for (Artist artist : album.getMusicianList()) {

if (artist.getName().startsWith("The")) {

String nationality = artist.getNationality();

System.out.println("Found nationality: " + nationality);

nationalities.add(nationality);

}

}

return nationalities;

```

现在可以使用 `forEach` 方法打印出流中的值,这同时会触发求值过程。但是这样的操作有 个缺点:我们无法再继续操作流了,流只能使用一次。如果我们还想继续,必须重新创建流。如下代码展示了这样的代码会有多难看。

- 使用 `forEach` 记录中间值,这种方式有点幼稚

```java

album.getMusicians()

.filter(artist -> artist.getName().startsWith("The"))

.map(artist -> artist.getNationality())

.forEach(nationality -> System.out.println("Found: " + nationality));

Set nationalities

= album.getMusicians()

.filter(artist -> artist.getName().startsWith("The"))

.map(artist -> artist.getNationality())

.collect(Collectors.toSet());

```

##7.6 解决方案:peak

遗憾的是,流有一个方法让你能查看每个值,同时能继续操作流。这就是 `peek` 方法。如下代码使用 `peek` 方法重写了前面的例子,输出流中的值,同时避免了重复的流操作。

- 使用 `peek` 方法记录中间值

```java

Set nationalities

= album.getMusicians()

.filter(artist -> artist.getName().startsWith("The"))

.map(artist -> artist.getNationality())

.peek(nation -> System.out.println("Found nationality: " + nation))

.collect(Collectors.toSet());

```

使用 `peek` 方法还能以同样的方式,将输出定向到现有的日志系统中,比如 `log4j、java`. `util.logging` 或者 `slf4j`。

##7.7 在流中间设置断点

记录日志这是 `peek` 方法的用途之一。为了像调试循环那样一步一步跟踪,可在 `peek`

此时,`peek` 方法可知包含一个空的方法体,只要能设置断点就行。有一些调试器不允许在 空的方法体中设置断点,此时,我将值简单地映射为其本身,这样就有地方设置断点了, 虽然这样做不够完美,但只要能工作就行。

##7.8 要点回顾

- 重构遗留代码时考虑如何使用Lambda表达式,有一些通用的模式。

- 如果想要对复杂一点的Lambda表达式编写单元测试,将其抽取成一个常规的方法。

- `peek` 方法能记录中间值,在调试时非常有用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值