目录

重构、测试和调试

本章内容

  • 如何使用Lambda表达式重构代码
  • Lambda表达式对面向对象的设计模式的影响
  • Lambda表达式的测试
  • 如何调试使用Lambda表达式和Stream API的代码

为改善可读性和灵活性重构代码

从本书的开篇我们就一直在强调,利用Lambda表达式,你可以写出更简洁、更灵活的代码。 用“更简洁”来描述Lambda表达式是因为相较于匿名类,Lambda表达式可以帮助我们用更紧凑 的方式描述程序的行为。第3章中我们也提到,如果你希望将一个既有的方法作为参数传递给另 一个方法,那么方法引用无疑是我们推荐的方法,利用这种方式我们能写出非常简洁的代码。

采用Lambda表达式之后,你的代码会变得更加灵活,因为Lambda表达式鼓励大家使用行为参数化的方式。在这种方式下,应对需求的变化时,你的代码可以依据传入的 参数动态选择和执行相应的行为。

改善代码的可读性

改善代码的可读性到底意味着什么?我们很难定义什么是好的可读性,因为这可能非常主 观。通常的理解是,“别人理解这段代码的难易程度”。改善可读性意味着你要确保你的代码能非 常容易地被包括自己在内的所有人理解和维护。为了确保你的代码能被其他人理解,有几个步骤 可以尝试,比如确保你的代码附有良好的文档,并严格遵守编程规范。

跟之前的版本相比较,Java 8的新特性也可以帮助提升代码的可读性:

  • 使用Java 8,你可以减少冗长的代码,让代码更易于理解
  • 通过方法引用和Stream API,你的代码会变得更直观

这里我们会介绍三种简单的重构,利用Lambda表达式、方法引用以及Stream改善程序代码的 可读性

  • 重构代码,用Lambda表达式取代匿名类
  • 用方法引用重构Lambda表达式
  • 用Stream API重构命令式的数据处理

从匿名类到Lambda表达式的转换

你值得尝试的第一种重构,也是简单的方式,是将实现单一抽象方法的匿名类转换为Lambda 表达式。为什么呢?前面几章的介绍应该足以说服你,因为匿名类是极其繁琐且容易出错的。采 用Lambda表达式之后, 你的代码会更简洁, 可读性更好。

但是某些情况下,将匿名类转换为Lambda表达式可能是一个比较复杂的过程。

首先,匿名 类和Lambda表达式中的this和super的含义是不同的。在匿名类中,this代表的是类自身,但是在Lambda中,它代表的是包含类。其次,匿名类可以屏蔽包含类的变量,而Lambda表达式不 能(它们会导致编译错误),譬如下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    @Test
    public void test11() {
        int a = 10;
        Runnable r1 = () -> {
//            int a = 2;  // 编译错误
            System.out.println(a);
        };

        Runnable r2 = new Runnable() {
            @Override
            public void run() {
                int a = 2; // 编译正常
                System.out.println(a);
            }
        };
    }

最后,在涉及重载的上下文里,将匿名类转换为Lambda表达式可能导致最终的代码更加晦 涩。实际上,匿名类的类型是在初始化时确定的,而Lambda的类型取决于它的上下文。

通过下 面这个例子,我们可以了解问题是如何发生的。我们假设你用与Runnable同样的签名声明了一 个函数接口,我们称之为Task:

可以对Task尝试使用显式的类型转换来解决这种模棱两可的情况:

1
doSomething((Task) () -> System.out.println("Danger"));

但是不要因此而放弃对Lambda的尝试。好消息是,目前大多数的集成开发环境,比如NetBeans 和IntelliJ都支持这种重构,它们能自动地帮你检查,避免发生这些问题。

从Lambda表达式到方法引用的转换

Lambda表达式非常适用于需要传递代码片段的场景。不过,为了改善代码的可读性,也请 尽量使用方法引用。因为方法名往往能更直观地表达代码的意图。比如,第6章中我们曾经展示 过下面这段代码,它的功能是按照食物的热量级别对菜肴进行分类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream()
                .collect(groupingBy(t -> {
                    if (t.getCalories() < 400) {
                        return CaloricLevel.DIET;
                    } else if (t.getCalories() >= 400 && t.getCalories() < 700) {
                        return CaloricLevel.NORMAL;
                    } else
                        return CaloricLevel.FAT;
                }));
        System.out.println();

