《Java 8 函数式编程》笔记7

设计和架构的原则

命令者模式

命令者是一个对象,它封装了调用另一个方法的所有细节,命令者模式使用该对象,可以编写出根据运行期条件,顺序调用方法的一般化代码。

命令者模式中有四个类参与其中:

命令接收者
执行实际任务

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

发起者
控制一个或多个命令的顺序和执行

客户端
创建具体的命令者实例

1
2
3
4
5
6
7
8
9
10
11
[发起者]  ->  [命令者]

↑ ↑
|创建 |实现

[客户端] -> [具体命令者]

调用|


[命令接收者]

举个栗子:
假设有个 GUI Editor 组件,可以执行 opensave 等一系列操作。
现在我们像实现宏功能——就是把一系列操作录下来,日后作为一个操作执行,这就是命令的接受者。

文本编辑器可能有的一般功能:

1
2
3
4
5
6
7
public interface Editor {
void save();

void open();

void close();
}

opensave 这样的操作称为命令,我们需要一个统一的接口来概括这些不同的操作。

通过 Action 接口,所有操作均可实现:

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

现在让每个操作都实现该接口:

1
2
3
4
5
6
7
8
9
10
11
12
public class Save implements Action {
private final Editor editor;

Save(Editor editor) {
this.editor = editor;
}

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

Open(Editor editor) {
this.editor = editor;
}

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

Close(Editor editor) {
this.editor = editor;
}

@Override
public void perform() {
editor.close();
}
}

实现一个宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Macro {
// 一系列操作
private final List<Action> actions;

Macro() {
actions = new ArrayList<>();
}

// 记录操作
void record(Action action) {
actions.add(action);
}

// 运行一系列动作
void run() {
actions.forEach(Action::perform);
}
}

别忘了实现一个具体的文本编辑器 EditorImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class EditorImpl implements Editor {
@Override
public void save() {
System.out.println("success save");
}

@Override
public void open() {
System.out.println("success open");
}

@Override
public void close() {
System.out.println("success close");
}
}

现在就可以通过录制这些操作做一个宏,来方便自己的工作了:

命令者模式构建宏:

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

lambda 表达式构建宏:

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

方法引用构建宏:

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

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

策略模式

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

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

首先,为策略定义 API CompressionStrategy,每种文件压缩算法都要实现该接口。
该接口有一个 compress 方法,接受并返回一个压缩后 OutputStream 对象。

1
2
3
4
5
压缩器 -调用-> 压缩策略
↗ ↖
实现/ \实现
/ \
zip压缩 gzip压缩

定义压缩数据的策略接口:

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

使用 gzip 算法压缩数据:

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

使用 zip 算法压缩数据:

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

压缩器 Compressor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Compressor {
private final CompressionStrategy strategy;

// 构造时使用用户提供的压缩策略
public Compressor(CompressionStrategy strategy) {
this.strategy = strategy;
}

// 读入文件,根据策略压缩文件
public void compress(Path inFile, File outFile) throws IOException {
try (OutputStream outputStream = new FileOutputStream(outFile)) {
Files.copy(inFile, strategy.compress(outputStream));
}
}
}

到此就可以开始使用我们的压缩器来压缩文件了:

使用具体策略类初始化 Compressor

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

Compressor zipCompressor = new Compressor(new ZipCompressionStrategy());
zipCompressor.compress(inFile, outFile);

使用方法引用初始化 Compressor

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

Compressor zipCompressor = new Compressor(ZipOutputStream::new);
zipCompressor.compress(inFile, outFile);

观察者模式

观察者模式是另一种可被 lambda 表达式简化和改进的行为模式。
在观察者模式中,被观察者持有一个观察者列表。当被观察者的状态发生改变,会通知观察者。

观察者模式被大量应用于基于 MVCGUI 工具中,以此让模型状态发生变化时,自动刷新视图模块,达到二者之间的解耦。

举个栗子:

NASA 和外星人都对登陆到月球上的东西感兴趣,都希望可以记录这些信息。
NASA 希望确保阿波罗号上的航天员成功登月;外星人则希望在 NASA 注意力分散时进攻地球。

这里他们的观察对象就是登陆到月球的东西。

首先,定义观察者的 API LandingObserver,它只有 observeLanding 方法,当有东西登陆到月球上时会调用该方法:

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

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

