8.1 Lambda表达式改变了设计模式

设计模式是人们熟悉的另一种设计思想,它是软件架构中解决通用问题的模板。如果碰到一个问题,并且恰好熟悉一个与之适应的模式,就能直接应用该模式来解决问题。从某种程度上来说,设计模式将解决特定问题的最佳实践途径固定了下来。

当然,没有永远的最佳实践。以曾经风靡一时的单例模式为例,该模式确保只产生一个对象实例。在过去十年中,人们批评它让程序变得更脆弱,且难于测试。敏捷开发的流行,让测试显得更加重要,单例模式的这个问题把它变成了一个反模式:一种应该避免使用的模式。

本书的重点并不是讨论设计模式如何变得过时,相反,我们讨论的是如何使用Lambda表达式,让现有设计模式变得更好、更简单,或者在某些情况下,有了不同的实现方式。Java 8引入的新语言特性是所有这些设计模式变化的推动因素。

8.1.1 命令者模式

命令者是一个对象,它封装了调用另一个方法的所有细节,命令者模式使用该对象,可以编写出根据运行期条件,顺序调用方法的一般化代码。命令者模式中有四个类参与其中,如图8-1所示。

命令接收者

执行实际任务。

命令者

封装了所有调用命令执行者的信息。

发起者

控制一个或多个命令的顺序和执行。

客户端

创建具体的命令者实例。

8.1 Lambda表达式改变了设计模式 - 图1

图8-1:命令者模式

看一个命令者模式的具体例子,看看如何使用Lambda表达式改进该模式。假设有一个GUI Editor组件,在上面可以执行opensave等一系列操作,如例8-1所示。现在我们想实现宏功能——也就是说,可以将一系列操作录制下来,日后作为一个操作执行,这就是我们的命令接收者。

例8-1 文本编辑器可能具有的一般功能

  1. public interface Editor {
  2. public void save();
  3. public void open();
  4. public void close();
  5. }

在该例子中,像opensave这样的操作称为命令,我们需要一个统一的接口来概括这些不同的操作,我将这个接口叫做Action,它代表了一个操作。所有的命令都要实现该接口(例8-2)。

例8-2 所有操作均实现Action接口

  1. public interface Action {
  2. public void perform();
  3. }

现在让每个操作都实现该接口,这些类要做的只是在Action接口中调用Editor类中的一个方法。我将遵循恰当的命名规范,用类名代表操作,比如save方法对应Save类。例8-3和例8-4是定义好的命令对象。

例8-3 保存操作代理给Editor方法

  1. public class Save implements Action {
  2. private final Editor editor;
  3. public Save(Editor editor) {
  4. this.editor = editor;
  5. }
  6. @Override
  7. public void perform() {
  8. editor.save();
  9. }
  10. }

例8-4 打开文件操作代理给Editor方法

  1. public class Open implements Action {
  2. private final Editor editor;
  3. public Open(Editor editor) {
  4. this.editor = editor;
  5. }
  6. @Override
  7. public void perform() {
  8. editor.open();
  9. }
  10. }

现在可以实现Macro类了,该类record操作,然后一起运行。我们使用List保存操作序列,然后调用forEach方法按顺序执行每一个Action,例8-5就是我们的命令发起者。

例8-5 包含操作序列的宏,可按顺序执行操作

  1. public class Macro {
  2. private final List<Action> actions;
  3. public Macro() {
  4. actions = new ArrayList<>();
  5. }
  6. public void record(Action action) {
  7. actions.add(action);
  8. }
  9. public void run() {
  10. actions.forEach(Action::perform);
  11. }
  12. }

在构建宏时,将每一个命令实例加入Macro对象的列表,然后运行宏,就会按顺序执行每一条命令。我是个“懒惰的”程序员,喜欢将通用的工作流定义成宏。我说“懒惰”了吗?我的意思其实是提高工作效率。例8-6展示了如何在用户代码中使用Macro对象。

例8-6 使用命令者模式构建宏

  1. Macro macro = new Macro();
  2. macro.record(new Open(editor));
  3. macro.record(new Save(editor));
  4. macro.record(new Close(editor));
  5. macro.run();

Lambda表达式能做点什么呢?事实上,所有的命令类,SaveOpen都是Lambda表达式,只是暂时藏在类的外壳下。它们是一些行为,我们通过创建类将它们在对象之间传递。Lambda表达式能让这个模式变得非常简单,我们可以扔掉这些类。例8-7展示了去掉命令类,使用Lambda表达式后的程序。

例8-7 使用Lambda表达式构建宏

  1. Macro macro = new Macro();
  2. macro.record(() -> editor.open());
  3. macro.record(() -> editor.save());
  4. macro.record(() -> editor.close());
  5. macro.run();

