设计模式和函数式编程——策略模式

半年没有学习设计模式了,这半年以来主要做的框架开发工作,也算是有一些实践经验(虽然远远不够),同时也是了解了很多函数式编程的概念,写的代码里状态越来越少,代码风格越来越声明式(好久没写过原生的 for 和 while 了 hhh),也开始觉得一些设计模式变得臃肿起来了。现在继续回来学习设计模式,顺便看看它们结合函数式编程中的概念会对样板代码有如何的简化。

今年第一篇笔记。

这里我选择了策略模式进行学习,之后是命令模式和状态模式,选择其的原因是因为这三个模式经由借助 Java8 所提供的函数式的工具——函数式接口和闭包——能很大程度地简化。

函数式接口和闭包

函数式接口结合 Lambda 表达式,使我们能够书写作为值/字面量的函数。这样,将函数作为值来看待,作为入参传入,作为返回值等使用方法都变得明显和符合直觉了。曾经我们只能够传递“名词”给函数,而现在我们能传递“动词”了。

而闭包使我们能够捕获外部作用域的变量,从而构造一个“穷人的类”——就如同类是数据和行为的一个聚合一样,闭包函数也是这样一个聚合,只不过行为只有一种罢了,比如说下面两个 Counter 完全可以认为是等价的——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
IntSupplier counter() {
// Java 限制捕获的值必须是不变的,或者说“实际上 final”的
// 因此如果要想改变捕获的变量的值的话,必须让变量作为引用类型,而值作为该类型包含的状态
// 最佳实践是使用长度为 1 的数组,将状态包裹在数组中,对其进行修改不会改变 count 的值(即该引用)
// 但倘若类型中有泛型,创建其的数组是无法实现的,可以考虑将变量捕获为特定类的状态
int[] count = {0};
return () -> {
return count[0]++;
}
}

class Counter {
private int count = 0;
public int getAndIncrement() {
return count++;
}
}

再考虑另一个情形——我们试图用多线程执行某个 task,而这个 task 有一个依赖的对象。如果在上古时代的 Java,我们必须创建一个实现 Runnable 的类来包含相应依赖,比如需要这么写——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SomeTask implements Runnable {
private final SomeDependency someDependency;

public SomeTask(SomeDependency someDependency) {
this.someDependency = someDependency;
}

@Override
public void run() {
// ...
someDependency.call();
// ...
}
}

// 客户端代码
public static void main(String[] args) {
SomeDependency someDependency = new SomeDependency();
new Thread(new SomeTask(someDependency)).start();
}

这里故意没有使用匿名实现类,但其实使用匿名实现类的话也需要用到闭包。

需要多加一个类!如果这个类只用一次的话,那也太麻烦了!而在 Java 8 里,我们可以这么写——

1
2
3
4
5
6
7
8
public static void main(String[] args) {
SomeDependency someDependency = new SomeDependency();
new Thread(()->{
// ...
someDependency.call(); // 通过闭包直接捕获 someDependency,优雅极了,而且也挺符合直觉
// ...
}).start();
}

如果我们有多个 task 都会利用这个依赖呢?旧时代的 Java 就只能对每个 task 都创建一个类了。我们可以用方法引用(本质上也是匿名函数)来解决这个问题——建立一个类来包含依赖的对象,把每个 task 作为一个方法并在方法中使用该依赖,如 taskA,taskB。

或者也可以通过函数参数来注入依赖,如 taskC,使用时使用匿名函数对该 task 进行调用,注入依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class TaskContainer {
private final SomeDependency someDependency;

private TaskContainer(SomeDependency someDependency) {
this.someDependency = someDependency;
}
public static TaskContainer inject(SomeDependency someDependency) {
return new TaskContainer(someDependency);
}

public void taskA() {
someDependency.call();
}
public void taskB() {
someDependency.call();
}

public static void taskC(SomeDependency someDependency) {
someDependency.call();
}
}

public static void main(String[] args) {
SomeDependency someDependency = new SomeDependency();
new Thread(() -> TaskContainer.inject(someDependency).taskA()).start();
new Thread(TaskContainer.inject(someDependency)::taskB).start();
new Thread(() -> TaskContainer.taskC(someDependency)).start();
}

容易发现,这样的代码虽然更加简短,但是却是破坏了开闭原则的——各种 Task 都定义在这个类里,如果要增加新的 Task,则必须要修改源代码。但这在实践上真的会造成影响吗?应当具体问题具体分析。