你可以将Lambda表达式的内容抽取到一个单独的方法中,将其作为参数传递给groupingBy 方法。变换之后,代码变得更加简洁,程序的意图也更加清晰了:

1
2
3
4
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType = menu.stream()
                .collect(groupingBy(Dish::getType,
                        mapping(Dish::getCaloricLevel, toCollection(HashSet::new))
                ));

为了实现这个方案,还需要在Dish类中添加getCaloricLevel方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Data
class Dish {
  ...

    public MenuDemo.CaloricLevel getCaloricLevel() {
        if (this.getCalories() <= 400) return MenuDemo.CaloricLevel.DIET;
        else if (this.getCalories() <= 700) return MenuDemo.CaloricLevel.NORMAL;
        else return MenuDemo.CaloricLevel.FAT;
    }
}

除此之外,我们还应该尽量考虑使用静态辅助方法,比如comparing、maxBy。这些方法设 计之初就考虑了会结合方法引用一起使用。通过示例,我们看到相对于第3章中的对应代码,优 化过的代码更清晰地表达了它的设计意图:

https://gitee.com/lienhui68/picStore/raw/master/null/20200812212145.png

此外,很多通用的归约操作,比如sum、maximum,都有内建的辅助方法可以和方法引用结 合使用。比如,在我们的示例代码中,使用Collectors接口可以轻松得到和或者最大值,与采 用Lambada表达式和底层的归约操作比起来,这种方式要直观得多。与其编写:

1
int totalCalories = menu.stream().map(Dish::getCalories) .reduce(0, (c1, c2) -> c1 + c2);

不如尝试使用内置的集合类,它能更清晰地表达问题陈述是什么。下面的代码中,我们使用了集 合类summingInt(方法的名词很直观地解释了它的功能):

1
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

从命令式的数据处理切换到Stream

我们建议你将所有使用迭代器这种数据处理模式处理集合的代码都转换成Stream API的方 式。为什么呢?Stream API能更清晰地表达数据处理管道的意图。除此之外,通过短路和延迟载 入以及利用第7章介绍的现代计算机的多核架构,我们可以对Stream进行优化。

增加代码的灵活性

我们曾经介绍过Lambda表达式有利于行为参数化。 你可以使用不同的 Lambda表示不同的行为,并将它们作为参数传递给函数去处理执行。这种方式可以帮助我们淡 定从容地面对需求的变化。比如,我们可以用多种方式为Predicate创建筛选条件,或者使用 Comparator对多种对象进行比较。现在,我们来看看哪些模式可以马上应用到你的代码中,让 你享受Lambda表达式带来的便利。

采用函数接口

首先,你必须意识到,没有函数接口,你就无法使用Lambda表达式。因此,你需要在代码 中引入函数接口。听起来很合理,但是在什么情况下使用它们呢?这里我们介绍两种通用的模式, 你可以依照这两种模式重构代码,利用Lambda表达式带来的灵活性,它们分别是:有条件的延迟执行和环绕执行。除此之外,在下一节,我们还将介绍一些基于面向对象的设计模式,比如策 略模式或者模板方法,这些在使用Lambda表达式重写后会更简洁。

有条件的延迟执行

举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
    public void test10() {
        Logger logger = Logger.getGlobal();
        logger.setLevel(Level.SEVERE);
//        logger.info("log test: " + generateDiagnostic());
        logger.info(() -> "log test: " + generateDiagnostic());
    }

    public String generateDiagnostic() {
        System.out.println("working...");
        return "world";
    }

运行结果:

注释掉的那一行会打印出working…. 因为该方法会先被计算传递给info方法,然后再确定是否真的要执行。

然而我们已经将日志级别设置成SERVER了,无需执行generateDiagnostic()方法。

从这个故事里我们学到了什么呢?如果你发现你需要频繁地从客户端代码去查询一个对象 的状态(比如前文例子中的日志器的状态),只是为了传递参数、调用该对象的一个方法(比如 输出一条日志),那么可以考虑实现一个新的方法,以Lambda或者方法表达式作为参数,新方法 在检查完该对象的状态之后才调用原来的方法。你的代码会因此而变得更易读(结构更清晰), 封装性更好(对象的状态也不会暴露给客户端代码了)。

