第8章-重构、测试和调试

gong_yz大约 23 分钟JAVAJAVA8

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

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

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

策略模式

以将这一模式应用到更广泛的领域,比如使用不同的标准来验证输入的有效性,使用不同的方式来分析或者格式化输入。

策略模式包含三部分内容,如图所示。

  • 一个代表某个算法的接口(它是策略模式的接口)。
  • 一个或多个该接口的具体实现,它们代表了算法的多种实现(比如,实体类ConcreteStrategyA或者ConcreteStrategyB)。
  • 一个或多个使用策略对象的客户。
image-20220318152849023
image-20220318152849023

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

public interface ValidationStrategy { 
 boolean execute(String s); 
}

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

public class IsAllLowerCase implements ValidationStrategy { 
 	public boolean execute(String s){ 
 	return s.matches("[a-z]+"); 
 } 
} 
public class IsNumeric implements ValidationStrategy { 
 	public boolean execute(String s){ 
 	return s.matches("\\d+"); 
 } 
}

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

public class Validator{ 
 	private final ValidationStrategy strategy; 
 	public Validator(ValidationStrategy v){ 
 		this.strategy = v;
 	} 
 	
 	public boolean validate(String s){ 
 		return strategy.execute(s); 
 	} 
} 

	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表达式就能达到同样的目的,并且还更简洁:

Validator numericValidator = new Validator((String s) -> s.matches("[a-z]+")); 
boolean b1 = numericValidator.validate("aaaa"); 
Validator lowerCaseValidator = new Validator((String s) -> s.matches("\\d+")); 
boolean b2 = lowerCaseValidator.validate("bbbb");

正如你看到的,Lambda表达式避免了采用策略设计模式时僵化的模板代码。如果你仔细分析一下个中缘由,可能会发现,Lambda表达式实际已经对部分代码(或策略)进行了封装,而这就是创建策略设计模式的初衷。

完整代码

StrategyMain.java


/**
 * 展示Lambda表达式替代策略模式想要解决的问题
 */
public class StrategyMain {

    public static void main(String[] args) {
        // old school
        Validator v1 = new Validator(new IsNumeric());
        System.out.println(v1.validate("aaaa"));
        Validator v2 = new Validator(new IsAllLowerCase());
        System.out.println(v2.validate("bbbb"));


        // with lambdas
        Validator v3 = new Validator((String s) -> s.matches("\\d+"));
        System.out.println(v3.validate("aaaa"));
        Validator v4 = new Validator((String s) -> s.matches("[a-z]+"));
        System.out.println(v4.validate("bbbb"));
    }

    interface ValidationStrategy {
        boolean execute(String s);
    }

    static private class IsAllLowerCase implements ValidationStrategy {
        @Override
        public boolean execute(String s) {
            return s.matches("[a-z]+");
        }
    }

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

    static private class Validator {
        private final ValidationStrategy strategy;

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

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


模板方法

如果你需要采用某个算法的框架,同时又希望有一定的灵活度,能对它的某些部分进行改进,那么采用模板方法设计模式是比较通用的方案。好吧,这样讲听起来有些抽象。换句话说,模板方法模式在你“希望使用这个算法,但是需要对其中的某些行进行改进,才能达到希望的效果”时是非常有用的。

举例

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

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表达式同样也可以解决这些问题(创建算法框架,让具体的实现插入某些部分)。你想要插入的不同算法组件可以通过Lambda表达式或者方法引用的方式实现。

这里我们向processCustomer方法引入了第二个参数,它是一个Consumer<Customer>类型的参数,与前文定义的makeCustomerHappy的特征保持一致:

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

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

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

这是又一个例子,佐证了Lamba表达式能帮助你解决设计模式与生俱来的设计僵化问题。

完整代码

OnlineBanking.java


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

    abstract void makeCustomerHappy(Customer c);


    // dummy Customer class
    static private class Customer {
    }

    // dummy Datbase class
    static private class Database {
        static Customer getCustomerWithId(int id) {
            return new Customer();
        }
    }
}

OnlineBankingLambda.java


import java.util.function.Consumer;


public class OnlineBankingLambda {

    public static void main(String[] args) {
        new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println("Hello!"));
    }

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