事实上 ,如果意识到这些Lambda表达式的作用只是调用了一个方法,还能让问题变得更简单。我们可以使用方法引用将命令和宏对象关联起来(如例8-8所示)。

例8-8 使用方法引用构建宏

  1. Macro macro = new Macro();
  2. macro.record(editor::open);
  3. macro.record(editor::save);
  4. macro.record(editor::close);
  5. macro.run();

命令者模式只是一个可怜的程序员使用Lambda表达式的起点。使用Lambda表达式或是方法引用,能让代码更简洁,去除了大量样板代码,让代码意图更加明显。

宏只是使用命令者模式的一个例子,它被大量用在实现组件化的图形界面系统、撤销功能、线程池、事务和向导中。

8.1 Lambda表达式改变了设计模式 - 图2在核心Java中,已经有一个和Action接口结构一致的函数接口——Runnable。我们可以在实现上述宏程序中直接使用该接口,但在这个例子中,似乎Action是一个更符合我们待解问题的词汇,因此我们创建了自己的接口。

8.1.2 策略模式

策略模式能在运行时改变软件的算法行为。如何实现策略模式根据你的情况而定,但其主要思想是定义一个通用的问题,使用不同的算法来实现,然后将这些算法都封装在一个统一接口的背后。

文件压缩就是一个很好的例子。我们提供给用户各种压缩文件的方式,可以使用zip算法,也可以使用gzip算法,我们实现一个通用的Compressor类,能以任何一种算法压缩文件。

首先,为我们的策略定义API(参见图8-2),我把它叫作CompressionStrategy,每一种文件压缩算法都要实现该接口。该接口有一个compress方法,接受并返回一个OutputStream对象,返回的就是压缩后的OutputStream(如例8-9所示)。

8.1 Lambda表达式改变了设计模式 - 图3

图8-2:策略模式

例8-9 定义压缩数据的策略接口

  1. public interface CompressionStrategy {
  2. public OutputStream compress(OutputStream data) throws IOException;
  3. }

我们有两个类实现了该接口,分别代表gzip和ZIP算法,使用Java内置的类实现gzip(例8-10)和ZIP(例8-11)算法。

例8-10 使用gzip算法压缩数据

  1. public class GzipCompressionStrategy implements CompressionStrategy {
  2. @Override
  3. public OutputStream compress(OutputStream data) throws IOException {
  4. return new GZIPOutputStream(data);
  5. }
  6. }

例8-11 使用zip算法压缩数据

  1. public class ZipCompressionStrategy implements CompressionStrategy {
  2. @Override
  3. public OutputStream compress(OutputStream data) throws IOException {
  4. return new ZipOutputStream(data);
  5. }
  6. }

现在可以动手实现Compressor类了,这里就是使用策略模式的地方。该类有一个compress方法,读入文件,压缩后输出。它的构造函数有一个CompressionStrategy参数,调用代码可以在运行期使用该参数决定使用哪种压缩策略,比如,可以等待用户输入选择(如例8-12所示)。

例8-12 在构造类时提供压缩策略

  1. public class Compressor {
  2. private final CompressionStrategy strategy;
  3. public Compressor(CompressionStrategy strategy) {
  4. this.strategy = strategy;
  5. }
  6. public void compress(Path inFile, File outFile) throws IOException {
  7. try (OutputStream outStream = new FileOutputStream(outFile)) {
  8. Files.copy(inFile, strategy.compress(outStream));
  9. }
  10. }
  11. }

如果使用这种传统的策略模式实现方式,可以编写客户代码创建一个新的Compressor,并且使用任何我们想要的策略(如例8-13所示)。

例8-13 使用具体的策略类初始化Compressor

  1. Compressor gzipCompressor = new Compressor(new GzipCompressionStrategy());
  2. gzipCompressor.compress(inFile, outFile);
  3. Compressor zipCompressor = new Compressor(new ZipCompressionStrategy());
  4. zipCompressor.compress(inFile, outFile);

和前面讨论的命令者模式一样,使用Lambda表达式或者方法引用可以去掉样板代码。在这里,我们可以去掉具体的策略实现,使用一个方法实现算法,这里的算法由构造函数中对应的OutputStream实现。使用这种方式,可以完全舍弃GzipCompressionStrategyZipCompressionStrategy类。例8-14展示了使用方法引用后的代码。

例8-14 使用方法引用初始化Compressor

  1. Compressor gzipCompressor = new Compressor(GZIPOutputStream::new);
  2. gzipCompressor.compress(inFile, outFile);
  3. Compressor zipCompressor = new Compressor(ZipOutputStream::new);
  4. zipCompressor.compress(inFile, outFile);

8.1.3 观察者模式