https://gitee.com/lienhui68/picStore/raw/master/null/20200812213318.png

环绕执行

第3章中,我们介绍过另一种值得考虑的模式,那就是环绕执行。如果你发现虽然你的业务 代码千差万别,但是它们拥有同样的准备和清理阶段,这时,你完全可以将这部分代码用Lambda 实现。这种方式的好处是可以重用准备和清理阶段的逻辑,减少重复冗余的代码。

使用Lambda重构面向对象的设计模式

Lambda表达式为程序员的工具箱又新添了一件利器。它们为解决传统设计模式所面对的问 题提供了新的解决方案,不但如此,采用这些方案往往更高效、更简单。使用Lambda表达式后, 很多现存的略显臃肿的面向对象设计模式能够用更精简的方式实现了。这一节中,我们会针对五 个设计模式展开讨论,它们分别是:

  • 策略模式
  • 模板方法
  • 观察者模式
  • 责任链模式
  • 工厂模式

我们会展示Lambda表达式是如何另辟蹊径解决设计模式原来试图解决的问题的。

策略模式

策略模式代表了解决一类算法的通用解决方案,你可以在运行时选择使用哪种方案。

我们假设你希望验证输入的内容是否根据标准进行了恰当的格式化(比如只包含小写字母或 数字)。你可以从定义一个验证文本(以String的形式表示)的接口入手:

1
2
3
interface ValidationStrategy {
    boolean execute(String s);
}

其次,你定义了该接口的一个或多个具体实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class IsAllLowerCase implements ValidationStrategy {
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

class IsNumeric implements ValidationStrategy {
    public boolean execute(String s) {
        return s.matches("\\d+");
    }
}

之后,你就可以在你的程序中使用这些略有差异的验证策略了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Validator {
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy v) {
        this.strategy = v;
    }

    public boolean validate(String s) {
        return strategy.execute(s);
    }

}
1
2
3
4
5
6
    public static void main(String[] args) {
        Validator numericValidator = new Validator(new IsNumeric());
        boolean b1 = numericValidator.validate("aaaa");
        Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
        boolean b2 = lowerCaseValidator.validate("bbbb");
    }

使用Lambda表达式

到现在为止,你应该已经意识到ValidationStrategy是一个函数接口了(除此之外,它 还与Predicate<String>具有同样的函数描述)。这意味着我们不需要声明新的类来实现不同 的策略,通过直接传递Lambda表达式就能达到同样的目的,并且还更简洁:

1
2
3
4
5
6
    public static void main(String[] args) {
        Validator numericValidator = new Validator(s -> s.matches("\\d+"));
        boolean b1 = numericValidator.validate("aaaa");
        Validator lowerCaseValidator = new Validator(s -> s.matches("[a-z]+"));
        boolean b2 = lowerCaseValidator.validate("bbbb");
    }

正如你看到的,Lambda表达式避免了采用策略设计模式时僵化的模板代码。如果你仔细分 析一下个中缘由,可能会发现,Lambda表达式实际已经对部分代码(或策略)进行了封装,而 这就是创建策略设计模式的初衷。因此,我们强烈建议对类似的问题,你应该尽量使用Lambda 表达式来解决。

模板方法

如果你需要采用某个算法的框架,同时又希望有一定的灵活度,能对它的某些部分进行改进, 那么采用模板方法设计模式是比较通用的方案。

让我们从一个例子着手,看看这个模式是如何工作的。

假设你需要编写一个简单的在线银行应用。通常,用户需要输入一个用户账户,之后应用才能从银行的数据库中得到用户的详细信息, 最终完成一些让用户满意的操作。不同分行的在线银行应用让客户满意的方式可能还略有不同, 比如给客户的账户发放红利,或者仅仅是少发送一些推广文件。你可能通过下面的抽象类方式来实现在线银行应用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
abstract class OnlineBanking {

    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }

    abstract void makeCustomerHappy(Customer c);

}

processCustomer方法搭建了在线银行算法的框架:获取客户提供的ID,然后提供服务让 用户满意。不同的支行可以通过继承OnlineBanking类,对该方法提供差异化的实现。

使用Lambda表达式