1
2
3
4
5
6
7
8
9
10
11
public class Moon {
private final List<LandingObserver> observers = new ArrayList<>();

public void land(String name) {
observers.forEach(observer -> observer.observerLanding(name));
}

public void startSpying(LandingObserver observer) {
observers.add(observer);
}
}

外星人观察到阿波罗号登陆月球,就开始发出进攻地球的信号:

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

NASA 观察到阿波罗号登陆到月球,会很兴奋:

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

传统方式,就是使用以上写好的模版类 AliensNasa 来调用:

1
2
3
4
5
6
Moon moon = new Moon();
moon.startSpying(new Nasa());
moon.startSpying(new Aliens());

moon.land("An asteroid");
moon.land("Apollo 11");

但使用 lambda 表达式的话,就不用写以上的模版类了:

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

moon.land("An asteroid");
moon.land("Apollo 11");

注意:
无论是使用观察者还是策略模式,实现时采用 lambda 表达式,还是传统的类,取决于观察者和策略代码的复杂度。
这里举的例子很简单,所以更能展示新的语言特性。

使用 lambda 表达式的 SOLID 原则

SOLID 原则是涉及面向对象程序是的一些基本原则,分别是:

  • Single responsibility
  • Open/closed
  • Liskov substitution
  • Interface segregation
  • Dependency inversion

这里主要关注如何 lambda 表达式的环境下应用其中的三条原则。

单一功能原则

程序中的类或方法只能有一个改变的理由。

当软件的需求发生变化,实现这些功能的类和方法也需要变化。
如果你的类有多个功能,一个功能引起的代码变化会影响该类其他功能。这可能会引入缺陷,还会影响代码演进的能力。

举个栗子:
有一个程序,可以由资产列表生成 BalanceSheet 表格,然后输出一份 PDF 格式的报告。
如果实现时将制表和输出功能都放进同一个类,那么该类就有两个变化的理由。
你可能想改变输出功能,输出不同的格式,比如 HTML,可能还想改变 BalanceSheet 的细节。
这将问题分解成两个类提供了很好的理由:一个负责将 BalanceSheet 生成表格,一个负责输出。

单一功能原则不止于此:一个类不仅要功能单一,而且还需要将功能封装好。
以上面的例子就是:如果我想改变输出格式,那么只需要改变负责输出的类,而不必关心负责制表的类。

这是强内聚性设计的一部分。说一个类是内聚的,是指它的方法和属性需要统一对待,因为它们紧密相关。
如果你试着将一个内聚的类拆分,可能会得到刚才创建的那两个类。

那么问题来了,这和 lambda 表达式有什么关系?

lambda 表达式在方法级别能更容易实现单一功能原则。

举个栗子:

计算质数个数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public long countPrimes(int upTo) {
long total = 0;
for (int i = 1; i < upTo; i++) {
boolean isPrime = true;
for (int j = 2; j < i; j++) {
if (i % j == 0) {
isPrime = false;
}
}
if (isPrime) {
total++;
}
}
return total;
}

显然,上面的方法塞了两个职责:判断一个数是否是质数、计数。

拆分这两个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public long countPrimes(int upTo) {
long total = 0;
for (int i = 1; i < upTo; i++) {
if (isPrime(i)) {
total++;
}
}
return total;
}

public boolean isPrime(int num) {
for (int i = 2; i < num; i++) {
if (num % i == 0) {
return false;
}
}
return true;
}

既然遵守单一功能原则,那么我们可以对迭代过程封装:

1
2
3
4
5
6
7
8
9
10
public long countPrimes(int upTo) {
return IntStream.range(1, upTo)
.filter(this::isPrime)
.count();
}

public boolean isPrime(int num) {
return IntStream.range(2, num)
.allMatch(x -> (num % x) != 0);
}

如果我们想利用多核加速计数,可以使用 parallel 方法,而不用修改任何其他代码:

1
2
3
4
5
6
public long countPrimes(int upTo) {
return IntStream.range(1, upTo)
.parallel()
.filter(this::isPrime)
.count();
}

开闭原则

软件应该对扩展开放,对修改闭合。

开闭原则的首要目标和单一功能原则类似:让软件易于修改。

一个新增功能或一处改动,会影响整个代码,容易引入新的缺陷。