    // dummy Customer class
    static private class Customer {
    }

    // dummy Database class
    static private class Database {
        static Customer getCustomerWithId(int id) {
            return new Customer();
        }
    }
}


观察者模式

观察者模式是一种比较常见的方案,某些事件发生时(比如状态转变),如果一个对象(通常我们称之为主题)需要自动地通知其他多个对象(称为观察者),就会采用该方案。创建图形用户界面(GUI)程序时,你经常会使用该设计模式。这种情况下,你会在图形用户界面组件(比如按钮)上注册一系列的观察者。如果点击按钮,观察者就会收到通知,并随即执行某个特定的行为。 但是观察者模式并不局限于图形用户界面。比如,观察者设计模式也适用于股票交易的情形,多个券商可能都希望对某一支股票价格(主题)的变动做出响应。通过UML图解释了观察者模式。

image-20220318171125220
image-20220318171125220

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

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

interface Observer { 
	void notify(String tweet); 
}

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

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!让我们为它定义一个接口:

interface Subject{ 
 	void registerObserver(Observer o); 
 	void notifyObservers(String tweet); 
}

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

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类在内部维护了一个观察者列表,一条新闻到达时,它就进行通知。

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!");

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

使用Lambda表达式

你可能会疑惑Lambda表达式在观察者设计模式中如何发挥它的作用。不知道你有没有注意到,Observer接口的所有实现类都提供了一个方法:notify。新闻到达时,它们都只是对同一段代码封装执行。Lambda表达式的设计初衷就是要消除这样的僵化代码。使用Lambda表达式后,你无需显式地实例化三个观察者对象,直接传递Lambda表达式表示需要执行的行为即可:

f.registerObserver((String tweet) -> { 
	if(tweet != null && tweet.contains("money")){ 
 	System.out.println("Breaking news in NY! " + tweet); 
 } 
}); 
f.registerObserver((String tweet) -> { 
 	if(tweet != null && tweet.contains("queen")){ 
 	System.out.println("Yet another news in London... " + tweet); 
 } 
});

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

完整代码


import java.util.ArrayList;
import java.util.List;


public class ObserverMain {

    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!");


        Feed feedLambda = new Feed();

        feedLambda.registerObserver((String tweet) -> {
            if (tweet != null && tweet.contains("money")) {
                System.out.println("Breaking news in NY! " + tweet);
            }
        });
        feedLambda.registerObserver((String tweet) -> {
            if (tweet != null && tweet.contains("queen")) {
                System.out.println("Yet another news in London... " + tweet);
            }
        });

        feedLambda.notifyObservers("Money money money, give me money!");

    }


    interface Observer {
        void inform(String tweet);
    }

    interface Subject {
        void registerObserver(Observer o);

        void notifyObservers(String tweet);
    }

    static private class NYTimes implements Observer {
        @Override
        public void inform(String tweet) {
            if (tweet != null && tweet.contains("money")) {
                System.out.println("Breaking news in NY!" + tweet);
            }
        }
    }

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

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

    static private class Feed implements Subject {
        private final List<Observer> observers = new ArrayList<>();

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

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

}


责任链模式

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

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

public 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); 
}

下图以UML的方式阐释了责任链模式。

image-20220318182248046
image-20220318182248046

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

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

public class HeaderTextProcessing extends ProcessingObject<String> { 
 	public String handleWork(String text){ 
 		return "From Raoul, Mario and Alan: " + text; 
 	} 
} 

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

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

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方法对其进行构造。

//第一个处理对象
UnaryOperator<String> headerProcessing = 
 (String text) -> "From Raoul, Mario and Alan: " + text;
 
//第二个处理对象
UnaryOperator<String> spellCheckerProcessing = 
 (String text) -> text.replaceAll("labda", "lambda"); 
 
//将两个方法结合起来,结果就是一个操作链
Function<String, String> pipeline = 
 headerProcessing.andThen(spellCheckerProcessing); 
 
String result = pipeline.apply("Aren't labdas really sexy?!!")

工厂模式

使用工厂模式,你无需向客户暴露实例化的逻辑就能完成对象的创建。比如,我们假定你为一家银行工作,他们需要一种方式创建不同的金融产品:贷款、期权、股票,等等。