也需要知道的是,虽然 Java 8 为函数式编程做了一些努力,但它的完善程度仍旧是远远不够的,它仍旧试图把函数都当作特定类型的对象来对待,这样即使两个函数的函数签名相同,它们两个也未必是能够互换使用的。这在异常处理,函数组合等地方都带来了很大的麻烦,而 Scala 或 Kotlin 在这方面做的很好——首先函数的签名就是函数的类型,这在很多时候甚至比被迫的命名还要清晰——Int -> Int可比IntUnaryOperator要好理解的多了;而且函数仍被当作类来看待,因此也具有自己的方法——和其他的函数进行组合等。这是 Java 做不到的(Java 提供的函数式接口似乎普遍包含了一个组合方法andThenFunction接口包含了compose,但是并不统一),即使能做到也缺乏泛用性。而试图进行柯里化等操作时,得到的函数签名更是不忍直视,比如Int -> Int -> Int -> Int会得到Function<Integer, Function<Integer, Function<Integer, Integer>>>,并且调用时也得fn.apply(1).apply(2).apply(3),实在难以使用。

策略模式

定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式使得算法可独立于使用它的客户而变化。

策略模式就本质上说来,就是将为特定目的/接口的算法封装在同样接口的类中,使我们能够方便添加新的算法,以及更换/切换使用的算法。策略模式无论是从面向对象的角度还是从函数式的角度都是非常容易理解的。一个简单的例子就是,JDBC 如何适配多个数据库?答案是提供同样的接口,为每个数据库都对该接口进行实现,在运行时选择使用的数据库的对应的实现类。

考虑这样的一个简单情景——我们在维护一个电影院的售票系统,现在我们要根据用户的年龄和类型来计算票价(假设票价只和用户的年龄和类型相关,和电影等其它属性无关)。根据票价的规则,我们快速整出来一个原型——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TicketService {
// ...
// 票价算法
int getPrice(int age, int type) {
int base = 50;
switch (type) {
case 0:
if (age < 18) return base - 10;
return base;
case 1:
if (age < 18) return (int) (base * 0.9 - 10);
return (int) (base * 0.9);
case 2:
return base / 2;
default:
throw new RuntimeException("错误的 type"); // 该函数可能抛出异常,这说明它是一个偏函数。我们可以通过函数式的手段解决它
}
}

// 客户端代码
void buyTicket(User user, Movie movie) {
int price = getPrice(user.getAge(), user.getType());
System.out.printf("用户%s 的票价为:%s\n", user.getName(), price);
// do some bussiness......
}
}

We did it!代码很简单,可是它真能“不忘初心”吗?non non 哒哟!基础价变了怎么办?成年人的定义变了怎么办?遇上节日了不打折吗?会员的折扣变了怎么办?显然,这代码可抵抗不了业务的变化,它必须得动。

介绍和示例

能够发现,当我们进行对计算规则进行改变的时候,我们实际上只需要改变 getPrice 方法中的内容,而 buyTicket 方法,以及 getPrice 的签名(接口)都是不变的。答案很显然了,我们把 getPrice 抽象成接口,而让具体的算法去实现这样的接口就行了,而 buyTicket 则利用该接口对具体算法进行使用。这样的接口就称为策略(Strategy)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
interface PriceStrategy {
int getPrice(int age, int type);
}

class StrategyA implements PriceStrategy {
@Override
int getPrice(int age, int type) {
// ...
}
}
class StrategyB implements PriceStrategy {
@Override
int getPrice(int age, int type) {
return 100; // 很平等(
}
}

class TicketService {
private PriceStrategy priceStrategy;

// 非 Spring 环境下当然也可以进行依赖注入
@Autowired
public void setPriceStrategy(PriceStrategy priceStrategy) {
this.priceStrategy = priceStrategy;
}

// 客户端代码
void buyTicket(User user, Movie movie) {
int price = priceStrategy.getPrice(user.getAge(), user.getType());
System.out.printf("用户%s 的票价为:%s\n", user.getName(), price);
// do some bussiness......
}
}

这样,当业务有调整的时候,只需要编写新的策略,并通过配置文件等形式修改注入的具体策略即可,甚至能够在运行时对使用的策略进行修改。我们还可以让策略之间互相依赖,比如对另外一个策略的票价再打个折之类的。

总而言之(真快啊!),策略模式中出现三种角色——上下文(context),使用策略的地方,即客户端;抽象策略,或者说策略的接口,其将被上下文所使用;具体策略,顾名思义。

FP 的观点

从函数式编程的角度来看,这一个个的具体的策略类实际上是一个个具有同样的接口/签名的函数,它们被命名,被保存了。于是,我们可以通过函数来表示具体策略,让函数的签名来代替抽象策略,从而消灭抽象策略这一角色,在上下文中直接使用符合该函数签名的函数作为具体策略。比如,这里的 getPrice 函数,它的签名为(int, int) -> int,或者从 Java 的话说,BiFunction<Integer, Integer, Integer>Function<Tuple2<Integer, Integer>, Integer>,抽象策略的类就可直接使用该函数类来代替,而具体策略只需要实现该接口的示例。比如我们可以这么写——

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