观察者模式是另一种可被Lambda表达式简化和改进的行为模式。在观察者模式中,被观察者持有一个观察者列表。当被观察者的状态发生改变,会通知观察者。观察者模式被大量应用于基于MVC的GUI工具中,以此让模型状态发生变化时,自动刷新视图模块,达到二者之间的解耦。

观看GUI模块自动刷新有点枯燥,我们要观察的对象是月球!NASA和外星人都对登陆到月球上的东西感兴趣,都希望可以记录这些信息。NASA希望确保阿波罗号上的航天员成功登月;外星人则希望在NASA注意力分散之时进犯地球。

让我们先来定义观察者的API,这里我将观察者称作LandingObserver。它只有一个observeLanding方法,当有东西登陆到月球上时会调用该方法(例8-15)。

例8-15 用于观察登陆到月球的组织的接口

  1. public interface LandingObserver {
  2. public void observeLanding(String name);
  3. }

被观察者是月球Moon,它持有一组LandingObserver实例,有东西着陆时会通知这些观察者,还可以增加新的LandingObserver实例观测Moon对象(例8-16)。

例8-16 Moon类——当然不如现实世界中那么完美

  1. public class Moon {
  2. private final List<LandingObserver> observers = new ArrayList<>();
  3. public void land(String name) {
  4. for (LandingObserver observer : observers) {
  5. observer.observeLanding(name);
  6. }
  7. }
  8. public void startSpying(LandingObserver observer) {
  9. observers.add(observer);
  10. }
  11. }

我们有两个具体的类实现了LandingObserver接口,分别代表外星人(例8-17)和NASA(例8-18)检测着陆情况。前面提到过,监测到登陆后它们有不同的反应。

例8-17 外星人观察到人类登陆月球

  1. public class Aliens implements LandingObserver {
  2. @Override
  3. public void observeLanding(String name) {
  4. if (name.contains("Apollo")) {
  5. System.out.println("They're distracted, lets invade earth!");
  6. }
  7. }
  8. }

例8-18 NASA也能观察到有人登陆月球

  1. public class Nasa implements LandingObserver {
  2. @Override
  3. public void observeLanding(String name) {
  4. if (name.contains("Apollo")) {
  5. System.out.println("We made it!");
  6. }
  7. }
  8. }

和前面的模式类似,在传统的例子中,用户代码需要有一层模板类,如果使用Lambda表达式,就不用编写这些类了(如例8-19和例8-20所示)。

例8-19 使用类的方式构建用户代码

  1. Moon moon = new Moon();
  2. moon.startSpying(new Nasa());
  3. moon.startSpying(new Aliens());
  4. moon.land("An asteroid");
  5. moon.land("Apollo 11");

例8-20 使用Lambda表达式构建用户代码

  1. Moon moon = new Moon();
  2. moon.startSpying(name -> {
  3. if (name.contains("Apollo"))
  4. System.out.println("We made it!");
  5. });
  6. moon.startSpying(name -> {
  7. if (name.contains("Apollo"))
  8. System.out.println("They're distracted, lets invade earth!");
  9. });
  10. moon.land("An asteroid");
  11. moon.land("Apollo 11");

还有一点值得思考,无论使用观察者模式或策略模式,实现时采用Lambda表达式还是传统的类,取决于策略和观察者代码的复杂度。我这里所举的例子代码很简单,只是一两个方法调用,很适合展示新的语言特性。然而在有些情况下,观察者本身就是一个很复杂的类,这时将很多代码塞进一个方法中会大大降低代码的可读性。

8.1 Lambda表达式改变了设计模式 - 图4从某种角度来说,将大量代码塞进一个方法会让可读性变差是决定如何使用Lambda表达式的黄金法则。之所以不在这里过分强调,是因为这也是编写一般方法时的黄金法则!

8.1.4 模板方法模式

开发软件时一个常见的情况是有一个通用的算法,只是步骤上略有不同。我们希望不同的实现能够遵守通用模式,保证它们使用了同一个算法,也是为了让代码更加易读。一旦你从整体上理解了算法,就能更容易理解其各种实现。

模板方法模式是为这些情况设计的:整体算法的设计是一个抽象类,它有一系列抽象方法,代表算法中可被定制的步骤,同时这个类中包含了一些通用代码。算法的每一个变种由具体的类实现,它们重写了抽象方法,提供了相应的实现。

让我们假想一个情境来搞明白这是怎么回事。假设我们是一家银行,需要对公众、公司和职员放贷。放贷程序大体一致——验明身份、信用记录和收入记录。这些信息来源不一,衡量标准也不一样。你可以查看一个家庭的账单来核对个人身份;公司都在官方机构注册过,比如美国的SEC、英国的Companies House。

我们先使用一个抽象类LoanApplication来控制算法结构,该类包含一些贷款调查结果报告的通用代码。根据不同的申请人,有不同的类:CompanyLoanApplicationPersonalLoanApplicationEmployeeLoanApplication。例8-21展示了LoanApplication类的结构。