你想要插入的不同算法组件可以通过Lambda表达式或者方法引用的方式实现。 这里我们向processCustomer方法引入了第二个参数,它是一个Consumer<Customer>类 型的参数,与前文定义的makeCustomerHappy的特征保持一致:

1
2
3
4
public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
    }

现在, 你可以很方便地通过传递Lambda表达式, 直接插入不同的行为, 不再需要继承 OnlineBanking类了:

1
new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println("Hello " + c.getName());

观察者模式

观察者模式是一种比较常见的方案,某些事件发生时(比如状态转变),如果一个对象(通 常我们称之为主题)需要自动地通知其他多个对象(称为观察者),就会采用该方案。

让我们写点儿代码来看看观察者模式在实际中多么有用。

你需要为Twitter这样的应用设计并 实现一个定制化的通知系统。想法很简单:好几家报纸机构,比如《纽约时报》《卫报》以及《世 界报》都订阅了新闻,他们希望当接收的新闻中包含他们感兴趣的关键字时,能得到特别通知。

首先,你需要一个观察者接口,它将不同的观察者聚合在一起。它仅有一个名为notify的 方法,一旦接收到一条新的新闻,该方法就会被调用:

1
2
3
interface Observer {
    void notify(String tweet);
}

现在,你可以声明不同的观察者(比如,这里是三家不同的报纸机构),依据新闻中不同的 关键字分别定义不同的行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class NYTimes implements Observer {
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("money")) {
            System.out.println("Breaking news in NY! " + tweet);
        }
    }
}

class Guardian implements Observer {
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("queen")) {
            System.out.println("Yet another news in London... " + tweet);
        }
    }
}

class LeMonde implements Observer {
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("wine")) {
            System.out.println("Today cheese, wine and news! " + tweet);
        }
    }
}

你还遗漏了最重要的部分:Subject!让我们为它定义一个接口:

1
2
3
4
5
interface Subject {
    void registerObserver(Observer o);

    void notifyObservers(String tweet);
}

Subject使用registerObserver方法可以注册一个新的观察者,使用notifyObservers 方法通知它的观察者一个新闻的到来。让我们更进一步,实现Feed类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Feed implements Subject {

    private final List<Observer> observers = new ArrayList<>();

    public void registerObserver(Observer o) {
        this.observers.add(o);
    }

    public void notifyObservers(String tweet) {
        observers.forEach(o -> o.notify(tweet));
    }

}

这是一个非常直观的实现:Feed类在内部维护了一个观察者列表,一条新闻到达时,它就 进行通知。

1
2
3
4
5
6
7
public static void main(String[] args) {
        Feed f = new Feed();
        f.registerObserver(new NYTimes());
        f.registerObserver(new Guardian());
        f.registerObserver(new LeMonde());
        f.notifyObservers("The queen said her favourite book is Java 8 in Action!");
    }

毫不意外,《卫报》会特别关注这条新闻!

1
Yet another news in London... The queen said her favourite book is Java 8 in Action!

使用Lambda表达式后, 你无需显式地实例化三个观察者对象,直接传递Lambda表达式表示需要执行的行为即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) {
        Feed f = new Feed();
        f.registerObserver(tweet -> {
            if (tweet != null && tweet.contains("money")) {
                System.out.println("Breaking news in NY! " + tweet);
            }
        });
        f.registerObserver(tweet -> {
            if (tweet != null && tweet.contains("queen")) {
                System.out.println("Yet another news in London... " + tweet);
            }
        });
        f.registerObserver(tweet -> {
            if (tweet != null && tweet.contains("wine")) {
                System.out.println("Today cheese, wine and news! " + tweet);
            }
        });
        f.notifyObservers("The queen said her favourite book is Java 8 in Action!");
    }

那么,是否我们随时随地都可以使用Lambda表达式呢?答案是否定的!我们前文介绍的例 子中,Lambda适配得很好,那是因为需要执行的动作都很简单,因此才能很方便地消除僵化代 码。但是,观察者的逻辑有可能十分复杂,它们可能还持有状态,抑或定义了多个方法,诸如此 类。在这些情形下,你还是应该继续使用类的方式。

责任链模式

责任链模式是一种创建处理对象序列(比如操作序列)的通用方案。一个处理对象可能需要 在完成一些工作之后,将结果传递给另一个对象,这个对象接着做一些工作,再转交给下一个处 理对象,以此类推。

