9.4 调试

调试有问题的代码时,程序员的兵器库里有两大经典武器,分别是:

  • 查看栈跟踪;
  • 输出日志。

Lambda表达式和流的引入同时也会给你的程序调试带来挑战。本节会探讨二者的影响。

9.4.1 查看栈跟踪

你的程序突然停止运行(比如突然抛出一个异常),这时你首先要调查程序在什么地方发生了异常以及为什么会发生该异常。这时栈帧就非常有用了。程序的每次方法调用都会产生相应的调用信息,包括程序中方法调用的位置、该方法调用使用的参数,以及被调用方法的本地变量。这些信息被保存在栈帧上。

程序失败时,你会得到它的栈跟踪,通过一个又一个栈帧,你可以了解程序失败时的概略信息。换句话说,通过这些你能得到程序失败时的方法调用列表。这些方法调用列表最终会帮助你发现问题出现的原因。

使用Lambda表达式

不幸的是,由于Lambda表达式没有名字,因此它的栈跟踪可能很难分析。在下面这段简单的代码中,我们刻意地引入了一些错误:

  1. import java.util.*;
  2. public class Debugging{
  3. public static void main(String[] args) {
  4. List<Point> points = Arrays.asList(new Point(12, 2), null);
  5. points.stream().map(p -> p.getX()).forEach(System.out::println);
  6. }
  7. }

运行这段代码会产生下面的栈跟踪(javac版本不同,栈跟踪也会不同):

  1. Exception in thread "main" java.lang.NullPointerException
  2. at Debugging.lambda$main$0(Debugging.java:6) ←---- 这行中的$0是什么意思?
  3. at Debugging$$Lambda$5/284720968.apply(Unknown Source)
  4. at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
  5. .java:193)
  6. at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators
  7. .java:948)
  8. ...

讨厌!发生了什么?这段程序当然会失败,因为Points列表的第二个元素是空(null)。这时你的程序实际是在试图处理一个空引用。由于Stream流水线发生了错误,因此构成Stream流水线的整个方法调用序列都暴露在你面前了。不过,你留意到了吗?栈跟踪中还包含下面这样类似加密的内容:

  1. at Debugging.lambda$main$0(Debugging.java:6)
  2. at Debugging$$Lambda$5/284720968.apply(Unknown Source)

这些表示错误发生在Lambda表达式内部。因为Lambda表达式没有名字,所以编译器只能为它们指定一个名字。在这个例子中,它的名字是lambda$main$0,看起来非常不直观。如果你使用了大量的类,其中又包含多个Lambda表达式,这就成了一个非常头痛的问题。

即使你使用了方法引用,还是有可能出现栈无法显示你使用的方法名的情况。将之前的Lambda表达式p-> p.getX()替换为方法引用Point::getX也会产生难于分析的栈跟踪:

  1. points.stream().map(Point::getX).forEach(System.out::println);
  2. Exception in thread "main" java.lang.NullPointerException
  3. at Debugging$$Lambda$5/284720968.apply(Unknown Source) ←---- 这一行表示什么呢?
  4. at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
  5. .java:193)
  6. ...

注意,如果方法引用指向的是同一个类中声明的方法,那么它的名称是可以在栈跟踪中显示的。比如,来看下面这个例子:

  1. import java.util.*;
  2. public class Debugging{
  3. public static void main(String[] args) {
  4. List<Integer> numbers = Arrays.asList(1, 2, 3);
  5. numbers.stream().map(Debugging::divideByZero).forEach(System
  6. .out::println);
  7. }
  8. public static int divideByZero(int n){
  9. return n / 0;
  10. }
  11. }

方法divideByZero在栈跟踪中就正确地显示了:

  1. Exception in thread "main" java.lang.ArithmeticException: / by zero
  2. at Debugging.divideByZero(Debugging.java:10) ←---- divideByZero正确地输出到栈跟踪中
  3. at Debugging$$Lambda$1/999966131.apply(Unknown Source)
  4. at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
  5. .java:193)
  6. ...

总的来说,我们需要特别注意,涉及Lambda表达式的栈跟踪可能非常难理解。这是Java编译器未来版本可以改进的一个方面。

9.4.2 使用日志调试

假设你试图对流操作中的流水线进行调试,该从何入手呢?可以像下面的例子那样,使用forEach将流操作的结果日志输出到屏幕上或者记录到日志文件中:

  1. List<Integer> numbers = Arrays.asList(2, 3, 4, 5);
  2. numbers.stream()
  3. .map(x -> x + 17)
  4. .filter(x -> x % 2 == 0)
  5. .limit(3)
  6. .forEach(System.out::println);

这段代码的输出如下:

  1. 20
  2. 22

不幸的是,一旦调用forEach,整个流就会恢复运行。到底哪种方式能更有效地帮助我们理解Stream流水线中的每个操作(比如mapfilterlimit)产生的输出呢?

这正是流操作方法peek大显身手的时候。peek的设计初衷就是在流的每个元素恢复运行之前,插入执行一个动作。但是它不像forEach那样恢复整个流的运行,而是在一个元素上完成操作之后,只会将操作顺承到流水线中的下一个操作。图9-4解释了peek的操作流程。

9.4 调试 - 图1

图 9-4 使用peek查看Stream流水线中的数据流的值

下面的这段代码使用peek输出了Stream流水线操作之前和操作之后的中间值:

  1. List<Integer> result =
  2. numbers.stream()
  3. .peek(x -> System.out.println("from stream: " + x)) ←---- 输出来自数据源的当前元素值
  4. .map(x -> x + 17)
  5. .peek(x -> System.out.println("after map: " + x)) ←---- 输出map操作的结果
  6. .filter(x -> x % 2 == 0)
  7. .peek(x -> System.out.println("after filter: " + x)) ←---- 输出经过filter操作之后,剩下的元素个数
  8. .limit(3)
  9. .peek(x -> System.out.println("after limit: " + x)) ←---- 输出经过limit操作之后,剩下的元素个数
  10. .collect(toList());

通过peek操作能清楚地了解流水线操作中每一步的输出结果:

  1. from stream: 2
  2. after map: 19
  3. from stream: 3
  4. after map: 20
  5. after filter: 20
  6. after limit: 20
  7. from stream: 4
  8. after map: 21
  9. from stream: 5
  10. after map: 22
  11. after filter: 22
  12. after limit: 22