例8-21 使用模板方法模式描述申请贷款过程

  1. public abstract class LoanApplication {
  2. public void checkLoanApplication() throws ApplicationDenied {
  3. checkIdentity();
  4. checkCreditHistory();
  5. checkIncomeHistory();
  6. reportFindings();
  7. }
  8. protected abstract void checkIdentity() throws ApplicationDenied;
  9. protected abstract void checkIncomeHistory() throws ApplicationDenied;
  10. protected abstract void checkCreditHistory() throws ApplicationDenied;
  11. private void reportFindings() {

CompanyLoanApplicationcheckIdentity方法在Companies House等注册公司数据库中查找相关信息。checkIncomeHistory方法评估公司的现有利润、损益表和资产负债表。checkCreditHistory方法则查看现有的坏账和未偿债务。

PersonalLoanApplicationcheckIdentity方法通过分析客户提供的纸本结算单,确认客户地址是否真实有效。checkIncomeHistory方法通过检查工资条判断客户是否仍被雇佣。checkCreditHistory方法则会将工作交给外部的信用卡支付提供商。

EmployeeLoanApplication就是没有查阅员工历史功能的PersonalLoanApplication。为了方便起见,我们的银行在雇佣员工时会查阅所有员工的收入记录(例8-22)。

例8-22 员工申请贷款是个人申请的一种特殊情况

  1. public class EmployeeLoanApplication extends PersonalLoanApplication {
  2. @Override
  3. protected void checkIncomeHistory() {
  4. // 这是自己人!
  5. }
  6. }

使用Lambda表达式和方法引用,我们能换个角度思考模板方法模式,实现方式也跟以前不一样。模板方法模式真正要做的是将一组方法调用按一定顺序组织起来。如果用函数接口表示函数,用Lambda表达式或者方法引用实现这些接口,相比使用继承构建算法,就会得到极大的灵活性。让我们看看如何使用这种方式实现LoanApplication算法,请看例8-23!

例8-23 员工申请贷款的例子

  1. public class LoanApplication {
  2. private final Criteria identity;
  3. private final Criteria creditHistory;
  4. private final Criteria incomeHistory;
  5. public LoanApplication(Criteria identity,
  6. Criteria creditHistory,
  7. Criteria incomeHistory) {
  8. this.identity = identity;
  9. this.creditHistory = creditHistory;
  10. this.incomeHistory = incomeHistory;
  11. }
  12. public void checkLoanApplication() throws ApplicationDenied {
  13. identity.check();
  14. creditHistory.check();
  15. incomeHistory.check();
  16. reportFindings();
  17. }
  18. private void reportFindings() {

正如读者所见,这里没有使用一系列的抽象方法,而是多出一些属性:identitycreditHistoryincomeHistory。每一个属性都实现了函数接口Criteria,该接口检查一项标准,如果不达标就抛出一个问题域里的异常。我们也可以选择从check方法返回一个类来表示成功或失败,但是沿用异常更加符合先前的实现(如例8-24所示)。

例8-24 如果申请失败,函数接口Criteria抛出异常

  1. public interface Criteria {
  2. public void check() throws ApplicationDenied;
  3. }

采用这种方式,而不是基于继承的模式的好处是不需要在LoanApplication及其子类中实现算法,分配功能时有了更大的灵活性。比如,我们想让Company类负责所有的检查,那么Company类就会多出一系列方法,如例8-25所示。

例8-25 Company类中的检查方法

  1. public void checkIdentity() throws ApplicationDenied;
  2. public void checkProfitAndLoss() throws ApplicationDenied;
  3. public void checkHistoricalDebt() throws ApplicationDenied;

现在只需为CompanyLoanApplication类传入对应的方法引用,如例8-26所示。

例8-26 CompanyLoanApplication类声明了对应的检查方法

  1. public class CompanyLoanApplication extends LoanApplication {
  2. public CompanyLoanApplication(Company company) {
  3. super(company::checkIdentity,
  4. company::checkHistoricalDebt,
  5. company::checkProfitAndLoss);
  6. }
  7. }

将行为分配给Company类的原因是各个国家之间确认公司信息的方式不同。在英国,Companies House规范了注册公司信息的地址,但在美国,各个州的政策是不一样的。

使用函数接口实现检查方法并没有排除继承的方式。我们可以显式地在这些类中使用Lambda表达式或者方法引用。

我们也不需要强制EmployeeLoanApplication继承PersonalLoanApplication来达到复用,可以对同一个方法传递引用。它们之间是否天然存在继承关系取决于员工的借贷是否是普通人借贷这种特殊情况,或者是另外一种不同类型的借贷。因此,使用这种方式能让我们更加紧密地为问题建模。