通常,这种模式是通过定义一个代表处理对象的抽象类来实现的,在抽象类中会定义一个字 段来记录后续对象。一旦对象完成它的工作,处理对象就会将它的工作转交给它的后继。代码中, 这段逻辑看起来是下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
abstract class ProcessingObject<T> {

    protected ProcessingObject<T> successor;

    public void setSuccessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }

    public T handle(T input) {
        T r = handleWork(input);
        if (successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    abstract protected T handleWork(T input);

}

图8-3以UML的方式阐释了责任链模式。

https://gitee.com/lienhui68/picStore/raw/master/null/20200812220158.png

可能你已经注意到,这就是8.2.2节介绍的模板方法设计模式。handle方法提供了如何进行 工作处理的框架。不同的处理对象可以通过继承ProcessingObject类,提供handleWork方法 来进行创建。

下面让我们看看如何使用该设计模式。你可以创建两个处理对象,它们的功能是进行一些文 本处理工作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class HeaderTextProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
        return "From Raoul, Mario and Alan: " + text;
    }
}

class SpellCheckerProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
        return text.replaceAll("labda", "lambda");
    }
}

现在你就可以将这两个处理对象结合起来,构造一个操作序列!

1
2
3
4
5
6
7
8
public static void main(String[] args) {
        ProcessingObject<String> p1 = new HeaderTextProcessing();
        ProcessingObject<String> p2 = new SpellCheckerProcessing();
        p1.setSuccessor(p2);

        String result = p1.handle("Aren't labdas really sexy?!!");
        System.out.println(result);
    }

使用Lambda表达式

稍等!这个模式看起来像是在链接(也即是构造)函数。第3章中我们探讨过如何构造Lambda 表达式。 你可以将处理对象作为函数的一个实例, 或者更确切地说作为 UnaryOperator- <String>的一个实例。为了链接这些函数,你需要使用andThen方法对其进行构造。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class Demo8 {

    public static void main(String[] args) {
        UnaryOperator<String> headerProcessing = text -> "From Raoul, Mario and Alan: " + text;
        UnaryOperator<String> spellCheckerProcessing = text -> text.replaceAll("labda", "lambda");

        Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing);
        String result = pipeline.apply("Aren't labdas really sexy?!!");
        System.out.println(result);
    }
}

工厂模式

使用工厂模式,你无需向客户暴露实例化的逻辑就能完成对象的创建。

比如,我们假定你为 一家银行工作,他们需要一种方式创建不同的金融产品:贷款、期权、股票,等等。 通常,你会创建一个工厂类,它包含一个负责实现不同对象的方法,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class ProductFactory {

    public static Product createProduct(String name) {
        switch (name) {
            case "loan":
                return new Loan();
            case "stock":
                return new Stock();
            case "bond":
                return new Bond();
            default:
                throw new RuntimeException("No such product " + name);
        }
    }
}

这里贷款( Loan )、股票( Stock )和债券( Bond )都是产品( Product )的子类。 createProduct 方法可以通过附加的逻辑来设置每个创建的产品。 但是带来的好处也显而易 见,你在创建对象时不用再担心会将构造函数或者配置暴露给客户,这使得客户创建产品时更 加简单:

1
Product p = ProductFactory.createProduct("loan");

使用Lambda表达式

第3章中,我们已经知道可以像引用方法一样引用构造函数。比如,下面就是一个引用贷款 (Loan)构造函数的示例:

Supplier<Product> loanSupplier = Loan::new; Loan loan = loanSupplier.get();

通过这种方式,你可以重构之前的代码,创建一个Map,将产品名映射到对应的构造函数:

1
2
3
4
5
6
7
8
final static Map<String, Supplier<Product>> map = new HashMap<>(); 
static {
  map.put("loan", Loan::new);

  map.put("stock", Stock::new);

  map.put("bond", Bond::new); 
}

现在,你可以像之前使用工厂设计模式那样,利用这个Map来实例化不同的产品。

1
2
3
4
5
    public static Product createProduct(String name) {
        Supplier<Product> p = map.get(name);
        if (p != null) return p.get();
        throw new IllegalArgumentException("No such product " + name);
    }