通常,你会创建一个工厂类,它包含一个负责实现不同对象的方法,如下所示:

public 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方法可以通过附加的逻辑来设置每个创建的产品。但是带来的好处也显而易见,你在创建对象时不用再担心会将构造函数或者配置暴露给客户,这使得客户创建产品时更加简单:

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

使用Lambda表达式

下面就是一个引用贷款(Loan)构造函数的示例:

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

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

 final static private Map<String, Supplier<Product>> map = new HashMap<>();
    static {
        map.put("loan", Loan::new);
        map.put("stock", Stock::new);
        map.put("bond", Bond::new);
    }

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

public static Product createProductLambda(String name){
            Supplier<Product> p = map.get(name);
            if(p != null){
                return p.get();
            }
            throw new RuntimeException("No such product " + name);
        }

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

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

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<>();

完整代码

FactoryMain.java

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;


public class FactoryMain {

    public static void main(String[] args) {
        Product p1 = ProductFactory.createProduct("loan");

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

        Product p3 = ProductFactory.createProductLambda("loan");

    }

    static private 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);
            }
        }

        public static Product createProductLambda(String name){
            Supplier<Product> p = map.get(name);
            if(p != null){
                return p.get();
            }
            throw new RuntimeException("No such product " + name);
        }
    }

    static private interface Product {}
    static private class Loan implements Product {}
    static private class Stock implements Product {}
    static private class Bond implements Product {}

    final static private Map<String, Supplier<Product>> map = new HashMap<>();
    {
        map.put("loan", Loan::new);
        map.put("stock", Stock::new);
        map.put("bond", Bond::new);
    }
}


测试 Lambda 表达式

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

public class Point {

    private final int x;
    private final int y;

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

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

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

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

    @Test
    public void testMoveRightBy() throws Exception {
        Point p1 = new Point(5, 5);
        Point p2 = p1.moveRightBy(10);
        assertEquals(15, p2.getX());
        assertEquals(5, p2.getY());
    }

测试可见 Lambda 函数的行为

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

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

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

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

    @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:

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内部的实现细节。我们更应该关注的是方法moveAllPointsRightBy的行为:

    @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表达式转换为方法引用(这时你往往需要声明一个新的常规方法),这之后,你可以用常规的方式对新的方法进行测试。

高阶函数的测试

接受函数作为参数的方法或者返回一个函数的方法更难测试。如果一个方法接受Lambda表达式作为参数,你可以采用的一个方案是使用不同的Lambda表达式对它进行测试。比如,你可以使用不同的谓词对通过行为参数化传递代码章节创建的filter方法进行测试。

    @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表达式没有名字,它的栈跟踪可能很难分析。在下面这段简单的代码中,我们刻意地引入了一些错误:

public class Debugging {
    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);
    }


    private static class Point {
        private int x;
        private int y;

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

        public int getX() {
            return x;
        }

        public void setX(int x) {
            this.x = x;
        }
    }
}

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

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

这段程序当然会失败,因为Points列表的第二个元素是空(null)。

这时你的程序实际是在试图处理一个空引用。由于Stream流水线发生了错误,构成Stream流水线的整个方法调用序列都暴露在你面前了。不过,你留意到了吗?栈跟踪中还包含下面这样类似加密的内容:

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

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

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

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

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

import java.util.*; 
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在栈跟踪中就正确地显示了:

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

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

使用日志调试

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

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);

这段代码的输出如下:

20

22

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

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

List<Integer> result = 
 numbers.stream() 
 		.peek(x -> System.out.println("from stream: " + x))
 		.map(x -> x + 17) 
 		.peek(x -> System.out.println("after map: " + x)) 
 		.filter(x -> x % 2 == 0) 
 		.peek(x -> System.out.println("after filter: " + x))
 		.limit(3) 
 		.peek(x -> System.out.println("after limit: " + x))
 		.collect(toList());
image-20220505113458631
image-20220505113458631

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

from stream: 2 
after map: 19
from stream: 3 
after map: 20 
after filter: 20 
after limit: 20 
from stream: 4 
after map: 21 
from stream: 5 
after map: 22 
after filter: 22 
after limit: 22

小结

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

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