开闭原则保证已有的类在不修改内部实现的基础上可扩展,这样就努力避免了上述问题。

举个栗子:

现在我们有个描述计算机花在用户空间、内核空间和输入输出上的时间散点图 MetricDataGraph 接口:

1
2
3
4
5
6
7
public interface MetricDataGraph {
void updateUserTime(int value);

void updateSystemTime(int value);

void updateIOTime(int value);
}

但这个接口有点问题:对扩展不友好。因为要想添加新的时间点,比如 XXTime,就要修改这个接口,添加对应的 updateXXTime 方法。

如何解决扩展问题呢?一般是通过引入抽象解决。

使用新的类 TimeSeries 来表示各种时间点,这样 MetricDataGraph 接口也得以简化,不必依赖某项具体指标。

1
2
3
public interface MetricDataGraph {
void updateTimeSeries(TimeSeries time);
}
1
2
3
public interface TimeSeries {
int getValue();
}

每项具体指标都实现 TimeSeries 接口,在需要时能直接插入:

1
2
3
4
5
6
7
8
public class UserTime implements TimeSeries {
private int value;

@Override
public int getValue() {
return this.value;
}
}

现在,要添加新的时间点,比如,“被浪费的CPU时间”:

1
2
3
4
5
6
7
8
public class WasteTime implements TimeSeries {
private int value;

@Override
public int getValue() {
return this.value;
}
}

高阶函数也展示了同样的特性:对扩展开放,对修改闭合。

ThreadLocal 类有一个特殊变量,每个线程都有一个该变量的副本与之交互。该类的静态方法 withInitial 是一个高阶函数,传入一个负责生成初始值的 lambda 表达式。即不用修改 ThreadLocal 类就能获得新的行为,所以符合开闭原则。

withInitial 方法传入不同的工厂方法,就能得到有着不同行为的 ThreadLocal 实例。

比如,使用 ThreadLocal 生成一个 DateFormatter 实例,该实例是线程安全的:

1
2
3
4
5
6
// 实现
ThreadLocal<DateFormat> localFormatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat());

// 使用
DateFormat formatter = localFormatter.get();

或者为每个 Java 线程创建唯一,有序的标识符:

1
2
3
4
5
6
AtomicInteger threadId = new AtomicInteger();

ThreadLocal<Integer> localId =
ThreadLocal.withInitial(() -> threadId.getAndIncrement());

int idForeThisThread = localId.get();

依赖反转原则

抽象不应依赖细节,细节应该依赖抽象。

该原则的目的:让程序猿脱离底层粘合代码,编写上层业务逻辑代码。这就让上层代码依赖于底层细节的抽象,从而可以重用上层代码。
这种模块化和重用方式是双向的:既可以替换不同的细节重用上层代码,也可以替换不同的业务逻辑重用细节的实现。

以下代码是从一种标记语言中提取标题,其中标题以冒号(:)结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public List<String> findHeadings(Reader input) {
// 读取文件
try (BufferedReader reader = new BufferedReader(input)) {
// 逐行检查
return reader.lines()
// 滤出标题
.filter(line -> line.endsWith(":"))
.map(line -> line.substring(0, line.length() - 1))
.collect(toList());
} catch (IOException e) {
// 将和读写文件有关的异常封装成待解决的异常
throw new HeadingLookupException(e);
}
}

这段代码,将提取标题,资源管理,文件处理都混在了一起。我们真正想要的是编写提取标题的代码,而将操作文件相关细节交给另一个方法。

剥离文件处理功能后的业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public List<String> findHeadings2(Reader input) {
return withLinesOf(input,
lines -> lines.filter(line -> line.endsWith(":"))
.map(line -> line.substring(0, line.length() - 1))
.collect(toList()),
HeadingLookupException::new);
}

// Stream 对象更安全,而且不容易被滥用
// 使用 Stream<String> 做抽象,让代码依赖它,而不是文件
private <T> T withLinesOf(Reader input,
Function<Stream<String>, T> handler,
Function<IOException, RuntimeException> error) {
try (BufferedReader reader = new BufferedReader(input)) {
return handler.apply(reader.lines());
} catch (IOException e) {
throw error.apply(e);
}
}
Author

Zoctan

Posted on

2018-03-05

Updated on

2023-03-14

Licensed under