这是个全新的尝试,它使用Java 8中的新特性达到了传统工厂模式同样的效果。但是,如果 工厂方法createProduct需要接收多个传递给产品构造方法的参数,这种方式的扩展性不是很 好。你不得不提供不同的函数接口,无法采用之前统一使用一个简单接口的方式。

比如,我们假设你希望保存具有三个参数(两个参数为Integer类型,一个参数为String 类型)的构造函数;为了完成这个任务,你需要创建一个特殊的函数接口TriFunction。最终 的结果是Map变得更加复杂。

1
2
3
4
public interface TriFunction<T, U, V, R>{ 
  R apply(T t, U u, V v); 
} 
Map<String, TriFunction<Integer, Integer, String, Product>> map = new HashMap<>();

你已经了解了如何使用Lambda表达式编写和重构代码。接下来,我们会介绍如何确保新编 写代码的正确性。

测试 Lambda 表达式

现在你的代码中已经充溢着Lambda表达式,看起来不错,也很简洁。但是,大多数时候, 我们受雇进行的程序开发工作的要求并不是编写优美的代码,而是编写正确的代码。

通常而言,好的软件工程实践一定少不了单元测试,借此保证程序的行为与预期一致。你编 写测试用例,通过这些测试用例确保你代码中的每个组成部分都实现预期的结果。比如,图形应 用的一个简单的Point类,可以定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Getter
class Point {

    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public Point moveRightBy(int x) {
        return new Point(this.x + x, this.y);
    }

}

下面的单元测试会检查moveRightBy方法的行为是否与预期一致:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
    public void testMoveRightBy() {

        Point p1 = new Point(5, 5);

        Point p2 = p1.moveRightBy(10);

        Assert.assertEquals(15, p2.getX());
        Assert.assertEquals(5, p2.getY());

    }

测试可见Lambda函数的行为

由于moveRightBy方法声明为public,测试工作变得相对容易。你可以在用例内部完成测试。 但是Lambda并无函数名(毕竟它们都是匿名函数),因此要对你代码中的Lambda函数进行测试实 际上比较困难,因为你无法通过函数名的方式调用它们。

有些时候,你可以借助某个字段访问Lambda函数,这种情况,你可以利用这些字段,通过 它们对封装在Lambda函数内的逻辑进行测试。比如,我们假设你在Point类中添加了静态字段 compareByXAndThenY,通过该字段,使用方法引用你可以访问Comparator对象:

1
2
3
4
public class Point {
        public final static Comparator<Point> compareByXAndThenY = comparing(Point::getX).thenComparing(Point::getY);
        
    }

还记得吗,Lambda表达式会生成函数接口的一个实例。由此,你可以测试该实例的行为。 这个例子中,我们可以使用不同的参数,对Comparator对象类型实例compareByXAndThenY 的compare方法进行调用,验证它们的行为是否符合预期:

1
2
3
4
5
6
@Test public void testComparingTwoPoints() throws Exception { 
  	Point p1 = new Point(10, 15); 
  	Point p2 = new Point(10, 20); 
  	int result = Point.compareByXAndThenY.compare(p1 , p2); 
  	assertEquals(-1, result); 
}

测试使用Lambda的方法和行为

但是Lambda的初衷是将一部分逻辑封装起来给另一个方法使用。从这个角度出发,你不应 该将Lambda表达式声明为public,它们仅是具体的实现细节。相反,我们需要对使用Lambda表达式的方法进行测试。比如下面这个方法moveAllPointsRightBy:

1
2
3
public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
        return points.stream().map(p -> new Point(p.getX() + x, p.getY())).collect(toList());
    }

我们没必要对Lambda表达式p -> new Point(p.getX() + x,p.getY())进行测试,它 只是 moveAllPointsRightBy 内部的实现细节。 我们更应该关注的是方法 moveAllPoints- RightBy的行为:

1
2
3
4
5
6
7
8
9
    @Test
    public void testMoveAllPointsRightBy() throws Exception {

        List<Point> points = Arrays.asList(new Point(5, 5), new Point(10, 5));
        List<Point> expectedPoints = Arrays.asList(new Point(15, 5), new Point(20, 5));

        List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
        assertEquals(expectedPoints, newPoints);
    }

注意,上面的单元测试中,Point类恰当地实现equals方法非常重要,否则该测试的结果 就取决于Object类的默认实现。