// 应当通过依赖注入的方式传入,这样也能在函数体中通过闭包来引入依赖。
private BiFunction<Integer, Integer, Integer> getPrice = (age, type) -> {
// ...
return 42;
};

// 客户端代码
void buyTicket(User user, Movie movie) {
int price = priceStrategy.apply(user.getAge(), user.getType());
System.out.printf("用户%s 的票价为:%s\n", user.getName(), price);
// do some bussiness......
}
}

适用场景

对同一个接口,需要能够切换多个实现的情况下使用策略模式非常适合,比如前面说到的票价计算场景,以及选择使用介质的缓存(本地,内存,磁盘,网络),统一不同的文件系统等。需注意的是如果策略包含多个方法,则还是使用面向对象的手段更方便些。

进一步

我们可以通过函数组合的方法对不同策略进行组合,达到复用代码,或者对结果进行“代理”的目的。比如我们可以让策略的返回值来乘以一个数来模拟打折情况——

1
2
3
4
5
6
7
BiFunction<Integer, Integer, Integer> beforeStrategy = (age, type) -> {
return 42;
};
BiFunction<Integer, Integer, Integer> afterStrategy0 = (age, type) -> {
return (int) (beforeStrategy.apply(age, type) * 0.9);
};
BiFunction<Integer, Integer, Integer> afterStrategy1 = beforeStrategy.andThen(price -> (int) (price * 0.9));

我们也可以通过流式接口等形式来创建策略的工厂类,通过 DSL 来描述业务,最终创建出最后的服务,比如可能可以这样——

1
2
3
4
5
6
7
8
// 随便写写
BiFunction<Integer, Integer, Integer> someStrategy =
PriceStrategyFactory
.from((age, type) -> 42)
.precondition((age, type) -> age > 0 && 0 <= type && type <= 3)
.andThen(price -> price + 100)
.onError(Exception::printStackTrace)
.build();

甚至利用动态语言等的特性,嵌入个 lua 虚拟机整整活?这里还会有无数的骚操作,但是我想不出来了,告辞!

关于函数组合

什么是函数组合?我们知道,数学上的函数是两个集合之间的映射,如 f(x) = x + 1 (x ∈ R)为一个实数集到实数集的映射。而类型可以认为是一个数据的集合,如 int 型代表……-1, 0, 1, ……的集合,char 型代表’a’, ‘b’, ……的集合。计算机中的函数因此也可表示从集合到集合的映射,如上面的 getPrice 函数,其可以表达为(int, int) -> int,即一个(int, int)——int 型和 int 型的笛卡尔积——的集合到 int 型的映射。

如果我们有一个函数f : A -> B,其中 A 为输入值的类型,B 为输出值的类型,又有一个函数g : B -> C,这样我们就可以组合这两个函数,得到函数g . f : A -> C。其中(g . f)(x) = g(f(x))

扯这些有啥用呢?我们可以通过函数签名来对函数进行组合,从而把简单的函数组装成复杂的函数,以更声明式的手段表达业务逻辑。比如在 Java 中,我们可以写Stream.of(1, 2, 3).filter(i -> i % 2 == 1).map(i -> i * 3).reduce(0, Integer::sum),而在函数式语言中,我们会写sum . map (* 3) . filter (\i -> div i 2 == 1)。一个 js 的例子如下——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 首先定义 filter,map 的柯里化形式——
// filter : (Int -> Bool) -> [Int] -> [Int]
const filter = algo => lst => lst.filter(algo)
// map : (Int -> Int) -> [Int] -> [Int]
const map = algo => lst => lst.map(algo)
// sum : [Int] -> Int
const sum = lst => lst.reduce((a, b) => a + b, 0)

// compose : (B -> C) -> (A -> B) -> (A -> C)
const compose = b2c => a2b => {
// A -> C,x 类型是 A
return x => {
return b2c(a2b(x))
}
}

// 考虑到 compose 这个函数太难看了(看起来像 Lisp),这里也给定了 haskell 形式的描述
// someAlgo : [Int] -> Int = sum . map (* 3) . filter (\i -> div i 2 == 1)
const someAlgo = compose (sum) (compose (map (i => i * 3)) (filter (i => i % 2 == 1)))

在 Java 中,我们对数据进行链式的处理,而在函数式编程中,我们通过组合小的,易理解、处理、证明的函数来构造最终的复杂函数并将其应用到数据上。显然后者是形式更加清晰(至少在 Haskell 里是这样的……),测试更为容易,更加容易进行复用的。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!