《设计模式:可复用面向对象软件的基础》这本书自 1994 年问世以来一直被视为面向对象理论的经典著作,尤其在 Java 世界,无数 Java 程序员将其视为软件开发架构设计的金科玉律。26 年以来,对于这本书里 23 种设计模式讨论与解读的书籍出版了一本又一本。令人遗憾的是,从 1994 年出版时算起,Java 已经从 1.0 版本迭代到了 14,而设计模式再版了几十次也并没有在内容上和 Java 技术的发展相适应。
老祖宗 26 年前留下的代码,在今天函数式编程满天飞的时代还能呼风唤雨经久不衰吗?在软件编程这个快速迭代的领域里,答案无疑是否定的,大神 Joshua Bloch 在近年最新的 Effective Java 第三版中就对常用的模板模式提出了批评:
The Template Method pattern [..] is far less attractive. The modern alternative is to provide a static factory or constructor that accepts a function object to achieve the same effect.
本文就试图在 Java 8 语境下重新审视模板模式的设计与实现。
在经典的 GOF 23 种设计模式中,模板模式(或
称
模板方法模式)因为简单明了、实现方便,很快就成为了在日常开发中最受欢迎的设计模式之一,在各种系统实现中被广泛使用。
模板模式最常见的应用场景在处理流程算法相对固定的一类业务中,提炼出算法骨架和原子动作集合,通过骨架代码(模板模式中成为模板方法)对原子动作进行组装,形成最终的业务逻辑实现。在面对后续不同的子业务场景时,只需要继承实现不同的子类,覆盖对应的差异性原子动作,即可很快的完成对一个子业务场景的封装实现。
例如,一个业务可以分成三步,那么可以提炼出父类 AbstractProcessor
public abstract class AbstractProcessor {
public final void process() {
step1();
step2();
step3();
}
public abstract void step1();
public abstract void step2();
public abstract void step3();
}
流程逻辑被封装在父抽象类的 process 方法中,客户端只需要调用父类的 process 骨架方法即可实现对应的业务逻辑:
public class ClientLogic {
private AbstractProcessor processor;
public void foo() {
processor.process();
}
}
public class ConcreteProcessor1 extends AbstractProcessor {
@Override
public void step1() {
System.out.println("Step1 Logic in ConcreteProcessor1");
}
@Override
public void step2() {
System.out.println("Step2 Logic in ConcreteProcessor1");
}
@Override
public void step3() {
System.out.println("Step3 Logic in ConcreteProcessor1");
}
}
假设现在有新的业务场景,第一步和第二步依然和 ConcreteProcessor1 的逻辑一致,仅第三步略有不同,此时就可以通过继承的方式编写 ConcreteProcessor2 覆盖 step3 方法:
public class ConcreteProcessor2 extends ConcreteProcessor1{
@Override
public void step3() {
System.out.println("Step3 Logic in ConcreteProcessor2");
}
}
这样一来,所有流程逻辑和相对稳定的原子动作都可以进行复用,在应对不同的业务场景时,只需要覆盖对应的原子动作就可以快速实现,提高了代码复用率,降低了维护成本。
老祖宗的代码看起来确实很香,然而在实际开发使用过程中模板模式有什么问题呢?
以我参与过的一个项目为例。
该项目需要对接上千家外部单位,虽然对接单位众多,但是业务流程相对固定,设计开发人员很快就意识到可以通过模板模式对整个业务流程进行封装,通过对业务流程的梳理提炼,很快设计了一个父类(示例代码):
public abstract class AbstractProcessor {
public final void process() {
step1();
step2();
step3();
step4();
step5();
}
public abstract void step1();
public abstract void step2();
public abstract void step3();
public abstract void step4();
public abstract void step5();
}
public class DefaultProcessor extends AbstractProcessor {
@Override
public void step1() {
System.out.println("default step1");
}
@Override
public void step2() {
System.out.println("default step2");
}
@Override
public void step3() {
System.out.println("default step3");
}
@Override
public void step4() {
System.out.println("default step4");
}
@Override
public void step5() {
System.out.println("default step5");
}
}
接下来对于第一家外部对接单位 A,分析其业务逻辑发现只需要修改默认实现的第一步即可,于是类图变成下面这样:
接着开始实现外部单位 B,发现只需要修改默认实现的第二步,于是类图变成:
在对接外部单位 C 时发现,其第一步逻辑与外部单位 A 类似,因此可以进一步继承复用,于是形成如下的类图结构:
对接了几十家外部单位后,类图结构变成了一颗茁壮的树:
这时问题出现了,开发人员很快发现随着对接单位的增多,模板树状结构越来越复杂,随之而来的问题是:
1.对于任意一个叶子节点的模板,它的 5 个步骤原子方法逻辑分散在整个继承逻辑的父类之上,单独阅读叶子节点的代码根本无法理解完整的模板逻辑。
2.要加入一家新的外部单位,如果想要正确实现逻辑复用,就必须全面分析模板树,找到对应的继承挂载点。
3.如果交付时间紧张,很难完成对模板树的梳理,取而代之的是从一个最熟悉的叶子节点开始挂载,这样就产生了破窗效应,后续的模板大量倾斜挂载在某个叶子模板之下,完全背离了原有设计。
回顾一下 GOF 的设计模式,其中有两条最为重要的原则:
1.Program to interface, not implementation 面向接口编程
2.Favor object composition over inheritance 聚合优于继承
而恰恰是 GOF 的模板模式,从一开始就违背了自己定下的原则,通过抽象类继承的方式进行实现。模板模式的这种实现方式在小规模的模板复用时尚且可以接受,一旦模板数量上升,整个模板继承的可维护性就会严重下降。在上面实际项目的实践中,正是违反了聚合优于继承的原则,导致整个设计在模板数量增长时,失去了可维护性,背离了设计目标。
观察一下现实生活就很容易发现聚合优于继承的情况。例如去西餐店吃饭,点餐时服务员会拿出菜单让顾客在前菜、主菜、饮料、甜点上分别选择,这就是一种典型的聚合实现的模板模式,前菜、主菜、饮料、甜点对应模板模式的原子方法,最终形成的菜单对应模板模式的模板方法。没有西餐店会提供一个基于继承的菜单服务,告诉客户如果您要将饮料从威士忌换成葡萄酒就需要选取套餐 5 而不是套餐 4。
那么聚合到底为什么优于继承呢?这就需要从面向对象理论中的耦合度计算说起。
一个有趣的经验是,在我参与的很多面试中,如果问面试者为什么使用设计模式,对方会很快告诉你这是为了“解耦”,但是如果进一步追问”这两个相关的类,耦合到底有多紧”时,绝大部分面试者都是以下两种表情:
![]()
耦合度成为了开发者口中皇帝的新装式的概念,所有人都知道需要松耦合,但是耦合如何度量、到底如何松耦合、一个代码修改是消除耦合还是增加耦合这类问题大家往往一知半解。事实上,面向对象基础理论中对于耦合度是有公式可以度量的。
对于任意两个存在关联关系的类 A 和 B,它们的关系无外乎下面五种:
表示类 A 和类 B 之间共享代码的行数,
表示类 B 的代码总行数,
表示类 A 和类 B 的关系系数。
对于系数
,取值范围从 0 到 1,0 表示没有关系,1 表示最大关系,可以将取值对应到 Dependency、Association、Aggregation、Composition、Inheritance 五类关系中,取值从小到大。
对于共享代码行数
,如果类 A 被类 B 继承,那么
等于类 A 所有非 private 修饰的代码行数;如果类 A 只是作为引用被类 B 调用,那么
等于类 A 所有 public 方法签名的行数。
从以上公式可以看出,在继承关系中,两个类的耦合度不仅关系系数
取值更大,而且
取值也更大,从而导致耦合度更高。
根据上面的理论分析可以看出,要优化现有的模板模式实现,其主要思想是降低耦合度。
可以通过将继承改为聚合的方式实现第一版的优化:
public class TemplatePattern1 {
private Step1Processor step1Processor;
private Step2Processor step2Processor;
private Step3Processor step3Processor;
private Step4Processor step4Processor;
private Step5Processor step5Processor;
public interface Step1Processor {
void step1();
}
public interface Step2Processor {
void step2();
}
public interface Step3Processor {
void step3();
}
public interface Step4Processor {
void step4();
}
public interface Step5Processor {
void step5();
}
public void templateMethod() {
step1Processor.step1();
step2Processor.step2();
step3Processor.step3();
step4Processor.step4();
step5Processor.step5();
}
}
在上面的代码中,将原有以继承方式实现的模板各原子方法变为通过聚合方式引入的各个处理器接口引用,注入不同的原子方法处理器就可以很容易的改变模板方法的逻辑。
通过这种实现解决前面的实际问题,对于任意一个需要对接的外部单位,只需要明确该单位在模板每个步骤中具体应该采用的处理器类型,然后简单配置组合就可以快速形成新的模板处理逻辑。
进一步的,根据前面耦合度计算的公式,如果使用方法参数而非成员变量的方式传入各步骤处理器,将会进一步降低耦合度,这样就形成了第二版优化代码:
public class TemplatePattern2 {
public interface Step1Processor {
void step1();
}
public interface Step2Processor {
void step2();
}
public interface Step3Processor {
void step3();
}
public interface Step4Processor {
void step4();
}
public interface Step5Processor {
void step5();
}
public void templateMethod(
Step1Processor step1Processor,
Step2Processor step2Processor,
Step3Processor step3Processor,
Step4Processor step4Processor,
Step5Processor step5Processor) {
step1Processor.step1();
step2Processor.step2();
step3Processor.step3();
step4Processor.step4();
step5Processor.step5();
}
}
接着,通过 Java 8 引入的接口静态方法实现静态工厂模式(这里也可以看作是函数式编程中高阶函数模式),并通过 Lambda 表达式进一步完善可读性后得到第三版代码:
public interface TemplatePattern3 {
void templateMethod(Step1Processor step1Processor,
Step2Processor step2Processor,
Step3Processor step3Processor,
Step4Processor step4Processor,
Step5Processor step5Processor);
static TemplatePattern3 getTemplate(Step1Processor step1Processor,
Step2Processor step2Processor,
Step3Processor step3Processor,
Step4Processor step4Processor,
Step5Processor step5Processor){
return (step1Processor1, step2Processor1, step3Processor1, step4Processor1, step5Processor1) -> {
step1Processor1.step1();
step1Processor1.step1();
step1Processor1.step1();
step1Processor1.step1();
step1Processor1.step1();
};
}
}
这里需要补充的是,从前面的理论分析可以看出,要想尽量降低耦合度,接口的设计者就应该抛弃继承改为使用方法参数传值的方式,而承载这个思想最好的实现正是 Java 8 引入的 Lambda 表达式,因为 Lambda 表达式不拥有任何成员变量,所有的逻辑引用来源都必须是参数传入,从这个意义上来说,尽量选择 Lambda 表达式作为业务逻辑承载的主体是一种能够显著降低系统耦合度的最佳实践。
1.《设计模式》中模板模式的原始实现存在缺陷,应通过聚合优于继承的思想进行改造;
2.类耦合度可以通过公式计量,最低的耦合关系是 Dependency,最高的耦合关系是继承;
3.Lambda 表达式天然适合实现 Dependency 关系,应该尽量选择作为业务逻辑封装的实现主体。
近期活动:金科大咖 云讲坛
如何融汇科技,打造数字化转型引擎?
如何共享资源,主动赋能同业与社会?
如何共建生态,构建无界多维的生态空间?
如何创新引领,成为新金融的实践者和先行者?
38位金融科技大咖应邀云端,与你探索未来科技前沿方向。豪华专家师资队伍,精选38项高端课程,尽在优源汇!
参与方式:关注公众号,点击“金科大咖”进入直播间
![]()