什么是策略模式
一提到策略模式,我们都不陌生,因为它的名声太过响亮。只要一提到 if-else 优化,大家总会脱口而出——策略模式。那么,策略模式又是如何优化 if-else 的呢?本篇将通过一个例子,来对策略模式是如何解决实际问题以及使用策略模式后的效果进行阐述。
演化历程
自 2019 年 12 月开始,新冠肺炎疫情到目前为止已经在全球肆虐了整整 2 年半的时间了。新冠肺炎疫情给我们在工作和生活的各个方面都带来了巨大的影响,尤其是出行方面。为此,我的旅游计划一再搁置,也不知道啥时候才能付诸实现(╥﹏╥)。在这样的紧张形势下,我大四川悄然出台了疫情防控政策措施,这个措施通俗成为A/B类防控。我对这种政策类的规定经常不大看得明白,反正是举例,这里索性就把这个防控政策重新改一改,让其更加简单一些。 对于离开出发地的旅客,假定分为以下两种类型:
- 宽松放行政策:持健康码绿码;
- 严苛放行政策:需持48小时核酸检测阴性证明和健康码绿码。
对于进入四川省的旅客,假定四川省的防疫政策如下:
- 自高风险区来:实施集中隔离直至抵川后满7天,第1、3、7天进行咽拭子核酸检测,最后1次双采双检阴性后解除隔离;解除集中隔离后,实施7天居家健康监测;
- 自中风险区来:实施居家隔离直至抵川后满7天,第1、3、7天进行咽拭子核酸检测,最后1次双采双检阴性后解除隔离;
- 自低风险区来:实施3天2次(间隔24小时)咽拭子核酸检测。
枚举所有可能
从上面的需求来看,出行者既需要知道当地的放行政策,也需要知道目的地的防疫政策,而出发地和目的地都仅有一个(这里就不考虑中转停留的情况了)。我们可以采取穷尽枚举的方式来定义每一种出发地和目的地的组合,组合后将得到 宽松放行-自高风险区入、严苛放行-自高风险区入……等等类,每个类不仅实现了出发地放行政策接口,还实现了目的地防疫政策接口。
但很快我们就意识到这种实现的缺点:如果两方中的任何一方新增一种政策,就必须引入 N 个类来进行表示。这样看来,穷尽枚举的方式实现上述需求是不合理的,因为其带来了 类爆炸 的问题。
组合优于继承
如果你对装饰器(Decorator)模式还有印象,或许你已经想到了如何解决这个问题。在装饰器(Decorator)模式的介绍中,我们曾遇到过类爆炸的问题,解决方法就是:组合优于继承。
组合优于继承的核心思想就是在运行时决定各个参与部分的实现,将对象的表示从编译期推迟到运行期。
说了这么多,那么具体该如何实现?见下图:
我们将每一种出发地出行政策及目的地防疫政策单独定义出来,让其各自变化,在运行过程中通过条件组合的方式来表示 出发地不同的放行政策 和 目的地不同的防疫政策 。
这样做的好处显而易见,当我产生一种新的防疫政策或者出行政策时,只需要添加一种对应的实现,并且在 travel 方法中定义新的条件分支即可。相比于继承实现,类的数量大大减少。
尽管如此,这份代码在日积月累的修改后,将变得很难维护。travel() 方法中的条件分支将随着各种政策的增多变得极其臃肿,且一旦有一个政策发生变化(比如自高风险区入的政策发生调整)受影响的将是整个条件分支逻辑的代码。
此时,我们迫切需要将条件分支中的逻辑进行解耦,因为单个类中的行为过多,导致出现了大量的条件分支,这让整个代码变得难以维护。
优化条件分支
如果你已经看过了 工厂方法(Factory Method)模式,那么你或许对条件分支的解耦有点印象。在 工厂方法模式 中,我们通过将 产品的集中创建(产品的静态创建方法)模式 改为 为每个产品配置一个工厂 的方式来进行解耦。那么在此处,我们一样可以用这种思路来优化。
事实上,不管是产品的静态创建方法,还是这里的 travel() 方法,出现大量条件语句的结果是因为类中存在过多行为导致的(换个更直白的方式来表达:出现大量 if-else 结构的代码是因为在一个方法中干了太多事情)。
分析 travel() 方法,我们发现每一个 if 分支的代码内部的结构都是如此的相似,区别仅仅是参数不同而导致产生不同的对象,仅此而已。那么,我们只需要将方法参数变更为具体的政策对象,travel() 方法中只负责调用政策对象的方法,耦合性也就解开了。考虑到对象的复用性,我们把 travel() 方法的参数放到类属性中,这样在调用 travel() 方法时就不必再构造政策对象了。
经过一系列的推导,我们最终得到如下的类图结构:
到了这里,事实上我们就已经接触到策略模式了,上图中结构正是策略模式的具化表示。接下来看一下上图中的结构所对应的代码。
代码实现
政策接口
public interface DepartureStrategy {
/**
* 离开出发地的出行要求
*/
void requirementsOfDeparture();
}
public interface EpidemicPreventionOfDestStrategy {
/**
* 疫情防控政策
*/
void prevent();
}
具体的政策
public class EasyLeaveStrategy implements DepartureStrategy {
@Override
public void requirementsOfDeparture() {
System.out.println(" 离开宽松放行出发地的出行要求:");
System.out.println(" 持健康码绿码。");
}
}
public class StrictLeaveStrategy implements DepartureStrategy {
@Override
public void requirementsOfDeparture() {
System.out.println(" 离开严格放行出发地的出行要求:");
System.out.println(" 需持48小时核酸检测阴性证明和健康码绿码。");
}
}
public class FromLowRiskStrategy implements EpidemicPreventionOfDestStrategy {
@Override
public void prevent() {
System.out.println(" 来自低风险区的防疫措施:");
System.out.println(" 实施3天2次(间隔24小时)咽拭子核酸检测。");
}
}
public class FromMediumRiskStrategy implements EpidemicPreventionOfDestStrategy {
@Override
public void prevent() {
System.out.println(" 来自中风险区的防疫措施:");
System.out.println(" 实施居家隔离直至抵川后满7天,第1、3、7天进行咽拭子核酸检测,最后1次双采双检阴性后解除隔离。");
}
}
public class FromHighRiskStrategy implements EpidemicPreventionOfDestStrategy {
@Override
public void prevent() {
System.out.println(" 来自高风险区的防疫措施:");
System.out.println(" 实施集中隔离直至抵川后满7天,第1、3、7天进行咽拭子核酸检测,最后1次双采双检阴性后解除隔离;");
System.out.println(" 解除集中隔离后,实施7天居家健康监测。");
}
}
统一的对外交互
public class Travel {
private final DepartureStrategy departureStrategy;
private final EpidemicPreventionOfDestStrategy preventionOfDestStrategy;
public Travel(DepartureStrategy departureStrategy,
EpidemicPreventionOfDestStrategy preventionOfDestStrategy) {
this.departureStrategy = departureStrategy;
this.preventionOfDestStrategy = preventionOfDestStrategy;
}
/**
* 离开出发地
*/
public void leaveDeparture() {
this.departureStrategy.requirementsOfDeparture();
}
/**
* 进入目的地
*/
public void enterDestination() {
this.preventionOfDestStrategy.prevent();
}
}
客户端
public class Client {
public static void main(String[] args) {
// 假设:当前上海有中风险区,采用宽松出行政策,Jack 从上海到成都
System.out.println("|==> Jack 从【上海 -> 成都】----------------------------------------------------------|");
Travel travelForJack = new Travel(new EasyLeaveStrategy(), new FromMediumRiskStrategy());
travelForJack.leaveDeparture();
travelForJack.enterDestination();
// 假设:当前天津为低风险区,采用严格限制出行政策,Tom 从天津到成都
System.out.println("|==> Tom 从【天津 -> 成都】-----------------------------------------------------------|");
Travel travelForTom = new Travel(new StrictLeaveStrategy(), new FromLowRiskStrategy());
travelForTom.leaveDeparture();
travelForTom.enterDestination();
}
}
|==> Jack 从【上海 -> 成都】----------------------------------------------------------|
离开宽松放行出发地的出行要求:
持健康码绿码。
来自中风险区的防疫措施:
实施居家隔离直至抵川后满7天,第1、3、7天进行咽拭子核酸检测,最后1次双采双检阴性后解除隔离。
|==> Tom 从【天津 -> 成都】-----------------------------------------------------------|
离开严格放行出发地的出行要求:
需持48小时核酸检测阴性证明和健康码绿码。
来自低风险区的防疫措施:
实施3天2次(间隔24小时)咽拭子核酸检测。
认识策略模式
尽管我们已经通过上面的例子一步一步的得到了策略模式的类图结构并且已经展示了实现代码,但对策略模式所强调的重点和所适用的场景并清楚。接下来,我们再加深一些对策略模式的认识。
策略模式的定义
先看一下策略模式的定义:
定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式使得算法可独立于使用它的客户而变化。
如果没有上面的例子,直接来理解这个定义是极其困难的,但现在我们有了例子的基础,回过头再看分析定义就会容易很多。
- 定义一系列算法,把它们一个个封装起来:算法就相当于上图中的 FromLowRiskStrategy.prevent() 方法、StrictLeaveStrategy.requirementsOfDeparture() 方法等内部的逻辑,一个一个封装起来指的是一个把原本在一个类中的多个算法逻辑拆分到不同的类中。简而言之就是将不同的算法逻辑拆分到不同的类中进行实现;
- 并且使它们可相互替换:同一种类型的算法之间可相互替换,比如 FromHighRiskStrategy 和 FromLowRiskStrategy 二者可以互相替换,这里主要是说同一种类型的算法要实现(继承)自同一个接口(抽象类);
- 使得算法可独立于使用它的客户而变化:这句描述的是,算法逻辑的变化不会造成客户端代码的改变,比如改变 FromLowRiskStrategy 类中的 prevent() 方法代码(意味着算法的逻辑发生了变化),而我们并不需要修改 Client 中的代码。强调这一点是相较于传统的 if-else 代码块中包含了算法逻辑的情况来说的。
类图分析
策略只有一种类型时,策略模式的类图如上所示。在类图中的只要有三个角色,分别是:
- Context:上下文,维护了一个 Strategy 对象的引用,在 contextInterface() 方法中使用这个对象的 algorithmInterface() 方法;
- Stategy:定义所有支持的算法的公共接口;
-
策略模式的特点
(1)为什么客户端需要与 context 直接交互?
在策略模式中,Client 的请求是通过 Context 进行转发的,客户端的请求最终都被转发到具体策略类中。这里加一层 context 的好处主要有两个。- 对外提供统一入口:对客户端提供统一的入口,可以避免客户端与具体的策略类直接进行交互。这样一来,客户端就和策略类隔离开来了,如果某天策略类的结构发生变化(例如,algorithmInterface() 方法改了名称),只需要变动 Context 内部即可,对客户端来说,依旧调用 contextInterface() 方法。
- 封装可能存在的变化:例如现在需要在执行 algorithmInterface() 方法之前记录日志,如果没有 Context ,那么就只能在每一个调用的地方写日志打印的代码,而现在只需要在 contextInterface() 方法中实现即可。
(2)客户端必须了解不同的策略
策略模式有一个潜在的缺点:客户必须对所有的策略类都相当了解,并能根据实际的需求选择一个合适的策略类作为 Context 的上下文。
其实只要仔细思考一下就不难发现,这个问题是由于把锅甩给客户端造成的。 还记得上面我们是怎么优化 if-else 的吗?没错,把原来的 参数 直接替换成 具体的策略类对象 以此来消除不同条件分支的差异。这不就相当于我不知道这里用那种策略合适,那么就交给你吧,既然你要用,你就应该知道。。。 这种甩锅行为确确实实让模式的结构内部耦合更低,但是如果客户端也不知道具体该创建哪种策略类,还是得使用条件分支。这种问题并不是策略模式独有,回想一下,工厂方法模式在这个问题上是不是和这里如出一辙?
区别于其他模式
策略模式 VS 状态模式
策略模式和状态模式的类图结构几乎完全一致,要将他们区分出来很难,想要说清楚他们的差别也不容易。这里不会花费过多的篇幅来说明这两者之间到底有什么不同,毕竟我可能也说不太清楚。
让我们跳出类图结构的框定之外,我们来讨论下这两者想要实现的目的:
策略模式在定义中已经说明了,是将一个个的算法封装为一个个的策略类,这样客户就可以根据自己的需要选择合适的策略类。这些策略类理论上是可以互相替换的,从一点上也可以看出来各个策略之间是相互独立的;状态模式则是封装一个与状态相关的行为,对象会根据不同的状态做出不同的行为,且一个状态会转换到另一个状态,状态转换的过程客户往往是无感知的。 可能这样说还是很抽象,打个不是很恰当的比方来说,策略模式就相当于在描述下班后如何回家这件事,骑单车是一种策略,坐地铁是一种策略,搭公交也是一种策略,甚至不同的公交线路也可以是不同的策略,通过选择一个策略你就可以回家。 而状态模式就相当于在描述你下班后搭乘电梯下楼这件事,电梯的状态分为开门状态、关门状态、运行状态、静止状态,当电梯处于开门状态时,你上了电梯,电梯关门后变成运行状态,电梯到达后变成静止状态,当电梯处于静止时开门,这就是状态的转换,与当前的状态有关,也与下一个状态有关。从这一点上来说,他们的目的是不一样的,要解决的问题也不一样。
策略模式 VS 工厂方法模式
策略模式和工厂方法模式都从一定程度上优化了条件分支,它们都要求组件(策略模式的策略类,工厂方法的工厂类)由客户端指定。工厂方法模式强调的重点是对象的创建,策略模式强调的是算法的封装,所以他们是很容易区分的。
策略模式的应用
(1)在 java.util.Arrays 类中,通过 Comparator
这里的 Comparator 就是抽象的策略,定义了排序的算法,由子类自行实现排序逻辑:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
java.util.Arrays 就是上下文 Context,和标准策略模式中不同的是,在 Arrays 的实现中,并未将 Comparator 作为对象的属性,而是在需要排序的时候以方法参数的形式传入。
public class Arrays {
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
private static <T> void legacyMergeSort(T[] a, Comparator<? super T> c) {
T[] aux = a.clone();
if (c==null)
mergeSort(aux, a, 0, a.length, 0);
else
mergeSort(aux, a, 0, a.length, 0, c);
}
private static void mergeSort(Object[] src,
Object[] dest,
int low, int high, int off,
Comparator c) {
int length = high - low;
// Insertion sort on smallest arrays
if (length < INSERTIONSORT_THRESHOLD) {
for (int i=low; i<high; i++)
for (int j=i; j>low && c.compare(dest[j-1], dest[j])>0; j--)
swap(dest, j, j-1);
return;
}
// Recursively sort halves of dest into src
int destLow = low;
int destHigh = high;
low += off;
high += off;
int mid = (low + high) >>> 1;
mergeSort(dest, src, low, mid, -off, c);
mergeSort(dest, src, mid, high, -off, c);
// If list is already sorted, just copy from src to dest. This is an
// optimization that results in faster sorts for nearly ordered lists.
if (c.compare(src[mid-1], src[mid]) <= 0) {
System.arraycopy(src, low, dest, destLow, length);
return;
}
// Merge sorted halves (now in src) into dest
for(int i = destLow, p = low, q = mid; i < destHigh; i++) {
// 在此处调用了 compare 方法
if (q >= high || p < mid && c.compare(src[p], src[q]) <= 0)
dest[i] = src[p++];
else
dest[i] = src[q++];
}
}
}
(2)在 Mybatis 中,通过 Executor 定义的各种方法,对数据库进行操作
在 Mybatis 中,Executor 扮演的就是抽象的策略,该类中定义了各种对数据库操作的方法,这里以 query() 方法为例:
public interface Executor {
<E> List<E> query(MappedStatement ms,
Object parameter,
RowBounds rowBounds,
ResultHandler resultHandler) throws SQLException;
}
DefaultSqlSession 就是上下文 Context,内部维护了一个 Executor 对象,在对外部暴露的接口中调用了其实现的 query() 方法,查询数据。
public class DefaultSqlSession implements SqlSession {
// Strategy
private Executor executor;
@Deprecated
public DefaultSqlSession(Configuration configuration, Executor executor, boolean autoCommit) {
this(configuration, executor);
}
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
MappedStatement ms = configuration.getMappedStatement(statement);
// 这里调用了 querey() 方法
List<E> result = executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
return result;
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
}