将复杂的Lambda表达式分到不同的方法

可能你会碰到非常复杂的Lambda表达式,包含大量的业务逻辑,比如需要处理复杂情况的 定价算法。 你无法在测试程序中引用Lambda表达式, 这种情况该如何处理呢?一种策略是将 Lambda表达式转换为方法引用(这时你往往需要声明一个新的常规方法),我们在8.1.3节详细讨 论过这种情况。这之后,你可以用常规的方式对新的方法进行测试。

高阶函数的测试

接受函数作为参数的方法或者返回一个函数的方法(所谓的“高阶函数”, higher-order function,我们在第14章会深入展开介绍)更难测试。如果一个方法接受Lambda表达式作为参数, 你可以采用的一个方案是使用不同的Lambda表达式对它进行测试。比如,你可以使用不同的谓 词对第2章中创建的filter方法进行测试。

1
2
3
4
5
6
7
8
@Test 
public void testFilter() throws Exception{ 
  	List<Integer> numbers = Arrays.asList(1, 2, 3, 4); 
  	List<Integer> even = filter(numbers, i -> i % 2 == 0); 
  	List<Integer> smallerThanThree = filter(numbers, i -> i < 3); 
  	assertEquals(Arrays.asList(2, 4), even); 
  	assertEquals(Arrays.asList(1, 2), smallerThanThree); 
}

如果被测试方法的返回值是另一个方法, 该如何处理呢?你可以仿照我们之前处理 Comparator的方法,把它当成一个函数接口,对它的功能进行测试。

然而,事情可能不会一帆风顺,你的测试可能会返回错误,报告说你使用Lambda表达式的 方式不对。因此,我们现在进入调试的环节。

调试

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

  • 查看栈跟踪

  • 输出日志

查看栈跟踪

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

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

Lambda表达式和栈跟踪

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

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

运行这段代码会产生下面的栈跟踪:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
12
Exception in thread "main" java.lang.NullPointerException
	at com.eh.eden.java8.demo.Debugging.lambda$main$0(Debugging.java:17)
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
	at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
	at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
	at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
	at com.eh.eden.java8.demo.Debugging.main(Debugging.java:17)

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

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

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

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

    public static int divideByZero(int n) {
        return n / 0;
    }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

Exception in thread "main" java.lang.ArithmeticException: / by zero
	at com.eh.eden.java8.demo.Debugging.divideByZero(Debugging.java:21)
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
	at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
	at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
	at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
	at com.eh.eden.java8.demo.Debugging.main(Debugging.java:17)

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

使用日志调试

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

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(2, 3, 4, 5);

        numbers.stream()
                .map(x -> x + 17).
                filter(x -> x % 2 == 0)
                .limit(3)
                .forEach(System.out::println);
    }

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

这就是流操作方法peek大显身手的时候。peek的设计初衷就是在流的每个元素恢复运行之 前,插入执行一个动作。但是它不像forEach那样恢复整个流的运行,而是在一个元素上完成操 作之后,它只会将操作顺承到流水线中的下一个操作。图8-4解释了peek的操作流程。下面的这 段代码中,我们使用peek输出了Stream流水线操作之前和操作之后的中间值:

https://gitee.com/lienhui68/picStore/raw/master/null/20200812224138.png

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

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

小结

下面回顾一下这一章的主要内容。

  • Lambda表达式能提升代码的可读性和灵活性。
  • 如果你的代码中使用了匿名类,尽量用Lambda表达式替换它们,但是要注意二者间语义的微妙差别,比如关键字this,以及变量隐藏。
  • 跟Lambda表达式比起来,方法引用的可读性更好 。
  • 尽量使用Stream API替换迭代式的集合处理。
  • Lambda表达式有助于避免使用面向对象设计模式时容易出现的僵化的模板代码,典型的比如策略模式、模板方法、观察者模式、责任链模式,以及工厂模式。
  • 即使采用了Lambda表达式, 也同样可以进行单元测试, 但是通常你应该关注使用了Lambda表达式的方法的行为。
  • 尽量将复杂的Lambda表达式抽象到普通方法中。
  • Lambda表达式会让栈跟踪的分析变得更为复杂。
  • 流提供的peek方法在分析Stream流水线时,能将中间变量的值输出到日志中,是非常有用的工具。